commit
a320dc5ca3
|
@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||
import 'hooks/stores.dart';
|
||||
import 'pages/communities_tab.dart';
|
||||
import 'pages/create_post.dart';
|
||||
import 'pages/home_tab.dart';
|
||||
import 'pages/profile_tab.dart';
|
||||
import 'stores/accounts_store.dart';
|
||||
import 'stores/config_store.dart';
|
||||
|
@ -71,7 +72,7 @@ class MyApp extends HookWidget {
|
|||
|
||||
class MyHomePage extends HookWidget {
|
||||
final List<Widget> pages = [
|
||||
Center(child: Text('home')), // TODO: home tab
|
||||
HomeTab(),
|
||||
CommunitiesTab(),
|
||||
Center(child: Text('search')), // TODO: search tab
|
||||
UserProfileTab(),
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:lemmy_api_client/lemmy_api_client.dart';
|
|||
|
||||
import '../hooks/debounce.dart';
|
||||
import '../hooks/stores.dart';
|
||||
import '../util/cleanup_url.dart';
|
||||
import '../widgets/fullscreenable_image.dart';
|
||||
|
||||
/// A page that let's user add a new instance. Pops a url of the added instance
|
||||
|
@ -26,7 +27,7 @@ class AddInstancePage extends HookWidget {
|
|||
final debounce = useDebounce(() async {
|
||||
if (prevInput == instanceController.text) return;
|
||||
|
||||
final inst = _fixInstanceUrl(instanceController.text);
|
||||
final inst = cleanUpUrl(instanceController.text);
|
||||
if (inst.isEmpty) {
|
||||
isSite.value = null;
|
||||
return;
|
||||
|
@ -47,7 +48,7 @@ class AddInstancePage extends HookWidget {
|
|||
instanceController.removeListener(debounce);
|
||||
};
|
||||
}, []);
|
||||
final inst = _fixInstanceUrl(instanceController.text);
|
||||
final inst = cleanUpUrl(instanceController.text);
|
||||
handleOnAdd() async {
|
||||
try {
|
||||
await accountsStore.addInstance(inst, assumeValid: true);
|
||||
|
@ -145,20 +146,3 @@ class AddInstancePage extends HookWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// removes protocol and trailing slash
|
||||
String _fixInstanceUrl(String inst) {
|
||||
if (inst.startsWith('https://')) {
|
||||
inst = inst.substring(8);
|
||||
}
|
||||
|
||||
if (inst.startsWith('http://')) {
|
||||
inst = inst.substring(7);
|
||||
}
|
||||
|
||||
if (inst.endsWith('/')) {
|
||||
inst = inst.substring(0, inst.length - 1);
|
||||
}
|
||||
|
||||
return inst;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,317 @@
|
|||
import 'dart:math' show max;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:lemmy_api_client/lemmy_api_client.dart';
|
||||
|
||||
import '../hooks/infinite_scroll.dart';
|
||||
import '../hooks/memo_future.dart';
|
||||
import '../hooks/stores.dart';
|
||||
import '../util/goto.dart';
|
||||
import '../widgets/bottom_modal.dart';
|
||||
import '../widgets/infinite_scroll.dart';
|
||||
import '../widgets/post.dart';
|
||||
import '../widgets/post_list_options.dart';
|
||||
import 'add_account.dart';
|
||||
import 'inbox.dart';
|
||||
|
||||
/// First thing users sees when opening the app
|
||||
/// Shows list of posts from all or just specific instances
|
||||
class HomeTab extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedList =
|
||||
useState(_SelectedList(listingType: PostListingType.subscribed));
|
||||
// TODO: needs to be an observer? for accounts changes
|
||||
final accStore = useAccountsStore();
|
||||
final isc = useInfiniteScrollController();
|
||||
final theme = Theme.of(context);
|
||||
final instancesIcons = useMemoFuture(() async {
|
||||
final map = <String, String>{};
|
||||
final instances = accStore.instances.toList(growable: false);
|
||||
final sites = await Future.wait(instances
|
||||
.map((e) => LemmyApi(e).v1.getSite().catchError((e) => null)));
|
||||
for (var i in Iterable.generate(sites.length)) {
|
||||
map[instances[i]] = sites[i].site.icon;
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
handleListChange() async {
|
||||
final val = await showModalBottomSheet<_SelectedList>(
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
pop(_SelectedList thing) => Navigator.of(context).pop(thing);
|
||||
return BottomModal(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 5),
|
||||
ListTile(
|
||||
title: Text('EVERYTHING'),
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
visualDensity:
|
||||
VisualDensity(vertical: VisualDensity.minimumDensity),
|
||||
leading: SizedBox.shrink(),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Subscribed'),
|
||||
leading: SizedBox(width: 20, height: 20),
|
||||
onTap: () => pop(
|
||||
_SelectedList(listingType: PostListingType.subscribed)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('All'),
|
||||
leading: SizedBox(width: 20, height: 20),
|
||||
onTap: () =>
|
||||
pop(_SelectedList(listingType: PostListingType.all)),
|
||||
),
|
||||
for (final instance in accStore.instances) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Divider(),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
instance.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color:
|
||||
theme.textTheme.bodyText1.color.withOpacity(0.7)),
|
||||
),
|
||||
onTap: () => goToInstance(context, instance),
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
visualDensity:
|
||||
VisualDensity(vertical: VisualDensity.minimumDensity),
|
||||
leading: (instancesIcons.hasData &&
|
||||
instancesIcons.data[instance] != null)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: SizedBox(
|
||||
width: 25,
|
||||
height: 25,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: instancesIcons.data[instance],
|
||||
height: 25,
|
||||
width: 25,
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(width: 30),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Subscribed',
|
||||
style: TextStyle(
|
||||
color: accStore.isAnonymousFor(instance)
|
||||
? theme.textTheme.bodyText1.color.withOpacity(0.4)
|
||||
: null),
|
||||
),
|
||||
onTap: accStore.isAnonymousFor(instance)
|
||||
? () => showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
AddAccountPage(instanceUrl: instance))
|
||||
: () => pop(_SelectedList(
|
||||
listingType: PostListingType.subscribed,
|
||||
instanceUrl: instance,
|
||||
)),
|
||||
leading: SizedBox(width: 20),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('All'),
|
||||
onTap: () => pop(_SelectedList(
|
||||
listingType: PostListingType.all,
|
||||
instanceUrl: instance,
|
||||
)),
|
||||
leading: SizedBox(width: 20),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (val != null) {
|
||||
selectedList.value = val;
|
||||
isc.clear();
|
||||
}
|
||||
}
|
||||
|
||||
final title = () {
|
||||
final first = selectedList.value.listingType == PostListingType.subscribed
|
||||
? 'Subscribed'
|
||||
: 'All';
|
||||
final last = selectedList.value.instanceUrl == null
|
||||
? ''
|
||||
: '@${selectedList.value.instanceUrl}';
|
||||
return '$first$last';
|
||||
}();
|
||||
|
||||
return Scaffold(
|
||||
// TODO: make appbar autohide when scrolling down
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.notifications),
|
||||
onPressed: () => goTo(context, (_) => InboxPage()),
|
||||
)
|
||||
],
|
||||
centerTitle: true,
|
||||
title: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
primary: theme.buttonColor,
|
||||
textStyle: theme.primaryTextTheme.headline6,
|
||||
),
|
||||
onPressed: handleListChange,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.primaryTextTheme.headline6,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: theme.primaryTextTheme.headline6.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: InfiniteHomeList(
|
||||
controller: isc,
|
||||
selectedList: selectedList.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Infinite list of posts
|
||||
class InfiniteHomeList extends HookWidget {
|
||||
final Function onStyleChange;
|
||||
final InfiniteScrollController controller;
|
||||
final _SelectedList selectedList;
|
||||
InfiniteHomeList({
|
||||
@required this.selectedList,
|
||||
this.onStyleChange,
|
||||
this.controller,
|
||||
}) : assert(selectedList != null);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accStore = useAccountsStore();
|
||||
|
||||
final sort = useState(SortType.active);
|
||||
|
||||
void changeSorting(SortType newSort) {
|
||||
sort.value = newSort;
|
||||
controller.clear();
|
||||
}
|
||||
|
||||
/// fetches post from many instances at once and combines them into a single
|
||||
/// list
|
||||
///
|
||||
/// Process of combining them works sort of like zip function in python
|
||||
Future<List<PostView>> generalFetcher(
|
||||
int page,
|
||||
int limit,
|
||||
SortType sort,
|
||||
PostListingType listingType,
|
||||
) async {
|
||||
assert(
|
||||
listingType != PostListingType.community, 'only subscribed or all');
|
||||
|
||||
final instances = () {
|
||||
if (listingType == PostListingType.all) {
|
||||
return accStore.instances;
|
||||
} else {
|
||||
return accStore.loggedInInstances;
|
||||
}
|
||||
}();
|
||||
|
||||
final futures =
|
||||
instances.map((instanceUrl) => LemmyApi(instanceUrl).v1.getPosts(
|
||||
type: listingType,
|
||||
sort: sort,
|
||||
page: page,
|
||||
limit: limit,
|
||||
auth: accStore.defaultTokenFor(instanceUrl)?.raw,
|
||||
));
|
||||
final posts = await Future.wait(futures);
|
||||
final newPosts = <PostView>[];
|
||||
for (final i
|
||||
in Iterable.generate(posts.map((e) => e.length).reduce(max))) {
|
||||
for (final el in posts) {
|
||||
if (el.elementAt(i) != null) {
|
||||
newPosts.add(el[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newPosts;
|
||||
}
|
||||
|
||||
Future<List<PostView>> Function(int, int) fetcherFromInstance(
|
||||
String instanceUrl, PostListingType listingType, SortType sort) =>
|
||||
(page, batchSize) => LemmyApi(instanceUrl).v1.getPosts(
|
||||
type: listingType,
|
||||
sort: sort,
|
||||
page: page,
|
||||
limit: batchSize,
|
||||
auth: accStore.defaultTokenFor(instanceUrl)?.raw,
|
||||
);
|
||||
|
||||
return InfiniteScroll<PostView>(
|
||||
prepend: Column(
|
||||
children: [
|
||||
PostListOptions(
|
||||
onChange: changeSorting,
|
||||
defaultSort: SortType.active,
|
||||
styleButton: onStyleChange != null,
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (post) => Column(
|
||||
children: [
|
||||
Post(post),
|
||||
SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
fetchMore: selectedList.instanceUrl == null
|
||||
? (page, limit) =>
|
||||
generalFetcher(page, limit, sort.value, selectedList.listingType)
|
||||
: fetcherFromInstance(
|
||||
selectedList.instanceUrl,
|
||||
selectedList.listingType,
|
||||
sort.value,
|
||||
),
|
||||
controller: controller,
|
||||
batchSize: 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedList {
|
||||
final String instanceUrl;
|
||||
final PostListingType listingType;
|
||||
_SelectedList({
|
||||
@required this.listingType,
|
||||
this.instanceUrl,
|
||||
});
|
||||
|
||||
String toString() =>
|
||||
'SelectedList({instanceUrl: $instanceUrl, listingType: $listingType})';
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
class InboxPage extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'🚧 WORK IN PROGRESS 🚧',
|
||||
style: Theme.of(context).textTheme.headline5,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -353,7 +353,7 @@ class _AboutTab extends HookWidget {
|
|||
),
|
||||
),
|
||||
if (commSnap.hasData)
|
||||
...commSnap.data.getRange(0, 6).map((e) => ListTile(
|
||||
...commSnap.data.take(6).map((e) => ListTile(
|
||||
onTap: () =>
|
||||
goToCommunity.byId(context, e.instanceUrl, e.id),
|
||||
title: Text(e.name),
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
/// Strips protocol, 'www.', and trailing '/' from [url] aka. cleans it up
|
||||
String cleanUpUrl(String url) {
|
||||
if (url.startsWith('https://')) {
|
||||
url = url.substring(8);
|
||||
}
|
||||
if (url.startsWith('www.')) {
|
||||
url = url.substring(4);
|
||||
}
|
||||
if (url.endsWith('/')) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:lemmy_api_client/lemmy_api_client.dart';
|
||||
|
||||
import '../cleanup_url.dart';
|
||||
|
||||
// Extensions to lemmy api objects which give a [.instanceUrl] getter
|
||||
// allowing for a convenient way of knowing from which instance did this
|
||||
// object come from
|
||||
|
@ -7,25 +9,27 @@ import 'package:lemmy_api_client/lemmy_api_client.dart';
|
|||
// TODO: change it to something more robust? regex?
|
||||
|
||||
extension GetInstanceCommunityView on CommunityView {
|
||||
String get instanceUrl => actorId.split('/')[2];
|
||||
String get instanceUrl => _extract(actorId);
|
||||
}
|
||||
|
||||
extension GetInstanceUserView on UserView {
|
||||
String get instanceUrl => actorId.split('/')[2];
|
||||
String get instanceUrl => _extract(actorId);
|
||||
}
|
||||
|
||||
extension GetInstanceCommunityModeratorView on CommunityModeratorView {
|
||||
String get instanceUrl => userActorId.split('/')[2];
|
||||
String get instanceUrl => _extract(userActorId);
|
||||
}
|
||||
|
||||
extension GetInstancePostView on PostView {
|
||||
String get instanceUrl => apId.split('/')[2];
|
||||
String get instanceUrl => _extract(apId);
|
||||
}
|
||||
|
||||
extension GetInstanceUser on User {
|
||||
String get instanceUrl => actorId.split('/')[2];
|
||||
String get instanceUrl => _extract(actorId);
|
||||
}
|
||||
|
||||
extension GetInstanceCommentView on CommentView {
|
||||
String get instanceUrl => apId.split('/')[2];
|
||||
String get instanceUrl => _extract(apId);
|
||||
}
|
||||
|
||||
String _extract(String s) => cleanUpUrl(s.split('/')[2]);
|
||||
|
|
|
@ -49,7 +49,10 @@ class InfiniteScroll<T> extends HookWidget {
|
|||
|
||||
useEffect(() {
|
||||
if (controller != null) {
|
||||
controller.clear = () => data.value = [];
|
||||
controller.clear = () {
|
||||
data.value = [];
|
||||
hasMore.current = true;
|
||||
};
|
||||
return controller.dispose;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue