diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf62f8..507996e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Added + +- Added page with saved posts/comments. It can be accessed from the profile tab under the bookmark icon + ## v0.2.3 - 2021-02-09 Lemmur is now available on the [play store](https://play.google.com/store/apps/details?id=com.krawieck.lemmur) and [f-droid](https://f-droid.org/packages/com.krawieck.lemmur) diff --git a/lib/pages/communities_list.dart b/lib/pages/communities_list.dart index eb5d4fd..75522cf 100644 --- a/lib/pages/communities_list.dart +++ b/lib/pages/communities_list.dart @@ -30,7 +30,7 @@ class CommunitiesListPage extends StatelessWidget { ), body: SortableInfiniteList( fetcher: fetcher, - builder: (community) => Column( + itemBuilder: (community) => Column( children: [ const Divider(), CommunitiesListItem( diff --git a/lib/pages/home_tab.dart b/lib/pages/home_tab.dart index 15de21c..bf227d0 100644 --- a/lib/pages/home_tab.dart +++ b/lib/pages/home_tab.dart @@ -12,8 +12,7 @@ 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 '../widgets/sortable_infinite_list.dart'; import 'add_account.dart'; import 'inbox.dart'; @@ -262,13 +261,6 @@ class InfiniteHomeList extends HookWidget { 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 /// @@ -313,9 +305,9 @@ class InfiniteHomeList extends HookWidget { return newPosts; } - Future> Function(int, int) fetcherFromInstance( - String instanceHost, PostListingType listingType, SortType sort) => - (page, batchSize) => LemmyApiV2(instanceHost).run(GetPosts( + FetcherWithSorting fetcherFromInstance( + String instanceHost, PostListingType listingType) => + (page, batchSize, sort) => LemmyApiV2(instanceHost).run(GetPosts( type: listingType, sort: sort, page: page, @@ -323,33 +315,13 @@ class InfiniteHomeList extends HookWidget { auth: accStore.defaultTokenFor(instanceHost)?.raw, )); - return InfiniteScroll( - prepend: Column( - children: [ - PostListOptions( - sortValue: sort.value, - onSortChanged: changeSorting, - styleButton: onStyleChange != null, - ), - ], - ), - builder: (post) => Column( - children: [ - PostWidget(post), - const SizedBox(height: 20), - ], - ), - padding: EdgeInsets.zero, - fetchMore: selectedList.instanceHost == null - ? (page, limit) => - generalFetcher(page, limit, sort.value, selectedList.listingType) + return InfinitePostList( + fetcher: selectedList.instanceHost == null + ? (page, limit, sort) => + generalFetcher(page, limit, sort, selectedList.listingType) : fetcherFromInstance( - selectedList.instanceHost, - selectedList.listingType, - sort.value, - ), + selectedList.instanceHost, selectedList.listingType), controller: controller, - batchSize: 20, ); } } diff --git a/lib/pages/profile_tab.dart b/lib/pages/profile_tab.dart index 02eefd1..19cc81a 100644 --- a/lib/pages/profile_tab.dart +++ b/lib/pages/profile_tab.dart @@ -6,6 +6,7 @@ import '../hooks/stores.dart'; import '../util/goto.dart'; import '../widgets/radio_picker.dart'; import '../widgets/user_profile.dart'; +import 'saved_page.dart'; import 'settings.dart'; /// Profile page for a logged in user. The difference between this and @@ -51,6 +52,10 @@ class UserProfileTab extends HookWidget { return Scaffold( extendBodyBehindAppBar: true, appBar: AppBar( + leading: IconButton( + onPressed: () => goTo(context, (context) => SavedPage()), + icon: const Icon(Icons.bookmark), + ), title: RadioPicker( title: 'account', values: accountsStore.loggedInInstances diff --git a/lib/pages/saved_page.dart b/lib/pages/saved_page.dart new file mode 100644 index 0000000..a7c9200 --- /dev/null +++ b/lib/pages/saved_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v2.dart'; + +import '../hooks/stores.dart'; +import '../widgets/sortable_infinite_list.dart'; + +/// Page with saved posts/comments. Fetches such saved data from the default user +/// Assumes there is at least one logged in user +class SavedPage extends HookWidget { + @override + Widget build(BuildContext context) { + final accountStore = useAccountsStore(); + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Saved'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Posts'), + Tab(text: 'Comments'), + ], + ), + ), + body: TabBarView( + children: [ + InfinitePostList( + fetcher: (page, batchSize, sortType) => + LemmyApiV2(accountStore.defaultInstanceHost) + .run( + GetUserDetails( + userId: accountStore.defaultToken.payload.id, + sort: sortType, + savedOnly: true, + page: page, + limit: batchSize, + auth: accountStore.defaultToken.raw, + ), + ) + .then((value) => value.posts), + ), + InfiniteCommentList( + fetcher: (page, batchSize, sortType) => + LemmyApiV2(accountStore.defaultInstanceHost) + .run( + GetUserDetails( + userId: accountStore.defaultToken.payload.id, + sort: sortType, + savedOnly: true, + page: page, + limit: batchSize, + auth: accountStore.defaultToken.raw, + ), + ) + .then((value) => value.comments), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/search_results.dart b/lib/pages/search_results.dart index f5ed730..55390cf 100644 --- a/lib/pages/search_results.dart +++ b/lib/pages/search_results.dart @@ -103,7 +103,7 @@ class _SearchResultsList extends HookWidget { throw UnimplementedError(); } }, - builder: (data) { + itemBuilder: (data) { switch (type) { case SearchType.comments: return CommentWidget( diff --git a/lib/widgets/infinite_scroll.dart b/lib/widgets/infinite_scroll.dart index 7e4b7c4..0789307 100644 --- a/lib/widgets/infinite_scroll.dart +++ b/lib/widgets/infinite_scroll.dart @@ -20,27 +20,45 @@ class InfiniteScrollController { } } -/// `ListView.builder` with asynchronous data fetching +/// `ListView.builder` with asynchronous data fetching and no `itemCount` class InfiniteScroll extends HookWidget { + /// How many items should be fetched per call final int batchSize; + + /// Widget displayed at the bottom when InfiniteScroll is fetching final Widget loadingWidget; - final Widget Function(T data) builder; - final Future> Function(int page, int batchSize) fetchMore; + + /// Builds your widget from the fetched data + final Widget Function(T data) itemBuilder; + + /// Fetches data to be displayed. It is important to respect `batchSize`, + /// if the returned list has less than `batchSize` then the InfiniteScroll + /// is considered finished + final Future> Function(int page, int batchSize) fetcher; + final InfiniteScrollController controller; - final Widget prepend; + + /// Widget to be added at the beginning of the list + final Widget leading; + + /// Padding for the [ListView.builder] final EdgeInsetsGeometry padding; + /// Widget that will be displayed if there are no items + final Widget noItems; + const InfiniteScroll({ this.batchSize = 10, - this.prepend = const SizedBox.shrink(), + this.leading = const SizedBox.shrink(), this.padding, this.loadingWidget = const ListTile(title: Center(child: CircularProgressIndicator())), - @required this.builder, - @required this.fetchMore, + @required this.itemBuilder, + @required this.fetcher, this.controller, - }) : assert(builder != null), - assert(fetchMore != null), + this.noItems = const SizedBox.shrink(), + }) : assert(itemBuilder != null), + assert(fetcher != null), assert(batchSize > 0); @override @@ -71,14 +89,19 @@ class InfiniteScroll extends HookWidget { }, child: ListView.builder( padding: padding, - // +2 for the loading widget and prepend widget + // +2 for the loading widget and leading widget itemCount: data.value.length + 2, itemBuilder: (_, i) { if (i == 0) { - return prepend; + return leading; } i -= 1; + // if we are done but we have no data it means the list is empty + if (!hasMore.current && data.value.isEmpty) { + return Center(child: noItems); + } + // reached the bottom, fetch more if (i == data.value.length) { // if there are no more, skip @@ -89,7 +112,7 @@ class InfiniteScroll extends HookWidget { // if it's already fetching more, skip if (!isFetching.current) { isFetching.current = true; - fetchMore(page, batchSize).then((newData) { + fetcher(page, batchSize).then((newData) { // if got less than the batchSize, mark the list as done if (newData.length < batchSize) { hasMore.current = false; @@ -106,7 +129,7 @@ class InfiniteScroll extends HookWidget { } // not last element, render list item - return builder(data.value[i]); + return itemBuilder(data.value[i]); }, ), ); diff --git a/lib/widgets/sortable_infinite_list.dart b/lib/widgets/sortable_infinite_list.dart index 6f35759..b6f3265 100644 --- a/lib/widgets/sortable_infinite_list.dart +++ b/lib/widgets/sortable_infinite_list.dart @@ -9,23 +9,31 @@ import 'infinite_scroll.dart'; import 'post.dart'; import 'post_list_options.dart'; +typedef FetcherWithSorting = Future> Function( + int page, int batchSize, SortType sortType); + /// Infinite list of posts class SortableInfiniteList extends HookWidget { - final Future> Function(int page, int batchSize, SortType sortType) - fetcher; - final Widget Function(T) builder; + final FetcherWithSorting fetcher; + final Widget Function(T) itemBuilder; + final InfiniteScrollController controller; final Function onStyleChange; + final Widget noItems; const SortableInfiniteList({ @required this.fetcher, - @required this.builder, + @required this.itemBuilder, + this.controller, this.onStyleChange, + this.noItems, }) : assert(fetcher != null), - assert(builder != null); + assert(itemBuilder != null); @override Widget build(BuildContext context) { - final isc = useInfiniteScrollController(); + final defaultController = useInfiniteScrollController(); + final isc = controller ?? defaultController; + final sort = useState(SortType.active); void changeSorting(SortType newSort) { @@ -34,50 +42,61 @@ class SortableInfiniteList extends HookWidget { } return InfiniteScroll( - prepend: PostListOptions( + leading: PostListOptions( sortValue: sort.value, onSortChanged: changeSorting, styleButton: onStyleChange != null, ), - builder: builder, + itemBuilder: itemBuilder, padding: EdgeInsets.zero, - fetchMore: (page, batchSize) => fetcher(page, batchSize, sort.value), + fetcher: (page, batchSize) => fetcher(page, batchSize, sort.value), controller: isc, batchSize: 20, + noItems: noItems, ); } } class InfinitePostList extends StatelessWidget { - final Future> Function( - int page, int batchSize, SortType sortType) fetcher; + final FetcherWithSorting fetcher; + final InfiniteScrollController controller; - const InfinitePostList({@required this.fetcher}) : assert(fetcher != null); + const InfinitePostList({ + @required this.fetcher, + this.controller, + }) : assert(fetcher != null); Widget build(BuildContext context) => SortableInfiniteList( onStyleChange: () {}, - builder: (post) => Column( + itemBuilder: (post) => Column( children: [ PostWidget(post), const SizedBox(height: 20), ], ), fetcher: fetcher, + controller: controller, + noItems: const Text('there are no posts'), ); } class InfiniteCommentList extends StatelessWidget { - final Future> Function( - int page, int batchSize, SortType sortType) fetcher; + final FetcherWithSorting fetcher; + final InfiniteScrollController controller; - const InfiniteCommentList({@required this.fetcher}) : assert(fetcher != null); + const InfiniteCommentList({ + @required this.fetcher, + this.controller, + }) : assert(fetcher != null); Widget build(BuildContext context) => SortableInfiniteList( - builder: (comment) => CommentWidget( + itemBuilder: (comment) => CommentWidget( CommentTree(comment), postCreatorId: null, detached: true, ), fetcher: fetcher, + controller: controller, + noItems: const Text('there are no comments'), ); }