From d94baab877607a721b052bffc1319834d275e926 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 23 Dec 2025 01:03:46 +0800 Subject: [PATCH] :sparkles: Explore subscription filter card --- lib/pods/post/post_list.dart | 122 +++++++--- lib/pods/post/post_list.freezed.dart | 51 +++-- lib/pods/post/post_subscriptions.dart | 16 -- lib/screens/explore.dart | 90 ++++++-- lib/widgets/post/post_list.dart | 11 +- .../posts/post_subscription_filter.dart | 214 ++++++++---------- 6 files changed, 299 insertions(+), 205 deletions(-) delete mode 100644 lib/pods/post/post_subscriptions.dart diff --git a/lib/pods/post/post_list.dart b/lib/pods/post/post_list.dart index 2d82b654..0e58767c 100644 --- a/lib/pods/post/post_list.dart +++ b/lib/pods/post/post_list.dart @@ -10,6 +10,7 @@ part 'post_list.freezed.dart'; sealed class PostListQuery with _$PostListQuery { const factory PostListQuery({ String? pubName, + List? publishers, String? realm, int? type, List? categories, @@ -61,35 +62,98 @@ class PostListNotifier extends AsyncNotifier> Future> fetch() async { final client = ref.read(apiClientProvider); - final queryParams = { - 'offset': fetchedCount, - 'take': pageSize, - 'replies': currentFilter.includeReplies, - 'orderDesc': currentFilter.orderDesc, - if (currentFilter.shuffle) 'shuffle': currentFilter.shuffle, - if (currentFilter.pubName != null) 'pub': currentFilter.pubName, - if (currentFilter.realm != null) 'realm': currentFilter.realm, - if (currentFilter.type != null) 'type': currentFilter.type, - if (currentFilter.tags != null) 'tags': currentFilter.tags, - if (currentFilter.categories != null) - 'categories': currentFilter.categories, - if (currentFilter.pinned != null) 'pinned': currentFilter.pinned, - if (currentFilter.order != null) 'order': currentFilter.order, - if (currentFilter.periodStart != null) - 'periodStart': currentFilter.periodStart, - if (currentFilter.periodEnd != null) 'periodEnd': currentFilter.periodEnd, - if (currentFilter.queryTerm != null) 'query': currentFilter.queryTerm, - if (currentFilter.mediaOnly != null) 'media': currentFilter.mediaOnly, - }; + // Handle multiple publishers by making separate requests and combining results + if (currentFilter.publishers != null && + currentFilter.publishers!.isNotEmpty) { + final allPosts = []; + var totalPostsCount = 0; - final response = await client.get( - '/sphere/posts', - queryParameters: queryParams, - ); - totalCount = int.parse(response.headers.value('X-Total') ?? '0'); - return response.data - .map((json) => SnPost.fromJson(json)) - .cast() - .toList(); + for (final publisherName in currentFilter.publishers!) { + final queryParams = { + 'offset': fetchedCount, + 'take': pageSize, + 'replies': currentFilter.includeReplies, + 'orderDesc': currentFilter.orderDesc, + if (currentFilter.shuffle) 'shuffle': currentFilter.shuffle, + 'pub': publisherName, + if (currentFilter.realm != null) 'realm': currentFilter.realm, + if (currentFilter.type != null) 'type': currentFilter.type, + if (currentFilter.tags != null) 'tags': currentFilter.tags, + if (currentFilter.categories != null) + 'categories': currentFilter.categories, + if (currentFilter.pinned != null) 'pinned': currentFilter.pinned, + if (currentFilter.order != null) 'order': currentFilter.order, + if (currentFilter.periodStart != null) + 'periodStart': currentFilter.periodStart, + if (currentFilter.periodEnd != null) + 'periodEnd': currentFilter.periodEnd, + if (currentFilter.queryTerm != null) 'query': currentFilter.queryTerm, + if (currentFilter.mediaOnly != null) 'media': currentFilter.mediaOnly, + }; + + final response = await client.get( + '/sphere/posts', + queryParameters: queryParams, + ); + + final posts = response.data + .map((json) => SnPost.fromJson(json)) + .cast() + .toList(); + + allPosts.addAll(posts); + totalPostsCount += int.parse(response.headers.value('X-Total') ?? '0'); + } + + // Sort combined results by creation date (newest first) + allPosts.sort( + (a, b) => (b.createdAt ?? DateTime.now()).compareTo( + a.createdAt ?? DateTime.now(), + ), + ); + + // Apply pagination to combined results + final startIndex = fetchedCount; + final endIndex = (fetchedCount + pageSize).clamp(0, allPosts.length); + final paginatedPosts = startIndex < allPosts.length + ? allPosts.sublist(startIndex, endIndex) + : []; + + totalCount = totalPostsCount; + return paginatedPosts; + } else { + // Single publisher or no publisher filter + final queryParams = { + 'offset': fetchedCount, + 'take': pageSize, + 'replies': currentFilter.includeReplies, + 'orderDesc': currentFilter.orderDesc, + if (currentFilter.shuffle) 'shuffle': currentFilter.shuffle, + if (currentFilter.pubName != null) 'pub': currentFilter.pubName, + if (currentFilter.realm != null) 'realm': currentFilter.realm, + if (currentFilter.type != null) 'type': currentFilter.type, + if (currentFilter.tags != null) 'tags': currentFilter.tags, + if (currentFilter.categories != null) + 'categories': currentFilter.categories, + if (currentFilter.pinned != null) 'pinned': currentFilter.pinned, + if (currentFilter.order != null) 'order': currentFilter.order, + if (currentFilter.periodStart != null) + 'periodStart': currentFilter.periodStart, + if (currentFilter.periodEnd != null) + 'periodEnd': currentFilter.periodEnd, + if (currentFilter.queryTerm != null) 'query': currentFilter.queryTerm, + if (currentFilter.mediaOnly != null) 'media': currentFilter.mediaOnly, + }; + + final response = await client.get( + '/sphere/posts', + queryParameters: queryParams, + ); + totalCount = int.parse(response.headers.value('X-Total') ?? '0'); + return response.data + .map((json) => SnPost.fromJson(json)) + .cast() + .toList(); + } } } diff --git a/lib/pods/post/post_list.freezed.dart b/lib/pods/post/post_list.freezed.dart index d632c095..d2c86f9b 100644 --- a/lib/pods/post/post_list.freezed.dart +++ b/lib/pods/post/post_list.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$PostListQuery { - String? get pubName; String? get realm; int? get type; List? get categories; List? get tags; bool? get pinned; bool get shuffle; bool? get includeReplies; bool? get mediaOnly; String? get queryTerm; String? get order; int? get periodStart; int? get periodEnd; bool get orderDesc; + String? get pubName; List? get publishers; String? get realm; int? get type; List? get categories; List? get tags; bool? get pinned; bool get shuffle; bool? get includeReplies; bool? get mediaOnly; String? get queryTerm; String? get order; int? get periodStart; int? get periodEnd; bool get orderDesc; /// Create a copy of PostListQuery /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $PostListQueryCopyWith get copyWith => _$PostListQueryCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is PostListQuery&&(identical(other.pubName, pubName) || other.pubName == pubName)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.categories, categories)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&(identical(other.shuffle, shuffle) || other.shuffle == shuffle)&&(identical(other.includeReplies, includeReplies) || other.includeReplies == includeReplies)&&(identical(other.mediaOnly, mediaOnly) || other.mediaOnly == mediaOnly)&&(identical(other.queryTerm, queryTerm) || other.queryTerm == queryTerm)&&(identical(other.order, order) || other.order == order)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&(identical(other.orderDesc, orderDesc) || other.orderDesc == orderDesc)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is PostListQuery&&(identical(other.pubName, pubName) || other.pubName == pubName)&&const DeepCollectionEquality().equals(other.publishers, publishers)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.categories, categories)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&(identical(other.shuffle, shuffle) || other.shuffle == shuffle)&&(identical(other.includeReplies, includeReplies) || other.includeReplies == includeReplies)&&(identical(other.mediaOnly, mediaOnly) || other.mediaOnly == mediaOnly)&&(identical(other.queryTerm, queryTerm) || other.queryTerm == queryTerm)&&(identical(other.order, order) || other.order == order)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&(identical(other.orderDesc, orderDesc) || other.orderDesc == orderDesc)); } @override -int get hashCode => Object.hash(runtimeType,pubName,realm,type,const DeepCollectionEquality().hash(categories),const DeepCollectionEquality().hash(tags),pinned,shuffle,includeReplies,mediaOnly,queryTerm,order,periodStart,periodEnd,orderDesc); +int get hashCode => Object.hash(runtimeType,pubName,const DeepCollectionEquality().hash(publishers),realm,type,const DeepCollectionEquality().hash(categories),const DeepCollectionEquality().hash(tags),pinned,shuffle,includeReplies,mediaOnly,queryTerm,order,periodStart,periodEnd,orderDesc); @override String toString() { - return 'PostListQuery(pubName: $pubName, realm: $realm, type: $type, categories: $categories, tags: $tags, pinned: $pinned, shuffle: $shuffle, includeReplies: $includeReplies, mediaOnly: $mediaOnly, queryTerm: $queryTerm, order: $order, periodStart: $periodStart, periodEnd: $periodEnd, orderDesc: $orderDesc)'; + return 'PostListQuery(pubName: $pubName, publishers: $publishers, realm: $realm, type: $type, categories: $categories, tags: $tags, pinned: $pinned, shuffle: $shuffle, includeReplies: $includeReplies, mediaOnly: $mediaOnly, queryTerm: $queryTerm, order: $order, periodStart: $periodStart, periodEnd: $periodEnd, orderDesc: $orderDesc)'; } @@ -45,7 +45,7 @@ abstract mixin class $PostListQueryCopyWith<$Res> { factory $PostListQueryCopyWith(PostListQuery value, $Res Function(PostListQuery) _then) = _$PostListQueryCopyWithImpl; @useResult $Res call({ - String? pubName, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc + String? pubName, List? publishers, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc }); @@ -62,10 +62,11 @@ class _$PostListQueryCopyWithImpl<$Res> /// Create a copy of PostListQuery /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? pubName = freezed,Object? realm = freezed,Object? type = freezed,Object? categories = freezed,Object? tags = freezed,Object? pinned = freezed,Object? shuffle = null,Object? includeReplies = freezed,Object? mediaOnly = freezed,Object? queryTerm = freezed,Object? order = freezed,Object? periodStart = freezed,Object? periodEnd = freezed,Object? orderDesc = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? pubName = freezed,Object? publishers = freezed,Object? realm = freezed,Object? type = freezed,Object? categories = freezed,Object? tags = freezed,Object? pinned = freezed,Object? shuffle = null,Object? includeReplies = freezed,Object? mediaOnly = freezed,Object? queryTerm = freezed,Object? order = freezed,Object? periodStart = freezed,Object? periodEnd = freezed,Object? orderDesc = null,}) { return _then(_self.copyWith( pubName: freezed == pubName ? _self.pubName : pubName // ignore: cast_nullable_to_non_nullable -as String?,realm: freezed == realm ? _self.realm : realm // ignore: cast_nullable_to_non_nullable +as String?,publishers: freezed == publishers ? _self.publishers : publishers // ignore: cast_nullable_to_non_nullable +as List?,realm: freezed == realm ? _self.realm : realm // ignore: cast_nullable_to_non_nullable as String?,type: freezed == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as int?,categories: freezed == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable as List?,tags: freezed == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable @@ -160,10 +161,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String? pubName, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String? pubName, List? publishers, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _PostListQuery() when $default != null: -return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags,_that.pinned,_that.shuffle,_that.includeReplies,_that.mediaOnly,_that.queryTerm,_that.order,_that.periodStart,_that.periodEnd,_that.orderDesc);case _: +return $default(_that.pubName,_that.publishers,_that.realm,_that.type,_that.categories,_that.tags,_that.pinned,_that.shuffle,_that.includeReplies,_that.mediaOnly,_that.queryTerm,_that.order,_that.periodStart,_that.periodEnd,_that.orderDesc);case _: return orElse(); } @@ -181,10 +182,10 @@ return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String? pubName, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String? pubName, List? publishers, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc) $default,) {final _that = this; switch (_that) { case _PostListQuery(): -return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags,_that.pinned,_that.shuffle,_that.includeReplies,_that.mediaOnly,_that.queryTerm,_that.order,_that.periodStart,_that.periodEnd,_that.orderDesc);} +return $default(_that.pubName,_that.publishers,_that.realm,_that.type,_that.categories,_that.tags,_that.pinned,_that.shuffle,_that.includeReplies,_that.mediaOnly,_that.queryTerm,_that.order,_that.periodStart,_that.periodEnd,_that.orderDesc);} } /// A variant of `when` that fallback to returning `null` /// @@ -198,10 +199,10 @@ return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? pubName, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? pubName, List? publishers, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc)? $default,) {final _that = this; switch (_that) { case _PostListQuery() when $default != null: -return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags,_that.pinned,_that.shuffle,_that.includeReplies,_that.mediaOnly,_that.queryTerm,_that.order,_that.periodStart,_that.periodEnd,_that.orderDesc);case _: +return $default(_that.pubName,_that.publishers,_that.realm,_that.type,_that.categories,_that.tags,_that.pinned,_that.shuffle,_that.includeReplies,_that.mediaOnly,_that.queryTerm,_that.order,_that.periodStart,_that.periodEnd,_that.orderDesc);case _: return null; } @@ -213,10 +214,19 @@ return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags class _PostListQuery implements PostListQuery { - const _PostListQuery({this.pubName, this.realm, this.type, final List? categories, final List? tags, this.pinned, this.shuffle = false, this.includeReplies, this.mediaOnly, this.queryTerm, this.order, this.periodStart, this.periodEnd, this.orderDesc = true}): _categories = categories,_tags = tags; + const _PostListQuery({this.pubName, final List? publishers, this.realm, this.type, final List? categories, final List? tags, this.pinned, this.shuffle = false, this.includeReplies, this.mediaOnly, this.queryTerm, this.order, this.periodStart, this.periodEnd, this.orderDesc = true}): _publishers = publishers,_categories = categories,_tags = tags; @override final String? pubName; + final List? _publishers; +@override List? get publishers { + final value = _publishers; + if (value == null) return null; + if (_publishers is EqualUnmodifiableListView) return _publishers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + @override final String? realm; @override final int? type; final List? _categories; @@ -257,16 +267,16 @@ _$PostListQueryCopyWith<_PostListQuery> get copyWith => __$PostListQueryCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostListQuery&&(identical(other.pubName, pubName) || other.pubName == pubName)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._categories, _categories)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&(identical(other.shuffle, shuffle) || other.shuffle == shuffle)&&(identical(other.includeReplies, includeReplies) || other.includeReplies == includeReplies)&&(identical(other.mediaOnly, mediaOnly) || other.mediaOnly == mediaOnly)&&(identical(other.queryTerm, queryTerm) || other.queryTerm == queryTerm)&&(identical(other.order, order) || other.order == order)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&(identical(other.orderDesc, orderDesc) || other.orderDesc == orderDesc)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostListQuery&&(identical(other.pubName, pubName) || other.pubName == pubName)&&const DeepCollectionEquality().equals(other._publishers, _publishers)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._categories, _categories)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&(identical(other.shuffle, shuffle) || other.shuffle == shuffle)&&(identical(other.includeReplies, includeReplies) || other.includeReplies == includeReplies)&&(identical(other.mediaOnly, mediaOnly) || other.mediaOnly == mediaOnly)&&(identical(other.queryTerm, queryTerm) || other.queryTerm == queryTerm)&&(identical(other.order, order) || other.order == order)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&(identical(other.orderDesc, orderDesc) || other.orderDesc == orderDesc)); } @override -int get hashCode => Object.hash(runtimeType,pubName,realm,type,const DeepCollectionEquality().hash(_categories),const DeepCollectionEquality().hash(_tags),pinned,shuffle,includeReplies,mediaOnly,queryTerm,order,periodStart,periodEnd,orderDesc); +int get hashCode => Object.hash(runtimeType,pubName,const DeepCollectionEquality().hash(_publishers),realm,type,const DeepCollectionEquality().hash(_categories),const DeepCollectionEquality().hash(_tags),pinned,shuffle,includeReplies,mediaOnly,queryTerm,order,periodStart,periodEnd,orderDesc); @override String toString() { - return 'PostListQuery(pubName: $pubName, realm: $realm, type: $type, categories: $categories, tags: $tags, pinned: $pinned, shuffle: $shuffle, includeReplies: $includeReplies, mediaOnly: $mediaOnly, queryTerm: $queryTerm, order: $order, periodStart: $periodStart, periodEnd: $periodEnd, orderDesc: $orderDesc)'; + return 'PostListQuery(pubName: $pubName, publishers: $publishers, realm: $realm, type: $type, categories: $categories, tags: $tags, pinned: $pinned, shuffle: $shuffle, includeReplies: $includeReplies, mediaOnly: $mediaOnly, queryTerm: $queryTerm, order: $order, periodStart: $periodStart, periodEnd: $periodEnd, orderDesc: $orderDesc)'; } @@ -277,7 +287,7 @@ abstract mixin class _$PostListQueryCopyWith<$Res> implements $PostListQueryCopy factory _$PostListQueryCopyWith(_PostListQuery value, $Res Function(_PostListQuery) _then) = __$PostListQueryCopyWithImpl; @override @useResult $Res call({ - String? pubName, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc + String? pubName, List? publishers, String? realm, int? type, List? categories, List? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc }); @@ -294,10 +304,11 @@ class __$PostListQueryCopyWithImpl<$Res> /// Create a copy of PostListQuery /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? pubName = freezed,Object? realm = freezed,Object? type = freezed,Object? categories = freezed,Object? tags = freezed,Object? pinned = freezed,Object? shuffle = null,Object? includeReplies = freezed,Object? mediaOnly = freezed,Object? queryTerm = freezed,Object? order = freezed,Object? periodStart = freezed,Object? periodEnd = freezed,Object? orderDesc = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? pubName = freezed,Object? publishers = freezed,Object? realm = freezed,Object? type = freezed,Object? categories = freezed,Object? tags = freezed,Object? pinned = freezed,Object? shuffle = null,Object? includeReplies = freezed,Object? mediaOnly = freezed,Object? queryTerm = freezed,Object? order = freezed,Object? periodStart = freezed,Object? periodEnd = freezed,Object? orderDesc = null,}) { return _then(_PostListQuery( pubName: freezed == pubName ? _self.pubName : pubName // ignore: cast_nullable_to_non_nullable -as String?,realm: freezed == realm ? _self.realm : realm // ignore: cast_nullable_to_non_nullable +as String?,publishers: freezed == publishers ? _self._publishers : publishers // ignore: cast_nullable_to_non_nullable +as List?,realm: freezed == realm ? _self.realm : realm // ignore: cast_nullable_to_non_nullable as String?,type: freezed == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as int?,categories: freezed == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable as List?,tags: freezed == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable diff --git a/lib/pods/post/post_subscriptions.dart b/lib/pods/post/post_subscriptions.dart deleted file mode 100644 index 08035170..00000000 --- a/lib/pods/post/post_subscriptions.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/models/post.dart'; -import 'package:island/pods/network.dart'; - -final subscriptionsProvider = FutureProvider>(( - ref, -) async { - final client = ref.read(apiClientProvider); - - final response = await client.get('/sphere/subscriptions'); - - return response.data - .map((json) => SnPublisherSubscription.fromJson(json)) - .cast() - .toList(); -}); diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index f0dc9b77..5cd78a01 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -23,6 +23,7 @@ import 'package:island/widgets/navigation/fab_menu.dart'; import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item_skeleton.dart'; +import 'package:island/widgets/post/post_list.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:island/widgets/realm/realm_card.dart'; import 'package:island/widgets/publisher/publisher_card.dart'; @@ -31,6 +32,8 @@ import 'package:island/services/event_bus.dart'; import 'package:island/widgets/share/share_sheet.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; +import 'package:island/widgets/posts/post_subscription_filter.dart'; +import 'package:island/pods/post/post_list.dart'; class ExploreScreen extends HookConsumerWidget { const ExploreScreen({super.key}); @@ -38,6 +41,7 @@ class ExploreScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentFilter = useState(null); + final selectedPublisherNames = useState>([]); final notifier = ref.watch(activityListProvider.notifier); useEffect(() { @@ -87,6 +91,8 @@ class ExploreScreen extends HookConsumerWidget { final isWide = isWideScreen(context); + final hasSubscriptionsSelected = selectedPublisherNames.value.isNotEmpty; + final filterBar = Card( margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top), child: Row( @@ -95,7 +101,9 @@ class ExploreScreen extends HookConsumerWidget { spacing: 8, children: [ IconButton( - onPressed: () => handleFilterChange(null), + onPressed: hasSubscriptionsSelected + ? null + : () => handleFilterChange(null), icon: Icon( Symbols.explore, fill: currentFilter.value == null ? 1 : null, @@ -107,7 +115,9 @@ class ExploreScreen extends HookConsumerWidget { : null, ), IconButton( - onPressed: () => handleFilterChange('subscriptions'), + onPressed: hasSubscriptionsSelected + ? null + : () => handleFilterChange('subscriptions'), icon: Icon( Symbols.subscriptions, fill: currentFilter.value == 'subscriptions' ? 1 : null, @@ -119,7 +129,9 @@ class ExploreScreen extends HookConsumerWidget { : null, ), IconButton( - onPressed: () => handleFilterChange('friends'), + onPressed: hasSubscriptionsSelected + ? null + : () => handleFilterChange('friends'), icon: Icon( Symbols.people, fill: currentFilter.value == 'friends' ? 1 : null, @@ -188,7 +200,12 @@ class ExploreScreen extends HookConsumerWidget { final appBar = isWide ? null - : _buildAppBar(currentFilter.value, handleFilterChange, context); + : _buildAppBar( + currentFilter.value, + handleFilterChange, + context, + hasSubscriptionsSelected, + ); final dragging = useState(false); @@ -221,6 +238,8 @@ class ExploreScreen extends HookConsumerWidget { query, events, selectedDay, + currentFilter.value, + selectedPublisherNames, ) : _buildNarrowBody(context, ref, currentFilter.value), ), @@ -273,6 +292,19 @@ class ExploreScreen extends HookConsumerWidget { ); } + Widget _buildPostList( + BuildContext context, + WidgetRef ref, + List selectedPublisherIds, + ) { + return SliverPostList( + queryKey: 'explore_filtered', + query: PostListQuery(publishers: selectedPublisherIds), + padding: EdgeInsets.zero, + itemPadding: EdgeInsets.zero, + ); + } + Widget _buildWideBody( BuildContext context, WidgetRef ref, @@ -282,10 +314,18 @@ class ExploreScreen extends HookConsumerWidget { ValueNotifier query, AsyncValue> events, ValueNotifier selectedDay, + String? currentFilter, + ValueNotifier> selectedPublisherNames, ) { - final bodyView = _buildActivityList(context, ref); + // Use post list when subscription filter is active and publishers are selected + final usePostList = selectedPublisherNames.value.isNotEmpty; + final bodyView = usePostList + ? _buildPostList(context, ref, selectedPublisherNames.value) + : _buildActivityList(context, ref); - final notifier = ref.watch(activityListProvider.notifier); + final notifier = usePostList + ? null // Post list handles its own refreshing + : ref.watch(activityListProvider.notifier); return Row( spacing: 12, @@ -293,7 +333,9 @@ class ExploreScreen extends HookConsumerWidget { Flexible( flex: 3, child: ExtendedRefreshIndicator( - onRefresh: notifier.refresh, + onRefresh: () async { + await notifier?.refresh(); + }, child: CustomScrollView( slivers: [ const SliverGap(12), @@ -310,7 +352,19 @@ class ExploreScreen extends HookConsumerWidget { child: Align( alignment: Alignment.topCenter, child: SingleChildScrollView( - child: Column(spacing: 8, children: [const Gap(4)]), + child: Column( + spacing: 8, + children: [ + Gap(4 + MediaQuery.paddingOf(context).top), + PostSubscriptionFilterWidget( + initialSelectedPublisherNames: + selectedPublisherNames.value, + onSelectedPublishersChanged: (names) { + selectedPublisherNames.value = names; + }, + ), + ], + ), ), ), ) @@ -358,6 +412,7 @@ class ExploreScreen extends HookConsumerWidget { String? currentFilter, void Function(String?) handleFilterChange, BuildContext context, + bool hasSubscriptionsSelected, ) { final foregroundColor = Theme.of(context).appBarTheme.foregroundColor; @@ -376,7 +431,9 @@ class ExploreScreen extends HookConsumerWidget { spacing: 8, children: [ IconButton( - onPressed: () => handleFilterChange(null), + onPressed: hasSubscriptionsSelected + ? null + : () => handleFilterChange(null), icon: Icon( Symbols.explore, color: foregroundColor, @@ -387,7 +444,9 @@ class ExploreScreen extends HookConsumerWidget { color: currentFilter == null ? foregroundColor : null, ), IconButton( - onPressed: () => handleFilterChange('subscriptions'), + onPressed: hasSubscriptionsSelected + ? null + : () => handleFilterChange('subscriptions'), icon: Icon( Symbols.subscriptions, color: foregroundColor, @@ -397,7 +456,9 @@ class ExploreScreen extends HookConsumerWidget { isSelected: currentFilter == 'subscriptions', ), IconButton( - onPressed: () => handleFilterChange('friends'), + onPressed: hasSubscriptionsSelected + ? null + : () => handleFilterChange('friends'), icon: Icon( Symbols.people, color: foregroundColor, @@ -477,12 +538,7 @@ class ExploreScreen extends HookConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(8)), child: ExtendedRefreshIndicator( onRefresh: notifier.refresh, - child: CustomScrollView( - slivers: [ - SliverGap(8 + MediaQuery.paddingOf(context).top), - bodyView, - ], - ), + child: CustomScrollView(slivers: [SliverGap(8), bodyView]), ), ).padding(horizontal: 8), ); diff --git a/lib/widgets/post/post_list.dart b/lib/widgets/post/post_list.dart index 12d22a29..a259a143 100644 --- a/lib/widgets/post/post_list.dart +++ b/lib/widgets/post/post_list.dart @@ -22,6 +22,7 @@ class SliverPostList extends HookConsumerWidget { final PostItemType itemType; final Color? backgroundColor; final EdgeInsets? padding; + final EdgeInsets? itemPadding; final bool isOpenable; final Function? onRefresh; final Function(SnPost)? onUpdate; @@ -34,6 +35,7 @@ class SliverPostList extends HookConsumerWidget { this.itemType = PostItemType.regular, this.backgroundColor, this.padding, + this.itemPadding, this.isOpenable = true, this.onRefresh, this.onUpdate, @@ -74,17 +76,17 @@ class SliverPostList extends HookConsumerWidget { return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth!), - child: _buildPostItem(post), + child: _buildPostItem(post, itemPadding), ), ); } - return _buildPostItem(post); + return _buildPostItem(post, itemPadding); }, ); } - Widget _buildPostItem(SnPost post) { + Widget _buildPostItem(SnPost post, EdgeInsets? padding) { switch (itemType) { case PostItemType.creator: return PostItemCreator( @@ -97,7 +99,8 @@ class SliverPostList extends HookConsumerWidget { ); case PostItemType.regular: return Card( - margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: + itemPadding ?? EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: PostActionableItem(item: post, borderRadius: 8), ); } diff --git a/lib/widgets/posts/post_subscription_filter.dart b/lib/widgets/posts/post_subscription_filter.dart index 07d0216e..60fb2e59 100644 --- a/lib/widgets/posts/post_subscription_filter.dart +++ b/lib/widgets/posts/post_subscription_filter.dart @@ -3,153 +3,129 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/pods/post/post_subscriptions.dart'; +import 'package:island/models/post.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/content/cloud_files.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +final subscriptionsProvider = FutureProvider>(( + ref, +) async { + final client = ref.read(apiClientProvider); + + final response = await client.get('/sphere/publishers/subscriptions'); + + return response.data + .map((json) => SnPublisherSubscription.fromJson(json)) + .cast() + .toList(); +}); class PostSubscriptionFilterWidget extends HookConsumerWidget { - final List initialSelectedPublisherIds; + final List initialSelectedPublisherNames; final ValueChanged> onSelectedPublishersChanged; final bool hideSearch; const PostSubscriptionFilterWidget({ super.key, - required this.initialSelectedPublisherIds, + required this.initialSelectedPublisherNames, required this.onSelectedPublishersChanged, this.hideSearch = false, }); @override Widget build(BuildContext context, WidgetRef ref) { - final selectedPublisherIds = useState>( - initialSelectedPublisherIds, + final selectedPublisherNames = useState>( + initialSelectedPublisherNames, ); - final showSubscriptions = useState(false); final subscriptionsAsync = ref.watch(subscriptionsProvider); void updateSelection() { - onSelectedPublishersChanged(selectedPublisherIds.value); + onSelectedPublishersChanged(selectedPublisherNames.value); } return Card( - margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: EdgeInsets.zero, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ListTile( - title: Text('filterBySubscriptions'.tr()), - leading: const Icon(Symbols.subscriptions), - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(const Radius.circular(8)), - ), - trailing: Icon( - showSubscriptions.value - ? Symbols.expand_less - : Symbols.expand_more, - ), - onTap: () { - showSubscriptions.value = !showSubscriptions.value; - }, - ), - if (showSubscriptions.value) ...[ - const Divider(height: 1), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - subscriptionsAsync.when( - data: (subscriptions) { - if (subscriptions.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text('noSubscriptions'.tr()), - ), - ); - } - - return Column( - children: subscriptions.map((subscription) { - final isSelected = selectedPublisherIds.value - .contains(subscription.publisherId); - final publisher = subscription.publisher; - - return CheckboxListTile( - title: Text(publisher.name), - subtitle: - publisher.nick.isNotEmpty && - publisher.nick != publisher.name - ? Text(publisher.nick) - : null, - value: isSelected, - onChanged: (value) { - if (value == true) { - selectedPublisherIds.value = [ - ...selectedPublisherIds.value, - subscription.publisherId, - ]; - } else { - selectedPublisherIds.value = - selectedPublisherIds.value - .where( - (id) => - id != subscription.publisherId, - ) - .toList(); - } - updateSelection(); - }, - dense: true, - controlAffinity: ListTileControlAffinity.leading, - secondary: const Icon(Symbols.person), - ); - }).toList(), - ); - }, - loading: () => const Center( - child: Padding( - padding: EdgeInsets.all(16.0), - child: CircularProgressIndicator(), - ), - ), - error: (error, stack) => Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text('errorLoadingSubscriptions'.tr()), - ), - ), + Row( + spacing: 16, + children: [ + const Icon(Symbols.subscriptions, size: 20), + Text( + 'exploreFilterSubscriptions'.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ).padding(horizontal: 16, top: 12), + const Gap(12), + subscriptionsAsync.when( + data: (subscriptions) { + if (subscriptions.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text('noSubscriptions'.tr()), ), - if (subscriptionsAsync.hasValue && - subscriptionsAsync.value!.isNotEmpty) ...[ - const Gap(12), - Row( - children: [ - TextButton( - onPressed: () { - selectedPublisherIds.value = subscriptionsAsync - .value! - .map((s) => s.publisherId) - .toList(); - updateSelection(); - }, - child: Text('selectAll'.tr()), - ), - const Gap(8), - TextButton( - onPressed: () { - selectedPublisherIds.value = []; - updateSelection(); - }, - child: Text('selectNone'.tr()), - ), - ], + ); + } + + return Column( + children: subscriptions.map((subscription) { + final isSelected = selectedPublisherNames.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(), + ); + }, + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), ), ), - ], + error: (error, stack) => Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text('errorLoadingSubscriptions'.tr()), + ), + ), + ), ], ), );