Explore subscription filter card

This commit is contained in:
2025-12-23 01:03:46 +08:00
parent 0a179acb13
commit d94baab877
6 changed files with 299 additions and 205 deletions

View File

@@ -10,6 +10,7 @@ part 'post_list.freezed.dart';
sealed class PostListQuery with _$PostListQuery { sealed class PostListQuery with _$PostListQuery {
const factory PostListQuery({ const factory PostListQuery({
String? pubName, String? pubName,
List<String>? publishers,
String? realm, String? realm,
int? type, int? type,
List<String>? categories, List<String>? categories,
@@ -61,35 +62,98 @@ class PostListNotifier extends AsyncNotifier<List<SnPost>>
Future<List<SnPost>> fetch() async { Future<List<SnPost>> fetch() async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final queryParams = { // Handle multiple publishers by making separate requests and combining results
'offset': fetchedCount, if (currentFilter.publishers != null &&
'take': pageSize, currentFilter.publishers!.isNotEmpty) {
'replies': currentFilter.includeReplies, final allPosts = <SnPost>[];
'orderDesc': currentFilter.orderDesc, var totalPostsCount = 0;
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( for (final publisherName in currentFilter.publishers!) {
'/sphere/posts', final queryParams = {
queryParameters: queryParams, 'offset': fetchedCount,
); 'take': pageSize,
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); 'replies': currentFilter.includeReplies,
return response.data 'orderDesc': currentFilter.orderDesc,
.map((json) => SnPost.fromJson(json)) if (currentFilter.shuffle) 'shuffle': currentFilter.shuffle,
.cast<SnPost>() 'pub': publisherName,
.toList(); 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<SnPost>()
.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)
: <SnPost>[];
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<SnPost>()
.toList();
}
} }
} }

View File

@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$PostListQuery { mixin _$PostListQuery {
String? get pubName; String? get realm; int? get type; List<String>? get categories; List<String>? 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<String>? get publishers; String? get realm; int? get type; List<String>? get categories; List<String>? 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 /// Create a copy of PostListQuery
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $PostListQueryCopyWith<PostListQuery> get copyWith => _$PostListQueryCopyWithImp
@override @override
bool operator ==(Object other) { 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 @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 @override
String toString() { 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; factory $PostListQueryCopyWith(PostListQuery value, $Res Function(PostListQuery) _then) = _$PostListQueryCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String? pubName, String? realm, int? type, List<String>? categories, List<String>? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc String? pubName, List<String>? publishers, String? realm, int? type, List<String>? categories, List<String>? 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 /// Create a copy of PostListQuery
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
pubName: freezed == pubName ? _self.pubName : pubName // ignore: cast_nullable_to_non_nullable 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<String>?,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 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 int?,categories: freezed == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable
as List<String>?,tags: freezed == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable as List<String>?,tags: freezed == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
@@ -160,10 +161,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String? pubName, String? realm, int? type, List<String>? categories, List<String>? 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 extends Object?>(TResult Function( String? pubName, List<String>? publishers, String? realm, int? type, List<String>? categories, List<String>? 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) { switch (_that) {
case _PostListQuery() when $default != null: 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(); return orElse();
} }
@@ -181,10 +182,10 @@ return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String? pubName, String? realm, int? type, List<String>? categories, List<String>? 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 extends Object?>(TResult Function( String? pubName, List<String>? publishers, String? realm, int? type, List<String>? categories, List<String>? 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) { switch (_that) {
case _PostListQuery(): 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` /// 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 extends Object?>(TResult? Function( String? pubName, String? realm, int? type, List<String>? categories, List<String>? 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 extends Object?>(TResult? Function( String? pubName, List<String>? publishers, String? realm, int? type, List<String>? categories, List<String>? 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) { switch (_that) {
case _PostListQuery() when $default != null: 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; return null;
} }
@@ -213,10 +214,19 @@ return $default(_that.pubName,_that.realm,_that.type,_that.categories,_that.tags
class _PostListQuery implements PostListQuery { class _PostListQuery implements PostListQuery {
const _PostListQuery({this.pubName, this.realm, this.type, final List<String>? categories, final List<String>? 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<String>? publishers, this.realm, this.type, final List<String>? categories, final List<String>? 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; @override final String? pubName;
final List<String>? _publishers;
@override List<String>? 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 String? realm;
@override final int? type; @override final int? type;
final List<String>? _categories; final List<String>? _categories;
@@ -257,16 +267,16 @@ _$PostListQueryCopyWith<_PostListQuery> get copyWith => __$PostListQueryCopyWith
@override @override
bool operator ==(Object other) { 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 @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 @override
String toString() { 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; factory _$PostListQueryCopyWith(_PostListQuery value, $Res Function(_PostListQuery) _then) = __$PostListQueryCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String? pubName, String? realm, int? type, List<String>? categories, List<String>? tags, bool? pinned, bool shuffle, bool? includeReplies, bool? mediaOnly, String? queryTerm, String? order, int? periodStart, int? periodEnd, bool orderDesc String? pubName, List<String>? publishers, String? realm, int? type, List<String>? categories, List<String>? 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 /// Create a copy of PostListQuery
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_PostListQuery(
pubName: freezed == pubName ? _self.pubName : pubName // ignore: cast_nullable_to_non_nullable 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<String>?,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 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 int?,categories: freezed == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable
as List<String>?,tags: freezed == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable as List<String>?,tags: freezed == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable

View File

@@ -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<List<SnPublisherSubscription>>((
ref,
) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/sphere/subscriptions');
return response.data
.map((json) => SnPublisherSubscription.fromJson(json))
.cast<SnPublisherSubscription>()
.toList();
});

View File

@@ -23,6 +23,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_skeleton.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:material_symbols_icons/symbols.dart';
import 'package:island/widgets/realm/realm_card.dart'; import 'package:island/widgets/realm/realm_card.dart';
import 'package:island/widgets/publisher/publisher_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:island/widgets/share/share_sheet.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.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 { class ExploreScreen extends HookConsumerWidget {
const ExploreScreen({super.key}); const ExploreScreen({super.key});
@@ -38,6 +41,7 @@ class ExploreScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentFilter = useState<String?>(null); final currentFilter = useState<String?>(null);
final selectedPublisherNames = useState<List<String>>([]);
final notifier = ref.watch(activityListProvider.notifier); final notifier = ref.watch(activityListProvider.notifier);
useEffect(() { useEffect(() {
@@ -87,6 +91,8 @@ class ExploreScreen extends HookConsumerWidget {
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
final hasSubscriptionsSelected = selectedPublisherNames.value.isNotEmpty;
final filterBar = Card( final filterBar = Card(
margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top), margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: Row( child: Row(
@@ -95,7 +101,9 @@ class ExploreScreen extends HookConsumerWidget {
spacing: 8, spacing: 8,
children: [ children: [
IconButton( IconButton(
onPressed: () => handleFilterChange(null), onPressed: hasSubscriptionsSelected
? null
: () => handleFilterChange(null),
icon: Icon( icon: Icon(
Symbols.explore, Symbols.explore,
fill: currentFilter.value == null ? 1 : null, fill: currentFilter.value == null ? 1 : null,
@@ -107,7 +115,9 @@ class ExploreScreen extends HookConsumerWidget {
: null, : null,
), ),
IconButton( IconButton(
onPressed: () => handleFilterChange('subscriptions'), onPressed: hasSubscriptionsSelected
? null
: () => handleFilterChange('subscriptions'),
icon: Icon( icon: Icon(
Symbols.subscriptions, Symbols.subscriptions,
fill: currentFilter.value == 'subscriptions' ? 1 : null, fill: currentFilter.value == 'subscriptions' ? 1 : null,
@@ -119,7 +129,9 @@ class ExploreScreen extends HookConsumerWidget {
: null, : null,
), ),
IconButton( IconButton(
onPressed: () => handleFilterChange('friends'), onPressed: hasSubscriptionsSelected
? null
: () => handleFilterChange('friends'),
icon: Icon( icon: Icon(
Symbols.people, Symbols.people,
fill: currentFilter.value == 'friends' ? 1 : null, fill: currentFilter.value == 'friends' ? 1 : null,
@@ -188,7 +200,12 @@ class ExploreScreen extends HookConsumerWidget {
final appBar = isWide final appBar = isWide
? null ? null
: _buildAppBar(currentFilter.value, handleFilterChange, context); : _buildAppBar(
currentFilter.value,
handleFilterChange,
context,
hasSubscriptionsSelected,
);
final dragging = useState(false); final dragging = useState(false);
@@ -221,6 +238,8 @@ class ExploreScreen extends HookConsumerWidget {
query, query,
events, events,
selectedDay, selectedDay,
currentFilter.value,
selectedPublisherNames,
) )
: _buildNarrowBody(context, ref, currentFilter.value), : _buildNarrowBody(context, ref, currentFilter.value),
), ),
@@ -273,6 +292,19 @@ class ExploreScreen extends HookConsumerWidget {
); );
} }
Widget _buildPostList(
BuildContext context,
WidgetRef ref,
List<String> selectedPublisherIds,
) {
return SliverPostList(
queryKey: 'explore_filtered',
query: PostListQuery(publishers: selectedPublisherIds),
padding: EdgeInsets.zero,
itemPadding: EdgeInsets.zero,
);
}
Widget _buildWideBody( Widget _buildWideBody(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
@@ -282,10 +314,18 @@ class ExploreScreen extends HookConsumerWidget {
ValueNotifier<EventCalendarQuery> query, ValueNotifier<EventCalendarQuery> query,
AsyncValue<List<dynamic>> events, AsyncValue<List<dynamic>> events,
ValueNotifier<DateTime> selectedDay, ValueNotifier<DateTime> selectedDay,
String? currentFilter,
ValueNotifier<List<String>> 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( return Row(
spacing: 12, spacing: 12,
@@ -293,7 +333,9 @@ class ExploreScreen extends HookConsumerWidget {
Flexible( Flexible(
flex: 3, flex: 3,
child: ExtendedRefreshIndicator( child: ExtendedRefreshIndicator(
onRefresh: notifier.refresh, onRefresh: () async {
await notifier?.refresh();
},
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
const SliverGap(12), const SliverGap(12),
@@ -310,7 +352,19 @@ class ExploreScreen extends HookConsumerWidget {
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: SingleChildScrollView( 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, String? currentFilter,
void Function(String?) handleFilterChange, void Function(String?) handleFilterChange,
BuildContext context, BuildContext context,
bool hasSubscriptionsSelected,
) { ) {
final foregroundColor = Theme.of(context).appBarTheme.foregroundColor; final foregroundColor = Theme.of(context).appBarTheme.foregroundColor;
@@ -376,7 +431,9 @@ class ExploreScreen extends HookConsumerWidget {
spacing: 8, spacing: 8,
children: [ children: [
IconButton( IconButton(
onPressed: () => handleFilterChange(null), onPressed: hasSubscriptionsSelected
? null
: () => handleFilterChange(null),
icon: Icon( icon: Icon(
Symbols.explore, Symbols.explore,
color: foregroundColor, color: foregroundColor,
@@ -387,7 +444,9 @@ class ExploreScreen extends HookConsumerWidget {
color: currentFilter == null ? foregroundColor : null, color: currentFilter == null ? foregroundColor : null,
), ),
IconButton( IconButton(
onPressed: () => handleFilterChange('subscriptions'), onPressed: hasSubscriptionsSelected
? null
: () => handleFilterChange('subscriptions'),
icon: Icon( icon: Icon(
Symbols.subscriptions, Symbols.subscriptions,
color: foregroundColor, color: foregroundColor,
@@ -397,7 +456,9 @@ class ExploreScreen extends HookConsumerWidget {
isSelected: currentFilter == 'subscriptions', isSelected: currentFilter == 'subscriptions',
), ),
IconButton( IconButton(
onPressed: () => handleFilterChange('friends'), onPressed: hasSubscriptionsSelected
? null
: () => handleFilterChange('friends'),
icon: Icon( icon: Icon(
Symbols.people, Symbols.people,
color: foregroundColor, color: foregroundColor,
@@ -477,12 +538,7 @@ class ExploreScreen extends HookConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ExtendedRefreshIndicator( child: ExtendedRefreshIndicator(
onRefresh: notifier.refresh, onRefresh: notifier.refresh,
child: CustomScrollView( child: CustomScrollView(slivers: [SliverGap(8), bodyView]),
slivers: [
SliverGap(8 + MediaQuery.paddingOf(context).top),
bodyView,
],
),
), ),
).padding(horizontal: 8), ).padding(horizontal: 8),
); );

View File

@@ -22,6 +22,7 @@ class SliverPostList extends HookConsumerWidget {
final PostItemType itemType; final PostItemType itemType;
final Color? backgroundColor; final Color? backgroundColor;
final EdgeInsets? padding; final EdgeInsets? padding;
final EdgeInsets? itemPadding;
final bool isOpenable; final bool isOpenable;
final Function? onRefresh; final Function? onRefresh;
final Function(SnPost)? onUpdate; final Function(SnPost)? onUpdate;
@@ -34,6 +35,7 @@ class SliverPostList extends HookConsumerWidget {
this.itemType = PostItemType.regular, this.itemType = PostItemType.regular,
this.backgroundColor, this.backgroundColor,
this.padding, this.padding,
this.itemPadding,
this.isOpenable = true, this.isOpenable = true,
this.onRefresh, this.onRefresh,
this.onUpdate, this.onUpdate,
@@ -74,17 +76,17 @@ class SliverPostList extends HookConsumerWidget {
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth!), 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) { switch (itemType) {
case PostItemType.creator: case PostItemType.creator:
return PostItemCreator( return PostItemCreator(
@@ -97,7 +99,8 @@ class SliverPostList extends HookConsumerWidget {
); );
case PostItemType.regular: case PostItemType.regular:
return Card( return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin:
itemPadding ?? EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: PostActionableItem(item: post, borderRadius: 8), child: PostActionableItem(item: post, borderRadius: 8),
); );
} }

View File

@@ -3,153 +3,129 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
final subscriptionsProvider = FutureProvider<List<SnPublisherSubscription>>((
ref,
) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/sphere/publishers/subscriptions');
return response.data
.map((json) => SnPublisherSubscription.fromJson(json))
.cast<SnPublisherSubscription>()
.toList();
});
class PostSubscriptionFilterWidget extends HookConsumerWidget { class PostSubscriptionFilterWidget extends HookConsumerWidget {
final List<String> initialSelectedPublisherIds; final List<String> initialSelectedPublisherNames;
final ValueChanged<List<String>> onSelectedPublishersChanged; final ValueChanged<List<String>> onSelectedPublishersChanged;
final bool hideSearch; final bool hideSearch;
const PostSubscriptionFilterWidget({ const PostSubscriptionFilterWidget({
super.key, super.key,
required this.initialSelectedPublisherIds, required this.initialSelectedPublisherNames,
required this.onSelectedPublishersChanged, required this.onSelectedPublishersChanged,
this.hideSearch = false, this.hideSearch = false,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedPublisherIds = useState<List<String>>( final selectedPublisherNames = useState<List<String>>(
initialSelectedPublisherIds, initialSelectedPublisherNames,
); );
final showSubscriptions = useState<bool>(false);
final subscriptionsAsync = ref.watch(subscriptionsProvider); final subscriptionsAsync = ref.watch(subscriptionsProvider);
void updateSelection() { void updateSelection() {
onSelectedPublishersChanged(selectedPublisherIds.value); onSelectedPublishersChanged(selectedPublisherNames.value);
} }
return Card( return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
ListTile( Row(
title: Text('filterBySubscriptions'.tr()), spacing: 16,
leading: const Icon(Symbols.subscriptions), children: [
contentPadding: const EdgeInsets.symmetric(horizontal: 24), const Icon(Symbols.subscriptions, size: 20),
shape: RoundedRectangleBorder( Text(
borderRadius: BorderRadius.all(const Radius.circular(8)), 'exploreFilterSubscriptions'.tr(),
), style: Theme.of(context).textTheme.titleMedium,
trailing: Icon( ),
showSubscriptions.value ],
? Symbols.expand_less ).padding(horizontal: 16, top: 12),
: Symbols.expand_more, const Gap(12),
), subscriptionsAsync.when(
onTap: () { data: (subscriptions) {
showSubscriptions.value = !showSubscriptions.value; if (subscriptions.isEmpty) {
}, return Center(
), child: Padding(
if (showSubscriptions.value) ...[ padding: const EdgeInsets.all(16.0),
const Divider(height: 1), child: Text('noSubscriptions'.tr()),
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()),
),
),
), ),
if (subscriptionsAsync.hasValue && );
subscriptionsAsync.value!.isNotEmpty) ...[ }
const Gap(12),
Row( return Column(
children: [ children: subscriptions.map((subscription) {
TextButton( final isSelected = selectedPublisherNames.value.contains(
onPressed: () { subscription.publisher.name,
selectedPublisherIds.value = subscriptionsAsync );
.value! final publisher = subscription.publisher;
.map((s) => s.publisherId)
.toList(); return CheckboxListTile(
updateSelection(); controlAffinity: ListTileControlAffinity.trailing,
}, title: Text(publisher.nick),
child: Text('selectAll'.tr()), subtitle: Text('@${publisher.name}'),
), shape: const RoundedRectangleBorder(
const Gap(8), borderRadius: BorderRadius.all(Radius.circular(8)),
TextButton(
onPressed: () {
selectedPublisherIds.value = [];
updateSelection();
},
child: Text('selectNone'.tr()),
),
],
), ),
], 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()),
),
),
),
], ],
), ),
); );