diff --git a/lib/main.dart b/lib/main.dart index 7086152..e4b7414 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 pages = [ - Center(child: Text('home')), // TODO: home tab + HomeTab(), CommunitiesTab(), Center(child: Text('search')), // TODO: search tab UserProfileTab(), diff --git a/lib/pages/add_instance.dart b/lib/pages/add_instance.dart index de8bb8a..eb0f9aa 100644 --- a/lib/pages/add_instance.dart +++ b/lib/pages/add_instance.dart @@ -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; -} diff --git a/lib/pages/home_tab.dart b/lib/pages/home_tab.dart new file mode 100644 index 0000000..5464dfa --- /dev/null +++ b/lib/pages/home_tab.dart @@ -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 = {}; + 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> 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 = []; + 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> 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( + 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})'; +} diff --git a/lib/pages/inbox.dart b/lib/pages/inbox.dart new file mode 100644 index 0000000..bfb17af --- /dev/null +++ b/lib/pages/inbox.dart @@ -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, + ) + ], + ), + ), + ); +} diff --git a/lib/pages/instance.dart b/lib/pages/instance.dart index 85d7d5f..73dd575 100644 --- a/lib/pages/instance.dart +++ b/lib/pages/instance.dart @@ -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), diff --git a/lib/util/cleanup_url.dart b/lib/util/cleanup_url.dart new file mode 100644 index 0000000..6108c09 --- /dev/null +++ b/lib/util/cleanup_url.dart @@ -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; +} diff --git a/lib/util/extensions/api.dart b/lib/util/extensions/api.dart index 6e3ae8c..505cf1d 100644 --- a/lib/util/extensions/api.dart +++ b/lib/util/extensions/api.dart @@ -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]); diff --git a/lib/widgets/infinite_scroll.dart b/lib/widgets/infinite_scroll.dart index bf085d9..64b3e59 100644 --- a/lib/widgets/infinite_scroll.dart +++ b/lib/widgets/infinite_scroll.dart @@ -49,7 +49,10 @@ class InfiniteScroll extends HookWidget { useEffect(() { if (controller != null) { - controller.clear = () => data.value = []; + controller.clear = () { + data.value = []; + hasMore.current = true; + }; return controller.dispose; }