From a4ae6a20e13b3c85f87f5e9db69fb2f531d03466 Mon Sep 17 00:00:00 2001 From: Jojo Feng Date: Sat, 21 Sep 2024 21:55:16 -0700 Subject: [PATCH] feat: separate tabs for comments and stories in favorites screen. (#479) --- lib/cubits/fav/fav_cubit.dart | 17 +- lib/cubits/fav/fav_state.dart | 8 +- lib/models/item/buildable_comment.dart | 3 +- lib/models/item/comment.dart | 3 +- lib/repositories/hacker_news_repository.dart | 38 ++-- lib/repositories/sembast_repository.dart | 31 +++- lib/screens/profile/profile_screen.dart | 126 +------------ .../profile/widgets/favorites_screen.dart | 169 ++++++++++++++++++ lib/screens/profile/widgets/settings.dart | 2 +- lib/screens/profile/widgets/widgets.dart | 1 + lib/services/caches/comment_cache.dart | 13 +- pubspec.yaml | 2 +- 12 files changed, 268 insertions(+), 145 deletions(-) create mode 100644 lib/screens/profile/widgets/favorites_screen.dart diff --git a/lib/cubits/fav/fav_cubit.dart b/lib/cubits/fav/fav_cubit.dart index 3b23f9a0..cd017375 100644 --- a/lib/cubits/fav/fav_cubit.dart +++ b/lib/cubits/fav/fav_cubit.dart @@ -19,6 +19,7 @@ class FavCubit extends Cubit with Loggable { PreferenceRepository? preferenceRepository, HackerNewsRepository? hackerNewsRepository, HackerNewsWebRepository? hackerNewsWebRepository, + SembastRepository? sembastRepository, }) : _authBloc = authBloc, _authRepository = authRepository ?? locator.get(), _preferenceRepository = @@ -27,6 +28,8 @@ class FavCubit extends Cubit with Loggable { hackerNewsRepository ?? locator.get(), _hackerNewsWebRepository = hackerNewsWebRepository ?? locator.get(), + _sembastRepository = + sembastRepository ?? locator.get(), super(FavState.init()) { init(); } @@ -36,8 +39,9 @@ class FavCubit extends Cubit with Loggable { final PreferenceRepository _preferenceRepository; final HackerNewsRepository _hackerNewsRepository; final HackerNewsWebRepository _hackerNewsWebRepository; + final SembastRepository _sembastRepository; late final StreamSubscription? _usernameSubscription; - static const int _pageSize = 20; + static const int _pageSize = 100; Future init() async { _usernameSubscription = _authBloc.stream @@ -55,6 +59,8 @@ class FavCubit extends Cubit with Loggable { _hackerNewsRepository .fetchItemsStream( ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), + getFromCache: (int id) => + _sembastRepository.getCachedItem(id: id), ) .listen(_onItemLoaded) .onDone(() { @@ -97,7 +103,10 @@ class FavCubit extends Cubit with Loggable { void removeFav(int id) { _preferenceRepository ..removeFav(username: username, id: id) - ..removeFav(username: '', id: id); + ..removeFav( + username: '', + id: id, + ); emit( state.copyWith( @@ -200,6 +209,7 @@ class FavCubit extends Cubit with Loggable { } void _onItemLoaded(Item item) { + _sembastRepository.cacheItem(item); emit( state.copyWith( favItems: List.from(state.favItems)..add(item), @@ -207,6 +217,9 @@ class FavCubit extends Cubit with Loggable { ); } + void switchTab() => + emit(state.copyWith(isDisplayingStories: !state.isDisplayingStories)); + @override Future close() { _usernameSubscription?.cancel(); diff --git a/lib/cubits/fav/fav_state.dart b/lib/cubits/fav/fav_state.dart index f637a1ec..5edf79de 100644 --- a/lib/cubits/fav/fav_state.dart +++ b/lib/cubits/fav/fav_state.dart @@ -7,6 +7,7 @@ class FavState extends Equatable { required this.status, required this.mergeStatus, required this.currentPage, + required this.isDisplayingStories, }); FavState.init() @@ -14,13 +15,15 @@ class FavState extends Equatable { favItems = [], status = Status.idle, mergeStatus = Status.idle, - currentPage = 0; + currentPage = 0, + isDisplayingStories = true; final List favIds; final List favItems; final Status status; final Status mergeStatus; final int currentPage; + final bool isDisplayingStories; FavState copyWith({ List? favIds, @@ -28,6 +31,7 @@ class FavState extends Equatable { Status? status, Status? mergeStatus, int? currentPage, + bool? isDisplayingStories, }) { return FavState( favIds: favIds ?? this.favIds, @@ -35,6 +39,7 @@ class FavState extends Equatable { status: status ?? this.status, mergeStatus: mergeStatus ?? this.mergeStatus, currentPage: currentPage ?? this.currentPage, + isDisplayingStories: isDisplayingStories ?? this.isDisplayingStories, ); } @@ -45,5 +50,6 @@ class FavState extends Equatable { currentPage, favIds, favItems, + isDisplayingStories, ]; } diff --git a/lib/models/item/buildable_comment.dart b/lib/models/item/buildable_comment.dart index b75cee41..5ec6e088 100644 --- a/lib/models/item/buildable_comment.dart +++ b/lib/models/item/buildable_comment.dart @@ -41,6 +41,7 @@ class BuildableComment extends Comment with Buildable { BuildableComment copyWith({ int? level, bool? hidden, + int? kid, }) { return BuildableComment( id: id, @@ -49,7 +50,7 @@ class BuildableComment extends Comment with Buildable { score: score, by: by, text: text, - kids: kids, + kids: kid == null ? kids : [...kids, kid], dead: dead, deleted: deleted, hidden: hidden ?? this.hidden, diff --git a/lib/models/item/comment.dart b/lib/models/item/comment.dart index 31cf12ed..1a61bad6 100644 --- a/lib/models/item/comment.dart +++ b/lib/models/item/comment.dart @@ -36,6 +36,7 @@ class Comment extends Item { Comment copyWith({ int? level, bool? hidden, + int? kid, }) { return Comment( id: id, @@ -44,7 +45,7 @@ class Comment extends Item { score: score, by: by, text: text, - kids: kids, + kids: kid == null ? kids : [...kids, kid], dead: dead, deleted: deleted, hidden: hidden ?? this.hidden, diff --git a/lib/repositories/hacker_news_repository.dart b/lib/repositories/hacker_news_repository.dart index 3be419f6..b90c1c6f 100644 --- a/lib/repositories/hacker_news_repository.dart +++ b/lib/repositories/hacker_news_repository.dart @@ -302,24 +302,32 @@ class HackerNewsRepository with Loggable { /// Fetch a list of [Item] based on ids and return results /// using a stream. - Stream fetchItemsStream({required List ids}) async* { + Stream fetchItemsStream({ + required List ids, + Future Function(int)? getFromCache, + }) async* { for (final int id in ids) { - final Item? item = - await _fetchItemJson(id).then((Map? json) async { - if (json == null) return null; + final Item? cachedItem = await getFromCache?.call(id); + if (cachedItem != null) { + yield cachedItem; + } else { + final Item? item = + await _fetchItemJson(id).then((Map? json) async { + if (json == null) return null; - if (json.isStory) { - final Story story = Story.fromJson(json); - return story; - } else if (json.isComment) { - final Comment comment = Comment.fromJson(json); - return comment; - } - return null; - }); + if (json.isStory) { + final Story story = Story.fromJson(json); + return story; + } else if (json.isComment) { + final Comment comment = Comment.fromJson(json); + return comment; + } + return null; + }); - if (item != null) { - yield item; + if (item != null) { + yield item; + } } } } diff --git a/lib/repositories/sembast_repository.dart b/lib/repositories/sembast_repository.dart index 4f08d956..4c05ffe3 100644 --- a/lib/repositories/sembast_repository.dart +++ b/lib/repositories/sembast_repository.dart @@ -67,7 +67,7 @@ class SembastRepository with Loggable { return db; } - //#region Cached comments for time machine feature. + //#region Cached comments for time machine feature and favorites screen. Future> cacheComment(Comment comment) async { final Database db = _database ?? await initializeDatabase(); final StoreRef> store = @@ -89,7 +89,34 @@ class SembastRepository with Loggable { } } - Future deleteAllCachedComments() async { + Future> cacheItem(Item item) async { + final Database db = _database ?? await initializeDatabase(); + final StoreRef> store = + intMapStoreFactory.store(_cachedCommentsKey); + return store.record(item.id).put(db, item.toJson()); + } + + Future getCachedItem({required int id}) async { + final Database db = _database ?? await initializeDatabase(); + final StoreRef> store = + intMapStoreFactory.store(_cachedCommentsKey); + final RecordSnapshot>? snapshot = + await store.record(id).getSnapshot(db); + if (snapshot != null) { + final bool isStory = snapshot['type'] == 'story'; + if (isStory) { + final Story story = Story.fromJson(snapshot.value); + return story; + } else { + final Comment comment = Comment.fromJson(snapshot.value); + return comment; + } + } else { + return null; + } + } + + Future deleteAllCachedItems() async { final Database db = _database ?? await initializeDatabase(); final StoreRef> store = intMapStoreFactory.store(_cachedCommentsKey); diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index e0c4683b..cc7c1164 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -1,7 +1,5 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:go_router/go_router.dart'; import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/config/constants.dart'; @@ -130,124 +128,12 @@ class _ProfileScreenState extends State top: Dimens.pt50, child: Visibility( visible: pageType == PageType.fav, - child: BlocConsumer( - listener: (BuildContext context, FavState favState) { - if (favState.status == Status.success) { - refreshControllerFav - ..refreshCompleted() - ..loadComplete(); - } - }, - buildWhen: (FavState previous, FavState current) => - previous.favItems.length != current.favItems.length, - builder: (BuildContext context, FavState favState) { - Widget? header() => authState.isLoggedIn - ? BlocSelector( - selector: (FavState state) => state.mergeStatus, - builder: ( - BuildContext context, - Status status, - ) { - return TextButton( - onPressed: () => - context.read().merge( - onError: (AppException e) => - showErrorSnackBar(e.message), - onSuccess: () => showSnackBar( - content: '''Sync completed.''', - ), - ), - child: status == Status.inProgress - ? const SizedBox( - height: Dimens.pt12, - width: Dimens.pt12, - child: - CustomCircularProgressIndicator( - strokeWidth: Dimens.pt2, - ), - ) - : const Text('Sync from Hacker News'), - ); - }, - ) - : null; - - if (favState.favItems.isEmpty && - favState.status != Status.inProgress) { - return Column( - children: [ - header() ?? const SizedBox.shrink(), - const CenteredMessageView( - content: - 'Your favorite stories will show up here.' - '\nThey will be synced to your Hacker ' - 'News account if you are logged in.', - ), - ], - ); - } - - return BlocBuilder( - buildWhen: ( - PreferenceState previous, - PreferenceState current, - ) => - previous.isComplexStoryTileEnabled != - current.isComplexStoryTileEnabled || - previous.isMetadataEnabled != - current.isMetadataEnabled || - previous.isUrlEnabled != current.isUrlEnabled, - builder: ( - BuildContext context, - PreferenceState prefState, - ) { - return ItemsListView( - showWebPreviewOnStoryTile: - prefState.isComplexStoryTileEnabled, - showMetadataOnStoryTile: - prefState.isMetadataEnabled, - showFavicon: prefState.isFaviconEnabled, - showUrl: prefState.isUrlEnabled, - useSimpleTileForStory: true, - refreshController: refreshControllerFav, - items: favState.favItems, - onRefresh: () { - HapticFeedbackUtil.light(); - context.read().refresh(); - }, - onLoadMore: () { - context.read().loadMore(); - }, - onTap: (Item item) => goToItemScreen( - args: ItemScreenArgs(item: item), - ), - header: header(), - itemBuilder: (Widget child, Item item) { - return Slidable( - dragStartBehavior: DragStartBehavior.start, - startActionPane: ActionPane( - motion: const BehindMotion(), - children: [ - SlidableAction( - onPressed: (_) { - HapticFeedbackUtil.light(); - context - .read() - .removeFav(item.id); - }, - backgroundColor: Palette.red, - foregroundColor: Palette.white, - icon: Icons.close, - ), - ], - ), - child: child, - ); - }, - ); - }, - ); - }, + child: FavoritesScreen( + refreshController: refreshControllerFav, + authState: authState, + onItemTap: (Item item) => goToItemScreen( + args: ItemScreenArgs(item: item), + ), ), ), ), diff --git a/lib/screens/profile/widgets/favorites_screen.dart b/lib/screens/profile/widgets/favorites_screen.dart new file mode 100644 index 00000000..fb70433b --- /dev/null +++ b/lib/screens/profile/widgets/favorites_screen.dart @@ -0,0 +1,169 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:hacki/blocs/auth/auth_bloc.dart'; +import 'package:hacki/cubits/cubits.dart'; +import 'package:hacki/extensions/extensions.dart'; +import 'package:hacki/models/models.dart'; +import 'package:hacki/screens/profile/widgets/centered_message_view.dart'; +import 'package:hacki/screens/widgets/widgets.dart'; +import 'package:hacki/styles/styles.dart'; +import 'package:hacki/utils/utils.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class FavoritesScreen extends StatelessWidget { + const FavoritesScreen({ + required this.refreshController, + required this.authState, + required this.onItemTap, + super.key, + }); + + final RefreshController refreshController; + final AuthState authState; + final void Function(Item) onItemTap; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (BuildContext context, FavState favState) { + if (favState.status == Status.success) { + refreshController + ..refreshCompleted() + ..loadComplete(); + } + }, + buildWhen: (FavState previous, FavState current) => + previous.favItems.length != current.favItems.length || + previous.isDisplayingStories != current.isDisplayingStories, + builder: (BuildContext context, FavState favState) { + Widget? header() => authState.isLoggedIn + ? Column( + children: [ + BlocSelector( + selector: (FavState state) => state.mergeStatus, + builder: ( + BuildContext context, + Status status, + ) { + return TextButton( + onPressed: () => context.read().merge( + onError: (AppException e) => + context.showErrorSnackBar(e.message), + onSuccess: () => context.showSnackBar( + content: '''Sync completed.''', + ), + ), + child: status == Status.inProgress + ? const SizedBox( + height: Dimens.pt12, + width: Dimens.pt12, + child: CustomCircularProgressIndicator( + strokeWidth: Dimens.pt2, + ), + ) + : const Text( + 'Sync from Hacker News', + ), + ); + }, + ), + Row( + children: [ + const SizedBox( + width: Dimens.pt12, + ), + CustomChip( + selected: favState.isDisplayingStories, + label: 'Story', + onSelected: (_) => context.read().switchTab(), + ), + const SizedBox( + width: Dimens.pt12, + ), + CustomChip( + selected: !favState.isDisplayingStories, + label: 'Comment', + onSelected: (_) => context.read().switchTab(), + ), + ], + ), + ], + ) + : null; + + if (favState.favItems.isEmpty && favState.status != Status.inProgress) { + return Column( + children: [ + header() ?? const SizedBox.shrink(), + const CenteredMessageView( + content: 'Your favorite stories will show up here.' + '\nThey will be synced to your Hacker ' + 'News account if you are logged in.', + ), + ], + ); + } + + return BlocBuilder( + buildWhen: ( + PreferenceState previous, + PreferenceState current, + ) => + previous.isComplexStoryTileEnabled != + current.isComplexStoryTileEnabled || + previous.isMetadataEnabled != current.isMetadataEnabled || + previous.isUrlEnabled != current.isUrlEnabled, + builder: ( + BuildContext context, + PreferenceState prefState, + ) { + return ItemsListView( + showWebPreviewOnStoryTile: prefState.isComplexStoryTileEnabled, + showMetadataOnStoryTile: prefState.isMetadataEnabled, + showFavicon: prefState.isFaviconEnabled, + showUrl: prefState.isUrlEnabled, + useSimpleTileForStory: true, + refreshController: refreshController, + items: favState.isDisplayingStories + ? favState.favItems.whereType().toList(growable: false) + : favState.favItems + .whereType() + .toList(growable: false), + onRefresh: () { + HapticFeedbackUtil.light(); + context.read().refresh(); + }, + onLoadMore: () { + context.read().loadMore(); + }, + onTap: onItemTap, + header: header(), + itemBuilder: (Widget child, Item item) { + return Slidable( + dragStartBehavior: DragStartBehavior.start, + startActionPane: ActionPane( + motion: const BehindMotion(), + children: [ + SlidableAction( + onPressed: (_) { + HapticFeedbackUtil.light(); + context.read().removeFav(item.id); + }, + backgroundColor: Palette.red, + foregroundColor: Palette.white, + icon: Icons.close, + ), + ], + ), + child: child, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/screens/profile/widgets/settings.dart b/lib/screens/profile/widgets/settings.dart index e033cbb0..71708239 100644 --- a/lib/screens/profile/widgets/settings.dart +++ b/lib/screens/profile/widgets/settings.dart @@ -606,7 +606,7 @@ class _SettingsState extends State with ItemActionMixin, Loggable { context.pop(); locator .get() - .deleteAllCachedComments() + .deleteAllCachedItems() .whenComplete( locator.get().deleteAll, ) diff --git a/lib/screens/profile/widgets/widgets.dart b/lib/screens/profile/widgets/widgets.dart index 17ac6eda..14ffaf50 100644 --- a/lib/screens/profile/widgets/widgets.dart +++ b/lib/screens/profile/widgets/widgets.dart @@ -1,5 +1,6 @@ export 'centered_message_view.dart'; export 'enter_offline_mode_list_tile.dart'; +export 'favorites_screen.dart'; export 'inbox_view.dart'; export 'offline_list_tile.dart'; export 'settings.dart'; diff --git a/lib/services/caches/comment_cache.dart b/lib/services/caches/comment_cache.dart index c8cf551f..d756b0cb 100644 --- a/lib/services/caches/comment_cache.dart +++ b/lib/services/caches/comment_cache.dart @@ -3,7 +3,18 @@ import 'package:hacki/models/models.dart' show Comment; class CommentCache { static final Map _comments = {}; - void cacheComment(Comment comment) => _comments[comment.id] = comment; + void cacheComment(Comment comment) { + _comments[comment.id] = comment; + + /// Comments fetched from `HackerNewsWebRepository` doesn't have populated + /// `kids` field, this is why we need to update that of the parent + /// comment here. + final int parentId = comment.parent; + final Comment? parent = _comments[parentId]; + if (parent == null || parent.kids.contains(comment.id)) return; + final Comment updatedParent = parent.copyWith(kid: comment.id); + _comments[parentId] = updatedParent; + } Comment? getComment(int id) => _comments[id]; diff --git a/pubspec.yaml b/pubspec.yaml index c053f49b..0301322a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: hacki description: A Hacker News reader. -version: 2.9.3+151 +version: 2.9.4+152 publish_to: none environment: