From 689965c58293b056454cdd35a01e8d7c2f3826df Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 24 Dec 2025 00:45:35 +0800 Subject: [PATCH] :sparkles: Categories and tags in subscription filter --- lib/screens/explore.dart | 46 ++++- .../posts/post_subscription_filter.dart | 192 ++++++++++++++---- 2 files changed, 186 insertions(+), 52 deletions(-) diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 5cd78a01..0e555642 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -42,6 +42,8 @@ class ExploreScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentFilter = useState(null); final selectedPublisherNames = useState>([]); + final selectedCategoryIds = useState>([]); + final selectedTagIds = useState>([]); final notifier = ref.watch(activityListProvider.notifier); useEffect(() { @@ -240,6 +242,8 @@ class ExploreScreen extends HookConsumerWidget { selectedDay, currentFilter.value, selectedPublisherNames, + selectedCategoryIds, + selectedTagIds, ) : _buildNarrowBody(context, ref, currentFilter.value), ), @@ -295,13 +299,19 @@ class ExploreScreen extends HookConsumerWidget { Widget _buildPostList( BuildContext context, WidgetRef ref, - List selectedPublisherIds, + List selectedPublishers, + List selectedCategories, + List selectedTags, ) { return SliverPostList( queryKey: 'explore_filtered', - query: PostListQuery(publishers: selectedPublisherIds), + query: PostListQuery( + publishers: selectedPublishers, + categories: selectedCategories, + tags: selectedTags, + ), padding: EdgeInsets.zero, - itemPadding: EdgeInsets.zero, + itemPadding: const EdgeInsets.only(bottom: 8), ); } @@ -315,12 +325,23 @@ class ExploreScreen extends HookConsumerWidget { AsyncValue> events, ValueNotifier selectedDay, String? currentFilter, - ValueNotifier> selectedPublisherNames, + ValueNotifier> selectedPublishers, + ValueNotifier> selectedCategories, + ValueNotifier> selectedTags, ) { // Use post list when subscription filter is active and publishers are selected - final usePostList = selectedPublisherNames.value.isNotEmpty; + final usePostList = + selectedPublishers.value.isNotEmpty || + selectedCategories.value.isNotEmpty || + selectedTags.value.isNotEmpty; final bodyView = usePostList - ? _buildPostList(context, ref, selectedPublisherNames.value) + ? _buildPostList( + context, + ref, + selectedPublishers.value, + selectedCategories.value, + selectedTags.value, + ) : _buildActivityList(context, ref); final notifier = usePostList @@ -357,10 +378,17 @@ class ExploreScreen extends HookConsumerWidget { children: [ Gap(4 + MediaQuery.paddingOf(context).top), PostSubscriptionFilterWidget( - initialSelectedPublisherNames: - selectedPublisherNames.value, + initialSelectedPublishers: selectedPublishers.value, + initialSelectedCategories: selectedCategories.value, + initialSelectedTags: selectedTags.value, onSelectedPublishersChanged: (names) { - selectedPublisherNames.value = names; + selectedPublishers.value = names; + }, + onSelectedCategoriesChanged: (ids) { + selectedCategories.value = ids; + }, + onSelectedTagsChanged: (ids) { + selectedTags.value = ids; }, ), ], diff --git a/lib/widgets/posts/post_subscription_filter.dart b/lib/widgets/posts/post_subscription_filter.dart index 223781d4..f3e51449 100644 --- a/lib/widgets/posts/post_subscription_filter.dart +++ b/lib/widgets/posts/post_subscription_filter.dart @@ -38,27 +38,42 @@ Future> categoriesSubscriptions(Ref ref) async { } class PostSubscriptionFilterWidget extends HookConsumerWidget { - final List initialSelectedPublisherNames; + final List initialSelectedPublishers; + final List initialSelectedCategories; + final List initialSelectedTags; final ValueChanged> onSelectedPublishersChanged; + final ValueChanged> onSelectedCategoriesChanged; + final ValueChanged> onSelectedTagsChanged; final bool hideSearch; const PostSubscriptionFilterWidget({ super.key, - required this.initialSelectedPublisherNames, + required this.initialSelectedPublishers, + required this.initialSelectedCategories, + required this.initialSelectedTags, required this.onSelectedPublishersChanged, + required this.onSelectedCategoriesChanged, + required this.onSelectedTagsChanged, this.hideSearch = false, }); @override Widget build(BuildContext context, WidgetRef ref) { - final selectedPublisherNames = useState>( - initialSelectedPublisherNames, + final selectedPublishers = useState>( + initialSelectedPublishers, ); + final selectedCategories = useState>( + initialSelectedCategories, + ); + final selectedTags = useState>(initialSelectedTags); - final subscriptionsAsync = ref.watch(publishersSubscriptionsProvider); + final publishersAsync = ref.watch(publishersSubscriptionsProvider); + final categoriesAsync = ref.watch(categoriesSubscriptionsProvider); void updateSelection() { - onSelectedPublishersChanged(selectedPublisherNames.value); + onSelectedPublishersChanged(selectedPublishers.value); + onSelectedCategoriesChanged(selectedCategories.value); + onSelectedTagsChanged(selectedTags.value); } return Card( @@ -77,7 +92,9 @@ class PostSubscriptionFilterWidget extends HookConsumerWidget { ], ).padding(horizontal: 16, top: 12), const Gap(12), - subscriptionsAsync.when( + + // Publishers Section + publishersAsync.when( data: (subscriptions) { if (subscriptions.isEmpty) { return Center( @@ -89,43 +106,53 @@ class PostSubscriptionFilterWidget extends HookConsumerWidget { } return Column( - children: subscriptions.map((subscription) { - final isSelected = selectedPublisherNames.value.contains( - subscription.publisher.name, - ); - final publisher = subscription.publisher; + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'publishers'.tr(), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ).padding(bottom: 8, horizontal: 16), + ...subscriptions.map((subscription) { + final isSelected = selectedPublishers.value.contains( + subscription.publisher.name, + ); + final publisher = subscription.publisher; - return CheckboxListTile( - controlAffinity: ListTileControlAffinity.trailing, - title: Text(publisher.nick), - subtitle: Text('@${publisher.name}'), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - value: isSelected, - onChanged: (value) { - if (value == true) { - selectedPublisherNames.value = [ - ...selectedPublisherNames.value, - subscription.publisher.name, - ]; - } else { - selectedPublisherNames.value = selectedPublisherNames - .value - .where( - (name) => name != subscription.publisher.name, - ) - .toList(); - } - updateSelection(); - }, - dense: true, - secondary: ProfilePictureWidget( - file: subscription.publisher.picture, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - ); - }).toList(), + return CheckboxListTile( + controlAffinity: ListTileControlAffinity.trailing, + title: Text(publisher.nick), + subtitle: Text('@${publisher.name}'), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + value: isSelected, + onChanged: (value) { + if (value == true) { + selectedPublishers.value = [ + ...selectedPublishers.value, + subscription.publisher.name, + ]; + } else { + selectedPublishers.value = selectedPublishers.value + .where( + (name) => name != subscription.publisher.name, + ) + .toList(); + } + updateSelection(); + }, + dense: true, + secondary: ProfilePictureWidget( + file: subscription.publisher.picture, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + ), + ); + }), + ], ); }, loading: () => const Center( @@ -141,6 +168,85 @@ class PostSubscriptionFilterWidget extends HookConsumerWidget { ), ), ), + + const Divider(height: 1).padding(vertical: 8), + + // Categories Section + categoriesAsync.when( + data: (subscriptions) { + if (subscriptions.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'categoriesAndTags'.tr(), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ).padding(bottom: 8, horizontal: 16), + ...subscriptions.map((subscription) { + final category = subscription.category; + final tag = subscription.tag; + final slug = category?.slug ?? tag?.slug; + final displayTitle = + category?.categoryDisplayTitle ?? + tag?.name ?? + slug ?? + ''; + final isCategorySelected = selectedCategories.value + .contains(slug); + final isTagSelected = selectedTags.value.contains(slug); + + return CheckboxListTile( + controlAffinity: ListTileControlAffinity.trailing, + title: Text(displayTitle), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + secondary: category != null + ? Icon(Symbols.category) + : Icon(Symbols.tag), + value: category != null + ? isCategorySelected + : isTagSelected, + onChanged: (value) { + if (value == true) { + if (category != null) { + selectedCategories.value = [ + ...selectedCategories.value, + slug!, + ]; + } else if (tag != null) { + selectedTags.value = [...selectedTags.value, slug!]; + } + } else { + if (category != null) { + selectedCategories.value = selectedCategories.value + .where((id) => id != slug) + .toList(); + } else if (tag != null) { + selectedTags.value = selectedTags.value + .where((id) => id != slug) + .toList(); + } + } + updateSelection(); + }, + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + ), + ); + }).toList(), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (error, stack) => const SizedBox.shrink(), + ), ], ), );