diff --git a/lib/pods/paging.dart b/lib/pods/paging.dart index eb6ea371..75387546 100644 --- a/lib/pods/paging.dart +++ b/lib/pods/paging.dart @@ -7,6 +7,7 @@ abstract class PaginationController { int get fetchedCount; bool get fetchedAll; + bool get isLoading; FutureOr> fetch(); @@ -32,11 +33,15 @@ mixin AsyncPaginationController on AsyncNotifier> @override bool get fetchedAll => totalCount != null && fetchedCount >= totalCount!; + @override + bool isLoading = false; + @override FutureOr> build() async => fetch(); @override Future refresh() async { + isLoading = true; totalCount = null; state = AsyncData>([]); @@ -44,12 +49,14 @@ mixin AsyncPaginationController on AsyncNotifier> return await fetch(); }); state = newState; + isLoading = false; } @override Future fetchFurther() async { if (fetchedAll) return; + isLoading = true; state = AsyncLoading>(); final newState = await AsyncValue.guard>(() async { @@ -58,6 +65,7 @@ mixin AsyncPaginationController on AsyncNotifier> }); state = newState; + isLoading = false; } } @@ -67,6 +75,7 @@ mixin AsyncPaginationFilter on AsyncPaginationController Future applyFilter(F filter) async { if (currentFilter == filter) return; // Reset the data + isLoading = true; totalCount = null; state = AsyncData>([]); currentFilter = filter; @@ -75,5 +84,6 @@ mixin AsyncPaginationFilter on AsyncPaginationController return await fetch(); }); state = newState; + isLoading = false; } } diff --git a/lib/pods/post/post_list.dart b/lib/pods/post/post_list.dart index 4e4127e7..28049e0d 100644 --- a/lib/pods/post/post_list.dart +++ b/lib/pods/post/post_list.dart @@ -26,18 +26,22 @@ sealed class PostListQuery with _$PostListQuery { }) = _PostListQuery; } -final postListNotifierProvider = AsyncNotifierProvider.autoDispose - .family, PostListQuery>( - PostListNotifier.new, - ); +final postListProvider = AsyncNotifierProvider.autoDispose.family( + PostListNotifier.new, +); class PostListNotifier extends AsyncNotifier> - with AsyncPaginationController { - final PostListQuery arg; - PostListNotifier(this.arg); - + with + AsyncPaginationController, + AsyncPaginationFilter { static const int pageSize = 20; + final String? id; + PostListNotifier(this.id); + + @override + PostListQuery currentFilter = PostListQuery(); + @override Future> fetch() async { final client = ref.read(apiClientProvider); @@ -45,20 +49,22 @@ class PostListNotifier extends AsyncNotifier> final queryParams = { 'offset': fetchedCount, 'take': pageSize, - 'replies': arg.includeReplies, - 'orderDesc': arg.orderDesc, - if (arg.shuffle) 'shuffle': arg.shuffle, - if (arg.pubName != null) 'pub': arg.pubName, - if (arg.realm != null) 'realm': arg.realm, - if (arg.type != null) 'type': arg.type, - if (arg.tags != null) 'tags': arg.tags, - if (arg.categories != null) 'categories': arg.categories, - if (arg.pinned != null) 'pinned': arg.pinned, - if (arg.order != null) 'order': arg.order, - if (arg.periodStart != null) 'periodStart': arg.periodStart, - if (arg.periodEnd != null) 'periodEnd': arg.periodEnd, - if (arg.queryTerm != null) 'query': arg.queryTerm, - if (arg.mediaOnly != null) 'media': arg.mediaOnly, + '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( diff --git a/lib/screens/creators/posts/post_manage_list.dart b/lib/screens/creators/posts/post_manage_list.dart index 825d579d..806cbe74 100644 --- a/lib/screens/creators/posts/post_manage_list.dart +++ b/lib/screens/creators/posts/post_manage_list.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/post/post_list.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/post/post_list.dart'; @@ -20,7 +21,7 @@ class CreatorPostListScreen extends HookConsumerWidget { key: ValueKey(refreshKey.value), slivers: [ SliverPostList( - pubName: pubName, + query: PostListQuery(pubName: pubName), itemType: PostItemType.creator, maxWidth: 640, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, diff --git a/lib/screens/posts/post_category_detail.dart b/lib/screens/posts/post_category_detail.dart index 4017ef9d..e4b7f7c0 100644 --- a/lib/screens/posts/post_category_detail.dart +++ b/lib/screens/posts/post_category_detail.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/post_category.dart'; import 'package:island/models/post_tag.dart'; import 'package:island/pods/network.dart'; +import 'package:island/pods/post/post_list.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/response.dart'; @@ -82,17 +83,17 @@ class PostCategoryDetailScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final postCategory = - isCategory ? ref.watch(postCategoryProvider(slug)) : null; + final postCategory = isCategory + ? ref.watch(postCategoryProvider(slug)) + : null; final postTag = isCategory ? null : ref.watch(postTagProvider(slug)); final subscriptionStatus = ref.watch( postCategorySubscriptionStatusProvider(slug, isCategory), ); - final postFilterTitle = - isCategory - ? postCategory?.value?.categoryDisplayTitle ?? 'loading' - : postTag?.value?.name ?? postTag?.value?.slug ?? 'loading'; + final postFilterTitle = isCategory + ? postCategory?.value?.categoryDisplayTitle ?? 'loading' + : postTag?.value?.name ?? postTag?.value?.slug ?? 'loading'; return AppScaffold( isNoBackground: false, @@ -108,63 +109,50 @@ class PostCategoryDetailScreen extends HookConsumerWidget { child: Card( margin: EdgeInsets.only(top: 8), child: postCategory!.when( - data: - (category) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - category.categoryDisplayTitle, - ).bold().fontSize(15), - Text('A category'), - const Gap(8), - subscriptionStatus.when( - data: - (isSubscribed) => - isSubscribed - ? FilledButton.icon( - onPressed: () async { - await _unsubscribeFromCategoryOrTag( - ref, - slug: slug, - isCategory: isCategory, - ); - }, - icon: const Icon( - Symbols.remove_circle, - ), - label: Text('unsubscribe'.tr()), - ) - : FilledButton.icon( - onPressed: () async { - await _subscribeToCategoryOrTag( - ref, - slug: slug, - isCategory: isCategory, - ); - }, - icon: const Icon( - Symbols.add_circle, - ), - label: Text('subscribe'.tr()), - ), - error: - (error, _) => Text( - 'Error loading subscription status', - ), - loading: - () => - CircularProgressIndicator().center(), - ), - ], - ).padding(horizontal: 24, vertical: 16), - error: - (error, _) => ResponseErrorWidget( - error: error, - onRetry: - () => ref.invalidate( - postCategoryProvider(slug), - ), + data: (category) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + category.categoryDisplayTitle, + ).bold().fontSize(15), + Text('A category'), + const Gap(8), + subscriptionStatus.when( + data: (isSubscribed) => isSubscribed + ? FilledButton.icon( + onPressed: () async { + await _unsubscribeFromCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon(Symbols.remove_circle), + label: Text('unsubscribe'.tr()), + ) + : FilledButton.icon( + onPressed: () async { + await _subscribeToCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon(Symbols.add_circle), + label: Text('subscribe'.tr()), + ), + error: (error, _) => + Text('Error loading subscription status'), + loading: () => + CircularProgressIndicator().center(), ), + ], + ).padding(horizontal: 24, vertical: 16), + error: (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => + ref.invalidate(postCategoryProvider(slug)), + ), loading: () => ResponseLoadingWidget(), ), ), @@ -179,61 +167,49 @@ class PostCategoryDetailScreen extends HookConsumerWidget { child: Card( margin: EdgeInsets.only(top: 8), child: postTag!.when( - data: - (tag) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tag.name ?? '#${tag.slug}', - ).bold().fontSize(15), - Text('A tag'), - const Gap(8), - subscriptionStatus.when( - data: - (isSubscribed) => - isSubscribed - ? FilledButton.icon( - onPressed: () async { - await _unsubscribeFromCategoryOrTag( - ref, - slug: slug, - isCategory: isCategory, - ); - }, - icon: const Icon( - Symbols.remove_circle, - ), - label: Text('unsubscribe'.tr()), - ) - : FilledButton.icon( - onPressed: () async { - await _subscribeToCategoryOrTag( - ref, - slug: slug, - isCategory: isCategory, - ); - }, - icon: const Icon( - Symbols.add_circle, - ), - label: Text('subscribe'.tr()), - ), - error: - (error, _) => Text( - 'Error loading subscription status', - ), - loading: - () => - CircularProgressIndicator().center(), - ), - ], - ).padding(horizontal: 24, vertical: 16), - error: - (error, _) => ResponseErrorWidget( - error: error, - onRetry: - () => ref.invalidate(postTagProvider(slug)), + data: (tag) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tag.name ?? '#${tag.slug}', + ).bold().fontSize(15), + Text('A tag'), + const Gap(8), + subscriptionStatus.when( + data: (isSubscribed) => isSubscribed + ? FilledButton.icon( + onPressed: () async { + await _unsubscribeFromCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon(Symbols.remove_circle), + label: Text('unsubscribe'.tr()), + ) + : FilledButton.icon( + onPressed: () async { + await _subscribeToCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon(Symbols.add_circle), + label: Text('subscribe'.tr()), + ), + error: (error, _) => + Text('Error loading subscription status'), + loading: () => + CircularProgressIndicator().center(), ), + ], + ).padding(horizontal: 24, vertical: 16), + error: (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => ref.invalidate(postTagProvider(slug)), + ), loading: () => ResponseLoadingWidget(), ), ), @@ -242,8 +218,11 @@ class PostCategoryDetailScreen extends HookConsumerWidget { ), const SliverGap(4), SliverPostList( - categories: isCategory ? [slug] : null, - tags: isCategory ? null : [slug], + query: PostListQuery( + categories: isCategory ? [slug] : null, + tags: isCategory ? null : [slug], + ), + maxWidth: 540 + 16, ), SliverGap(MediaQuery.of(context).padding.bottom + 8), diff --git a/lib/screens/posts/post_search.dart b/lib/screens/posts/post_search.dart index 93288ad3..d12f9601 100644 --- a/lib/screens/posts/post_search.dart +++ b/lib/screens/posts/post_search.dart @@ -3,140 +3,18 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/models/post.dart'; -import 'package:island/pods/network.dart'; import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/posts/post_filter.dart'; import 'package:gap/gap.dart'; -import 'package:island/pods/paging.dart'; +import 'package:island/pods/post/post_list.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/paging/pagination_list.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; -final postSearchProvider = AsyncNotifierProvider.autoDispose( - PostSearchNotifier.new, -); - -class PostSearchNotifier extends AsyncNotifier> - with AsyncPaginationController { - static const int _pageSize = 20; - String _currentQuery = ''; - String? _pubName; - String? _realm; - int? _type; - List? _categories; - List? _tags; - bool _shuffle = false; - bool? _pinned; - - @override - FutureOr> build() async { - // Initial state is empty if no query/filters, or fetch if needed - // But original logic allowed initial empty state. - // Let's replicate original logic: return empty list initially if no query. - return []; - } - - bool? _includeReplies; - bool _mediaOnly = false; - String? _queryTerm; - String? _order; - bool _orderDesc = true; - int? _periodStart; - int? _periodEnd; - - Future search( - String query, { - String? pubName, - String? realm, - int? type, - List? categories, - List? tags, - bool shuffle = false, - bool? pinned, - bool? includeReplies, - bool mediaOnly = false, - String? queryTerm, - String? order, - bool orderDesc = true, - int? periodStart, - int? periodEnd, - }) async { - _currentQuery = query.trim(); - _pubName = pubName; - _realm = realm; - _type = type; - _categories = categories; - _tags = tags; - _shuffle = shuffle; - _pinned = pinned; - _includeReplies = includeReplies; - _mediaOnly = mediaOnly; - _queryTerm = queryTerm; - _order = order; - _orderDesc = orderDesc; - _periodStart = periodStart; - _periodEnd = periodEnd; - - final hasFilters = - pubName != null || - realm != null || - type != null || - categories != null || - tags != null || - shuffle || - pinned != null || - includeReplies != null || - mediaOnly || - queryTerm != null || - order != null || - periodStart != null || - periodEnd != null; - - if (_currentQuery.isEmpty && !hasFilters) { - state = const AsyncData([]); - totalCount = null; - return; - } - - await refresh(); - } - - @override - Future> fetch() async { - final client = ref.read(apiClientProvider); - - final response = await client.get( - '/sphere/posts', - queryParameters: { - 'query': _currentQuery, - 'offset': fetchedCount, - 'take': _pageSize, - 'vector': false, - if (_pubName != null) 'pub': _pubName, - if (_realm != null) 'realm': _realm, - if (_type != null) 'type': _type, - if (_tags != null) 'tags': _tags, - if (_categories != null) 'categories': _categories, - if (_shuffle) 'shuffle': true, - if (_pinned != null) 'pinned': _pinned, - if (_includeReplies != null) 'includeReplies': _includeReplies, - if (_mediaOnly) 'mediaOnly': true, - if (_queryTerm != null) 'queryTerm': _queryTerm, - if (_order != null) 'order': _order, - if (_orderDesc) 'orderDesc': true, - if (_periodStart != null) 'periodStart': _periodStart, - if (_periodEnd != null) 'periodEnd': _periodEnd, - }, - ); - - totalCount = int.parse(response.headers.value('X-Total') ?? '0'); - final data = response.data as List; - return data.map((json) => SnPost.fromJson(json)).toList(); - } -} +const kSearchPostListId = 'search'; class PostSearchScreen extends HookConsumerWidget { const PostSearchScreen({super.key}); @@ -149,22 +27,14 @@ class PostSearchScreen extends HookConsumerWidget { final showFilters = useState(false); final pubNameController = useTextEditingController(); final realmController = useTextEditingController(); - final typeValue = useState(null); - final selectedCategories = useState>([]); - final selectedTags = useState>([]); - final shuffleValue = useState(false); - final pinnedValue = useState(null); // State variables for PostFilterWidget final categoryTabController = useTabController(initialLength: 3); - final includeReplies = useState(null); - final mediaOnly = useState(false); - final queryTerm = useState(null); - final order = useState('date'); - final orderDesc = useState(true); - final periodStart = useState(null); - final periodEnd = useState(null); - final showAdvancedFilters = useState(false); + + // Single query state + final queryState = useState(const PostListQuery()); + + final noti = ref.read(postListProvider(kSearchPostListId).notifier); useEffect(() { return () { @@ -175,77 +45,32 @@ class PostSearchScreen extends HookConsumerWidget { }; }, []); - void onSearchChanged(String query) { - if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel(); + void onSearchChanged(String query, {bool skipDebounce = false}) { + queryState.value = queryState.value.copyWith(queryTerm: query); + if (skipDebounce) { + noti.applyFilter(queryState.value); + return; + } + + if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel(); debounceTimer.value = Timer(debounce, () { - ref - .read(postSearchProvider.notifier) - .search( - query, - type: categoryTabController.index == 1 - ? 0 - : (categoryTabController.index == 2 ? 1 : null), - includeReplies: includeReplies.value, - mediaOnly: mediaOnly.value, - queryTerm: queryTerm.value, - order: order.value, - orderDesc: orderDesc.value, - periodStart: periodStart.value, - periodEnd: periodEnd.value, - ); + noti.applyFilter(queryState.value); }); } - void onSearchWithFilters(String query) { - if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel(); - - debounceTimer.value = Timer(debounce, () { - ref - .read(postSearchProvider.notifier) - .search( - query, - pubName: pubNameController.text.isNotEmpty - ? pubNameController.text - : null, - realm: realmController.text.isNotEmpty - ? realmController.text - : null, - type: categoryTabController.index == 1 - ? 0 - : (categoryTabController.index == 2 ? 1 : null), - categories: selectedCategories.value.isNotEmpty - ? selectedCategories.value - : null, - tags: selectedTags.value.isNotEmpty ? selectedTags.value : null, - shuffle: shuffleValue.value, - pinned: pinnedValue.value, - includeReplies: includeReplies.value, - mediaOnly: mediaOnly.value, - queryTerm: queryTerm.value, - order: order.value, - orderDesc: orderDesc.value, - periodStart: periodStart.value, - periodEnd: periodEnd.value, - ); - }); - } - - void toggleFilters() { + void toggleFilterDisplay() { showFilters.value = !showFilters.value; } Widget buildFilterPanel() { return PostFilterWidget( categoryTabController: categoryTabController, - includeReplies: includeReplies, - mediaOnly: mediaOnly, - queryTerm: queryTerm, - order: order, - orderDesc: orderDesc, - periodStart: periodStart, - periodEnd: periodEnd, - showAdvancedFilters: showAdvancedFilters, + initialQuery: queryState.value, + onQueryChanged: (newQuery) { + queryState.value = newQuery; + noti.applyFilter(newQuery); + }, hideSearch: true, ); } @@ -272,7 +97,7 @@ class PostSearchScreen extends HookConsumerWidget { ), onChanged: onSearchChanged, onSubmitted: (value) { - onSearchWithFilters(value); + onSearchChanged(value, skipDebounce: true); }, autofocus: true, ), @@ -283,7 +108,7 @@ class PostSearchScreen extends HookConsumerWidget { ? Icons.filter_alt : Icons.filter_alt_outlined, ), - onPressed: toggleFilters, + onPressed: toggleFilterDisplay, tooltip: 'toggleFilters'.tr(), ), ], @@ -291,62 +116,76 @@ class PostSearchScreen extends HookConsumerWidget { ), body: Consumer( builder: (context, ref, child) { - final searchState = ref.watch(postSearchProvider); + final searchState = ref.watch(postListProvider(kSearchPostListId)); return isWideScreen(context) ? Row( children: [ Flexible( flex: 4, - child: CustomScrollView( - slivers: [ - SliverGap(16), - SliverToBoxAdapter( - child: SearchBar( - elevation: WidgetStateProperty.all(4), - controller: searchController, - hintText: 'search'.tr(), - leading: const Icon(Icons.search), - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(horizontal: 24), + child: ExtendedRefreshIndicator( + onRefresh: noti.refresh, + child: CustomScrollView( + slivers: [ + SliverGap(16), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + child: SearchBar( + elevation: WidgetStateProperty.all(4), + controller: searchController, + hintText: 'search'.tr(), + leading: const Icon(Icons.search), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 24), + ), + onChanged: onSearchChanged, + onSubmitted: (value) { + onSearchChanged(value, skipDebounce: true); + }, + ), ), - onChanged: onSearchChanged, - onSubmitted: (value) { - onSearchWithFilters(value); + ), + const SliverGap(16), + if (showFilters.value && !isWideScreen(context)) + SliverToBoxAdapter(child: buildFilterPanel()), + // Use PaginationList with isSliver=true + PaginationList( + provider: postListProvider(kSearchPostListId), + notifier: postListProvider( + kSearchPostListId, + ).notifier, + isSliver: true, + isRefreshable: false, + itemBuilder: (context, index, post) { + return Card( + margin: EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: PostActionableItem( + item: post, + borderRadius: 8, + ), + ); }, ), - ), - const SliverGap(16), - if (showFilters.value && !isWideScreen(context)) - SliverToBoxAdapter(child: buildFilterPanel()), - // Use PaginationList with isSliver=true - PaginationList( - provider: postSearchProvider, - notifier: postSearchProvider.notifier, - isSliver: true, - isRefreshable: false, - itemBuilder: (context, index, post) { - return Card( - margin: EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + if (searchState.value?.isEmpty == true && + searchController.text.isNotEmpty && + !searchState.isLoading) + SliverFillRemaining( + child: Center( + child: Text('noResultsFound'.tr()), ), - child: PostActionableItem( - item: post, - borderRadius: 8, - ), - ); - }, - ), - if (searchState.value?.isEmpty == true && - searchController.text.isNotEmpty && - !searchState.isLoading) - SliverFillRemaining( - child: Center(child: Text('noResultsFound'.tr())), + ), + SliverGap( + MediaQuery.of(context).padding.bottom + 16, ), - SliverGap(MediaQuery.of(context).padding.bottom + 16), - ], - ).padding(left: 8), + ], + ).padding(left: 8), + ), ), Flexible( flex: 3, @@ -382,7 +221,7 @@ class PostSearchScreen extends HookConsumerWidget { Symbols.filter_alt, fill: showFilters.value ? 1 : null, ), - onPressed: toggleFilters, + onPressed: toggleFilterDisplay, tooltip: 'toggleFilters'.tr(), ), const Gap(4), @@ -412,8 +251,8 @@ class PostSearchScreen extends HookConsumerWidget { ), // Use PaginationList with isSliver=true PaginationList( - provider: postSearchProvider, - notifier: postSearchProvider.notifier, + provider: postListProvider(kSearchPostListId), + notifier: postListProvider(kSearchPostListId).notifier, isSliver: true, isRefreshable: false, itemBuilder: (context, index, post) { diff --git a/lib/screens/posts/publisher_profile.dart b/lib/screens/posts/publisher_profile.dart index 32661b85..a08a2366 100644 --- a/lib/screens/posts/publisher_profile.dart +++ b/lib/screens/posts/publisher_profile.dart @@ -11,6 +11,7 @@ import 'package:island/models/account.dart'; import 'package:island/models/heatmap.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; +import 'package:island/pods/post/post_list.dart'; import 'package:island/services/color.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/account/account_name.dart'; @@ -344,45 +345,6 @@ class _PublisherHeatmapWidget extends StatelessWidget { } } -class _PublisherCategoryTabWidget extends StatelessWidget { - final TabController categoryTabController; - final ValueNotifier includeReplies; - final ValueNotifier mediaOnly; - final ValueNotifier queryTerm; - final ValueNotifier order; - final ValueNotifier orderDesc; - final ValueNotifier periodStart; - final ValueNotifier periodEnd; - final ValueNotifier showAdvancedFilters; - - const _PublisherCategoryTabWidget({ - required this.categoryTabController, - required this.includeReplies, - required this.mediaOnly, - required this.queryTerm, - required this.order, - required this.orderDesc, - required this.periodStart, - required this.periodEnd, - required this.showAdvancedFilters, - }); - - @override - Widget build(BuildContext context) { - return PostFilterWidget( - categoryTabController: categoryTabController, - includeReplies: includeReplies, - mediaOnly: mediaOnly, - queryTerm: queryTerm, - order: order, - orderDesc: orderDesc, - periodStart: periodStart, - periodEnd: periodEnd, - showAdvancedFilters: showAdvancedFilters, - ); - } -} - @riverpod Future publisher(Ref ref, String uname) async { final apiClient = ref.watch(apiClientProvider); @@ -454,24 +416,22 @@ class PublisherProfileScreen extends HookConsumerWidget { ); final categoryTabController = useTabController(initialLength: 3); - final categoryTab = useState(0); - categoryTabController.addListener(() { - categoryTab.value = categoryTabController.index; - }); - final includeReplies = useState(null); - final mediaOnly = useState(false); - final queryTerm = useState(null); - final order = useState('date'); // 'popularity' or 'date' - final orderDesc = useState( - true, - ); // true for descending, false for ascending - final periodStart = useState(null); - final periodEnd = useState(null); - final showAdvancedFilters = useState(false); + final queryState = useState(PostListQuery(pubName: name)); + final subscribing = useState(false); final isPinnedExpanded = useState(true); + useEffect(() { + final index = switch (queryState.value.type) { + 0 => 1, + 1 => 2, + _ => 0, + }; + categoryTabController.index = index; + return null; + }, []); + Future subscribe() async { final apiClient = ref.watch(apiClientProvider); subscribing.value = true; @@ -564,37 +524,22 @@ class PublisherProfileScreen extends HookConsumerWidget { ), ...[ if (isPinnedExpanded.value) - SliverPostList(pubName: name, pinned: true), + SliverPostList( + query: PostListQuery(pubName: name, pinned: true), + queryKey: 'publisher-$name-pinned', + ), ], SliverToBoxAdapter( - child: _PublisherCategoryTabWidget( + child: PostFilterWidget( categoryTabController: categoryTabController, - includeReplies: includeReplies, - mediaOnly: mediaOnly, - queryTerm: queryTerm, - order: order, - orderDesc: orderDesc, - periodStart: periodStart, - periodEnd: periodEnd, - showAdvancedFilters: showAdvancedFilters, + initialQuery: queryState.value, + onQueryChanged: (newQuery) => + queryState.value = newQuery, ), ), SliverPostList( - key: ValueKey( - '${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}', - ), - pubName: name, - pinned: false, - type: categoryTab.value == 1 - ? 0 - : (categoryTab.value == 2 ? 1 : null), - includeReplies: includeReplies.value, - mediaOnly: mediaOnly.value, - queryTerm: queryTerm.value, - order: order.value, - orderDesc: orderDesc.value, - periodStart: periodStart.value, - periodEnd: periodEnd.value, + query: queryState.value, + queryKey: 'publisher-$name', ), SliverGap(MediaQuery.of(context).padding.bottom + 16), ], @@ -704,37 +649,22 @@ class PublisherProfileScreen extends HookConsumerWidget { ), ...[ if (isPinnedExpanded.value) - SliverPostList(pubName: name, pinned: true), + SliverPostList( + query: PostListQuery(pubName: name, pinned: true), + queryKey: 'publisher-$name-pinned', + ), ], SliverToBoxAdapter( - child: _PublisherCategoryTabWidget( + child: PostFilterWidget( categoryTabController: categoryTabController, - includeReplies: includeReplies, - mediaOnly: mediaOnly, - queryTerm: queryTerm, - order: order, - orderDesc: orderDesc, - periodStart: periodStart, - periodEnd: periodEnd, - showAdvancedFilters: showAdvancedFilters, + initialQuery: queryState.value, + onQueryChanged: (newQuery) => queryState.value = newQuery, ), ), SliverPostList( - key: ValueKey( - '${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}', - ), - pubName: name, - pinned: false, - type: categoryTab.value == 1 - ? 0 - : (categoryTab.value == 2 ? 1 : null), - includeReplies: includeReplies.value, - mediaOnly: mediaOnly.value, - queryTerm: queryTerm.value, - order: order.value, - orderDesc: orderDesc.value, - periodStart: periodStart.value, - periodEnd: periodEnd.value, + key: ValueKey(queryState.value), + query: queryState.value, + queryKey: 'publisher-$name', ), SliverGap(MediaQuery.of(context).padding.bottom + 16), ], diff --git a/lib/screens/realm/realm_detail.dart b/lib/screens/realm/realm_detail.dart index 0cf88fa7..c5740ae1 100644 --- a/lib/screens/realm/realm_detail.dart +++ b/lib/screens/realm/realm_detail.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:island/pods/post/post_list.dart'; import 'package:island/screens/chat/chat.dart'; import 'package:flutter/material.dart'; import 'package:island/models/chat.dart'; @@ -171,204 +172,180 @@ class RealmDetailScreen extends HookConsumerWidget { return AppScaffold( isNoBackground: false, - appBar: - isWideScreen(context) - ? realmState.when( - data: - (realm) => AppBar( - foregroundColor: appbarColor.value, - leading: PageBackButton( - color: appbarColor.value, - shadows: [iconShadow], - ), - flexibleSpace: Stack( - children: [ - Positioned.fill( - child: - realm!.background?.id != null - ? CloudImageWidget( - fileId: realm.background!.id, - ) - : Container( - color: - Theme.of( - context, - ).appBarTheme.backgroundColor, - ), - ), - FlexibleSpaceBar( - title: Text( - realm.name, - style: TextStyle( - color: - appbarColor.value ?? - Theme.of( - context, - ).appBarTheme.foregroundColor, - shadows: [iconShadow], - ), + appBar: isWideScreen(context) + ? realmState.when( + data: (realm) => AppBar( + foregroundColor: appbarColor.value, + leading: PageBackButton( + color: appbarColor.value, + shadows: [iconShadow], + ), + flexibleSpace: Stack( + children: [ + Positioned.fill( + child: realm!.background?.id != null + ? CloudImageWidget(fileId: realm.background!.id) + : Container( + color: Theme.of( + context, + ).appBarTheme.backgroundColor, ), - background: Container(), - ), - ], - ), - actions: [ - IconButton( - icon: Icon(Icons.people, shadows: [iconShadow]), - onPressed: () { - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: - (context) => - _RealmMemberListSheet(realmSlug: slug), - ); - }, - ), - _RealmActionMenu( - realmSlug: slug, - iconShadow: iconShadow, - ), - const Gap(8), - ], ), - error: (_, _) => AppBar(leading: PageBackButton()), - loading: () => AppBar(leading: PageBackButton()), - ) - : null, + FlexibleSpaceBar( + title: Text( + realm.name, + style: TextStyle( + color: + appbarColor.value ?? + Theme.of(context).appBarTheme.foregroundColor, + shadows: [iconShadow], + ), + ), + background: Container(), + ), + ], + ), + actions: [ + IconButton( + icon: Icon(Icons.people, shadows: [iconShadow]), + onPressed: () { + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => + _RealmMemberListSheet(realmSlug: slug), + ); + }, + ), + _RealmActionMenu(realmSlug: slug, iconShadow: iconShadow), + const Gap(8), + ], + ), + error: (_, _) => AppBar(leading: PageBackButton()), + loading: () => AppBar(leading: PageBackButton()), + ) + : null, body: realmState.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('Error: $error')), - data: - (realm) => - isWideScreen(context) - ? Row( - children: [ - Flexible( - flex: 3, - child: CustomScrollView( - slivers: [ - SliverPostList(realm: slug, pinned: true), - SliverPostList(realm: slug, pinned: false), - ], - ), - ), - Flexible( - flex: 2, - child: Column( - children: [ - realmIdentity.when( - loading: () => const SizedBox.shrink(), - error: (_, _) => const SizedBox.shrink(), - data: - (identity) => Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - realmDescriptionWidget(realm!), - if (identity == null && - realm.isCommunity) - realmActionWidget(realm) - else - const SizedBox.shrink(), - ], - ), - ), - realmChatRoomListWidget(realm!), - ], - ), - ), - ], - ).padding(horizontal: 8, top: 8) - : CustomScrollView( + data: (realm) => isWideScreen(context) + ? Row( + children: [ + Flexible( + flex: 3, + child: CustomScrollView( slivers: [ - SliverAppBar( - expandedHeight: 180, - pinned: true, - foregroundColor: appbarColor.value, - leading: PageBackButton( - color: appbarColor.value, - shadows: [iconShadow], - ), - flexibleSpace: Stack( - children: [ - Positioned.fill( - child: - realm!.background?.id != null - ? CloudImageWidget( - fileId: realm.background!.id, - ) - : Container( - color: - Theme.of( - context, - ).appBarTheme.backgroundColor, - ), - ), - FlexibleSpaceBar( - title: Text( - realm.name, - style: TextStyle( - color: - appbarColor.value ?? - Theme.of( - context, - ).appBarTheme.foregroundColor, - shadows: [iconShadow], - ), - ), - background: - Container(), // Empty container since background is handled by Stack - ), - ], - ), - actions: [ - IconButton( - icon: Icon(Icons.people, shadows: [iconShadow]), - onPressed: () { - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: - (context) => _RealmMemberListSheet( - realmSlug: slug, - ), - ); - }, - ), - _RealmActionMenu( - realmSlug: slug, - iconShadow: iconShadow, - ), - const Gap(8), - ], + SliverPostList( + query: PostListQuery(realm: slug, pinned: true), ), - SliverGap(4), - SliverToBoxAdapter( - child: realmIdentity.when( - loading: () => const SizedBox.shrink(), - error: (_, _) => const SizedBox.shrink(), - data: - (identity) => Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - realmDescriptionWidget(realm), - if (identity == null && realm.isCommunity) - realmActionWidget(realm) - else - const SizedBox.shrink(), - ], - ), - ), + SliverPostList( + query: PostListQuery(realm: slug, pinned: false), ), - SliverToBoxAdapter( - child: realmChatRoomListWidget(realm), - ), - SliverPostList(realm: slug, pinned: true), - SliverPostList(realm: slug, pinned: false), ], ), + ), + Flexible( + flex: 2, + child: Column( + children: [ + realmIdentity.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (identity) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + realmDescriptionWidget(realm!), + if (identity == null && realm.isCommunity) + realmActionWidget(realm) + else + const SizedBox.shrink(), + ], + ), + ), + realmChatRoomListWidget(realm!), + ], + ), + ), + ], + ).padding(horizontal: 8, top: 8) + : CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 180, + pinned: true, + foregroundColor: appbarColor.value, + leading: PageBackButton( + color: appbarColor.value, + shadows: [iconShadow], + ), + flexibleSpace: Stack( + children: [ + Positioned.fill( + child: realm!.background?.id != null + ? CloudImageWidget(fileId: realm.background!.id) + : Container( + color: Theme.of( + context, + ).appBarTheme.backgroundColor, + ), + ), + FlexibleSpaceBar( + title: Text( + realm.name, + style: TextStyle( + color: + appbarColor.value ?? + Theme.of(context).appBarTheme.foregroundColor, + shadows: [iconShadow], + ), + ), + background: + Container(), // Empty container since background is handled by Stack + ), + ], + ), + actions: [ + IconButton( + icon: Icon(Icons.people, shadows: [iconShadow]), + onPressed: () { + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => + _RealmMemberListSheet(realmSlug: slug), + ); + }, + ), + _RealmActionMenu(realmSlug: slug, iconShadow: iconShadow), + const Gap(8), + ], + ), + SliverGap(4), + SliverToBoxAdapter( + child: realmIdentity.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (identity) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + realmDescriptionWidget(realm), + if (identity == null && realm.isCommunity) + realmActionWidget(realm) + else + const SizedBox.shrink(), + ], + ), + ), + ), + SliverToBoxAdapter(child: realmChatRoomListWidget(realm)), + SliverPostList( + query: PostListQuery(realm: slug, pinned: true), + ), + SliverPostList( + query: PostListQuery(realm: slug, pinned: false), + ), + ], + ), ), ); } @@ -391,135 +368,125 @@ class _RealmActionMenu extends HookConsumerWidget { return PopupMenuButton( icon: Icon(Icons.more_vert, shadows: [iconShadow]), - itemBuilder: - (context) => [ - if (isModerator) - PopupMenuItem( - onTap: () { - context.pushReplacementNamed( - 'realmEdit', - pathParameters: {'slug': realmSlug}, - ); - }, - child: Row( - children: [ - Icon( - Icons.edit, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - const Gap(12), - const Text('editRealm').tr(), - ], + itemBuilder: (context) => [ + if (isModerator) + PopupMenuItem( + onTap: () { + context.pushReplacementNamed( + 'realmEdit', + pathParameters: {'slug': realmSlug}, + ); + }, + child: Row( + children: [ + Icon( + Icons.edit, + color: Theme.of(context).colorScheme.onSecondaryContainer, ), - ), - realmIdentity.when( - data: - (identity) => - (identity?.role ?? 0) >= 100 - ? PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.delete, color: Colors.red), - const Gap(12), - const Text( - 'deleteRealm', - style: TextStyle(color: Colors.red), - ).tr(), - ], - ), - onTap: () { - showConfirmAlert( - 'deleteRealmHint'.tr(), - 'deleteRealm'.tr(), - isDanger: true, - ).then((confirm) { - if (confirm) { - final client = ref.watch(apiClientProvider); - client.delete('/pass/realms/$realmSlug'); - ref.invalidate(realmsJoinedProvider); - if (context.mounted) { - context.pop(true); - } - } - }); - }, - ) - : PopupMenuItem( - child: Row( - children: [ - Icon( - Icons.exit_to_app, - color: Theme.of(context).colorScheme.error, - ), - const Gap(12), - Text( - 'leaveRealm', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ).tr(), - ], - ), - onTap: () { - showConfirmAlert( - 'leaveRealmHint'.tr(), - 'leaveRealm'.tr(), - ).then((confirm) async { - if (confirm) { - final client = ref.watch(apiClientProvider); - await client.delete( - '/pass/realms/$realmSlug/members/me', - ); - ref.invalidate(realmsJoinedProvider); - if (context.mounted) { - context.pop(true); - } - } - }); - }, - ), - loading: - () => const PopupMenuItem( - enabled: false, - child: Center(child: CircularProgressIndicator()), + const Gap(12), + const Text('editRealm').tr(), + ], + ), + ), + realmIdentity.when( + data: (identity) => (identity?.role ?? 0) >= 100 + ? PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.delete, color: Colors.red), + const Gap(12), + const Text( + 'deleteRealm', + style: TextStyle(color: Colors.red), + ).tr(), + ], ), - error: - (_, _) => PopupMenuItem( - child: Row( - children: [ - Icon( - Icons.exit_to_app, + onTap: () { + showConfirmAlert( + 'deleteRealmHint'.tr(), + 'deleteRealm'.tr(), + isDanger: true, + ).then((confirm) { + if (confirm) { + final client = ref.watch(apiClientProvider); + client.delete('/pass/realms/$realmSlug'); + ref.invalidate(realmsJoinedProvider); + if (context.mounted) { + context.pop(true); + } + } + }); + }, + ) + : PopupMenuItem( + child: Row( + children: [ + Icon( + Icons.exit_to_app, + color: Theme.of(context).colorScheme.error, + ), + const Gap(12), + Text( + 'leaveRealm', + style: TextStyle( color: Theme.of(context).colorScheme.error, ), - const Gap(12), - Text( - 'leaveRealm', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ).tr(), - ], - ), - onTap: () { - showConfirmAlert( - 'leaveRealmHint'.tr(), - 'leaveRealm'.tr(), - ).then((confirm) async { - if (confirm) { - final client = ref.watch(apiClientProvider); - await client.delete( - '/pass/realms/$realmSlug/members/me', - ); - ref.invalidate(realmsJoinedProvider); - if (context.mounted) { - context.pop(true); - } - } - }); - }, + ).tr(), + ], ), + onTap: () { + showConfirmAlert( + 'leaveRealmHint'.tr(), + 'leaveRealm'.tr(), + ).then((confirm) async { + if (confirm) { + final client = ref.watch(apiClientProvider); + await client.delete( + '/pass/realms/$realmSlug/members/me', + ); + ref.invalidate(realmsJoinedProvider); + if (context.mounted) { + context.pop(true); + } + } + }); + }, + ), + loading: () => const PopupMenuItem( + enabled: false, + child: Center(child: CircularProgressIndicator()), + ), + error: (_, _) => PopupMenuItem( + child: Row( + children: [ + Icon( + Icons.exit_to_app, + color: Theme.of(context).colorScheme.error, + ), + const Gap(12), + Text( + 'leaveRealm', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ).tr(), + ], ), - ], + onTap: () { + showConfirmAlert('leaveRealmHint'.tr(), 'leaveRealm'.tr()).then(( + confirm, + ) async { + if (confirm) { + final client = ref.watch(apiClientProvider); + await client.delete('/pass/realms/$realmSlug/members/me'); + ref.invalidate(realmsJoinedProvider); + if (context.mounted) { + context.pop(true); + } + } + }); + }, + ), + ), + ], ); } } @@ -684,11 +651,10 @@ class _RealmMemberListSheet extends HookConsumerWidget { showModalBottomSheet( isScrollControlled: true, context: context, - builder: - (context) => _RealmMemberRoleSheet( - realmSlug: realmSlug, - member: member, - ), + builder: (context) => _RealmMemberRoleSheet( + realmSlug: realmSlug, + member: member, + ), ).then((value) { if (value != null) { // Refresh the provider @@ -809,23 +775,19 @@ class _RealmMemberRoleSheet extends HookConsumerWidget { onSelected: (int selection) { roleController.text = selection.toString(); }, - fieldViewBuilder: ( - context, - controller, - focusNode, - onFieldSubmitted, - ) { - return TextField( - controller: controller, - focusNode: focusNode, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: 'memberRole'.tr(), - helperText: 'memberRoleHint'.tr(), - ), - onTapOutside: (event) => focusNode.unfocus(), - ); - }, + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'memberRole'.tr(), + helperText: 'memberRoleHint'.tr(), + ), + onTapOutside: (event) => focusNode.unfocus(), + ); + }, ), const Gap(16), FilledButton.icon( diff --git a/lib/widgets/paging/pagination_list.dart b/lib/widgets/paging/pagination_list.dart index 7326640f..d9a06835 100644 --- a/lib/widgets/paging/pagination_list.dart +++ b/lib/widgets/paging/pagination_list.dart @@ -39,7 +39,7 @@ class PaginationList extends HookConsumerWidget { final data = ref.watch(provider); final noti = ref.watch(notifier); - if (data.isLoading && data.value?.isEmpty == true) { + if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) { final content = ResponseLoadingWidget(); return isSliver ? SliverFillRemaining(child: content) : content; } @@ -115,7 +115,7 @@ class PaginationWidget extends HookConsumerWidget { final data = ref.watch(provider); final noti = ref.watch(notifier); - if (data.isLoading && data.value?.isEmpty == true) { + if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) { final content = ResponseLoadingWidget(); return isSliver ? SliverFillRemaining(child: content) : content; } diff --git a/lib/widgets/post/post_list.dart b/lib/widgets/post/post_list.dart index 6698fc0c..c65e13bd 100644 --- a/lib/widgets/post/post_list.dart +++ b/lib/widgets/post/post_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/post.dart'; import 'package:island/pods/post/post_list.dart'; @@ -17,21 +18,7 @@ enum PostItemType { } class SliverPostList extends HookConsumerWidget { - final String? pubName; - final String? realm; - final int? type; - final List? categories; - final List? tags; - final bool shuffle; - final bool? pinned; - final bool? includeReplies; - final bool? mediaOnly; - final String? queryTerm; - // Can be "populaurity", other value will be treated as "date" - final String? order; - final int? periodStart; - final int? periodEnd; - final bool? orderDesc; + final PostListQuery? query; final PostItemType itemType; final Color? backgroundColor; final EdgeInsets? padding; @@ -39,23 +26,11 @@ class SliverPostList extends HookConsumerWidget { final Function? onRefresh; final Function(SnPost)? onUpdate; final double? maxWidth; + final String? queryKey; const SliverPostList({ super.key, - this.pubName, - this.realm, - this.type, - this.categories, - this.tags, - this.shuffle = false, - this.pinned, - this.includeReplies, - this.mediaOnly, - this.queryTerm, - this.order, - this.orderDesc = true, - this.periodStart, - this.periodEnd, + this.query, this.itemType = PostItemType.regular, this.backgroundColor, this.padding, @@ -63,29 +38,19 @@ class SliverPostList extends HookConsumerWidget { this.onRefresh, this.onUpdate, this.maxWidth, + this.queryKey, }); @override Widget build(BuildContext context, WidgetRef ref) { - final params = PostListQuery( - pubName: pubName, - realm: realm, - type: type, - categories: categories, - tags: tags, - shuffle: shuffle, - pinned: pinned, - includeReplies: includeReplies, - mediaOnly: mediaOnly, - queryTerm: queryTerm, - order: order, - periodStart: periodStart, - periodEnd: periodEnd, - orderDesc: orderDesc ?? true, - ); - final provider = postListNotifierProvider(params); + final provider = postListProvider(queryKey); final notifier = provider.notifier; + useEffect(() { + ref.read(notifier).applyFilter(query!); + return null; + }, [query]); + return PaginationList( provider: provider, notifier: notifier, diff --git a/lib/widgets/post/post_shuffle.dart b/lib/widgets/post/post_shuffle.dart index 492560e3..0be8d51c 100644 --- a/lib/widgets/post/post_shuffle.dart +++ b/lib/widgets/post/post_shuffle.dart @@ -3,26 +3,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_card_swiper/flutter_card_swiper.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/post/post_list.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/post/post_item.dart'; -import 'package:island/widgets/post/post_list.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; +const kShufflePostListId = 'shuffle'; + class PostShuffleScreen extends HookConsumerWidget { const PostShuffleScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - const params = PostListQuery(shuffle: true); - final postListState = ref.watch(postListNotifierProvider(params)); + const query = PostListQuery(shuffle: true); + final postListState = ref.watch(postListProvider(kShufflePostListId)); final postListNotifier = ref.watch( - postListNotifierProvider(params).notifier, + postListProvider(kShufflePostListId).notifier, ); final cardSwiperController = useMemoized(() => CardSwiperController(), []); useEffect(() { + postListNotifier.applyFilter(query); return cardSwiperController.dispose; }, []); @@ -46,29 +49,32 @@ class PostShuffleScreen extends HookConsumerWidget { controller: cardSwiperController, cardsCount: items.length, isLoop: false, - cardBuilder: ( - context, - index, - horizontalOffsetPercentage, - verticalOffsetPercentage, - ) { - return Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 540), - child: SingleChildScrollView( - child: Card( - margin: EdgeInsets.zero, - child: ClipRRect( - borderRadius: const BorderRadius.all( - Radius.circular(8), + cardBuilder: + ( + context, + index, + horizontalOffsetPercentage, + verticalOffsetPercentage, + ) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 540), + child: SingleChildScrollView( + child: Card( + margin: EdgeInsets.zero, + child: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + child: PostActionableItem( + item: items[index], + ), + ), ), - child: PostActionableItem(item: items[index]), ), ), - ), - ), - ); - }, + ); + }, onEnd: () async { if (!postListNotifier.fetchedAll) { postListNotifier.fetchFurther(); @@ -91,24 +97,23 @@ class PostShuffleScreen extends HookConsumerWidget { bottom: MediaQuery.of(context).padding.bottom, ), height: kBottomControlHeight, - child: - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () { - cardSwiperController.undo(); - }, - icon: const Icon(Symbols.arrow_left_alt), - ), - IconButton( - onPressed: () { - cardSwiperController.swipe(CardSwiperDirection.right); - }, - icon: const Icon(Symbols.arrow_right_alt), - ), - ], - ).padding(all: 8).center(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () { + cardSwiperController.undo(); + }, + icon: const Icon(Symbols.arrow_left_alt), + ), + IconButton( + onPressed: () { + cardSwiperController.swipe(CardSwiperDirection.right); + }, + icon: const Icon(Symbols.arrow_right_alt), + ), + ], + ).padding(all: 8).center(), ), ), ], diff --git a/lib/widgets/posts/post_filter.dart b/lib/widgets/posts/post_filter.dart index 6a15f4e9..e3635a16 100644 --- a/lib/widgets/posts/post_filter.dart +++ b/lib/widgets/posts/post_filter.dart @@ -1,34 +1,89 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:island/pods/post/post_list.dart'; import 'package:material_symbols_icons/symbols.dart'; -class PostFilterWidget extends StatelessWidget { +class PostFilterWidget extends StatefulWidget { final TabController categoryTabController; - final ValueNotifier includeReplies; - final ValueNotifier mediaOnly; - final ValueNotifier queryTerm; - final ValueNotifier order; - final ValueNotifier orderDesc; - final ValueNotifier periodStart; - final ValueNotifier periodEnd; - final ValueNotifier showAdvancedFilters; + final PostListQuery initialQuery; + final ValueChanged onQueryChanged; final bool hideSearch; const PostFilterWidget({ super.key, required this.categoryTabController, - required this.includeReplies, - required this.mediaOnly, - required this.queryTerm, - required this.order, - required this.orderDesc, - required this.periodStart, - required this.periodEnd, - required this.showAdvancedFilters, + required this.initialQuery, + required this.onQueryChanged, this.hideSearch = false, }); + @override + State createState() => _PostFilterWidgetState(); +} + +class _PostFilterWidgetState extends State { + late bool? _includeReplies; + late bool _mediaOnly; + late String? _queryTerm; + late String? _order; + late bool _orderDesc; + late int? _periodStart; + late int? _periodEnd; + late int? _type; + late bool _showAdvancedFilters; + late TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _includeReplies = widget.initialQuery.includeReplies; + _mediaOnly = widget.initialQuery.mediaOnly ?? false; + _queryTerm = widget.initialQuery.queryTerm; + _order = widget.initialQuery.order; + _orderDesc = widget.initialQuery.orderDesc; + _periodStart = widget.initialQuery.periodStart; + _periodEnd = widget.initialQuery.periodEnd; + _type = widget.initialQuery.type; + _showAdvancedFilters = false; + _searchController = TextEditingController(text: _queryTerm); + + widget.categoryTabController.addListener(_onTabChanged); + } + + @override + void dispose() { + widget.categoryTabController.removeListener(_onTabChanged); + _searchController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + final tabIndex = widget.categoryTabController.index; + setState(() { + _type = switch (tabIndex) { + 1 => 0, + 2 => 1, + _ => null, + }; + }); + _updateQuery(); + } + + void _updateQuery() { + final newQuery = widget.initialQuery.copyWith( + includeReplies: _includeReplies, + mediaOnly: _mediaOnly, + queryTerm: _queryTerm, + order: _order, + periodStart: _periodStart, + periodEnd: _periodEnd, + orderDesc: _orderDesc, + type: _type, + ); + widget.onQueryChanged(newQuery); + } + @override Widget build(BuildContext context) { return Card( @@ -36,7 +91,7 @@ class PostFilterWidget extends StatelessWidget { child: Column( children: [ TabBar( - controller: categoryTabController, + controller: widget.categoryTabController, dividerColor: Colors.transparent, splashBorderRadius: const BorderRadius.all(Radius.circular(8)), tabs: [ @@ -53,17 +108,20 @@ class PostFilterWidget extends StatelessWidget { Expanded( child: CheckboxListTile( title: Text('reply'.tr()), - value: includeReplies.value, + value: _includeReplies, tristate: true, onChanged: (value) { // Cycle through: null -> false -> true -> null - if (includeReplies.value == null) { - includeReplies.value = false; - } else if (includeReplies.value == false) { - includeReplies.value = true; - } else { - includeReplies.value = null; - } + setState(() { + if (_includeReplies == null) { + _includeReplies = false; + } else if (_includeReplies == false) { + _includeReplies = true; + } else { + _includeReplies = null; + } + }); + _updateQuery(); }, dense: true, controlAffinity: ListTileControlAffinity.leading, @@ -73,11 +131,14 @@ class PostFilterWidget extends StatelessWidget { Expanded( child: CheckboxListTile( title: Text('attachments'.tr()), - value: mediaOnly.value, + value: _mediaOnly, onChanged: (value) { - if (value != null) { - mediaOnly.value = value; - } + setState(() { + if (value != null) { + _mediaOnly = value; + } + }); + _updateQuery(); }, dense: true, controlAffinity: ListTileControlAffinity.leading, @@ -88,11 +149,14 @@ class PostFilterWidget extends StatelessWidget { ), CheckboxListTile( title: Text('descendingOrder'.tr()), - value: orderDesc.value, + value: _orderDesc, onChanged: (value) { - if (value != null) { - orderDesc.value = value; - } + setState(() { + if (value != null) { + _orderDesc = value; + } + }); + _updateQuery(); }, dense: true, controlAffinity: ListTileControlAffinity.leading, @@ -109,23 +173,24 @@ class PostFilterWidget extends StatelessWidget { borderRadius: BorderRadius.all(const Radius.circular(8)), ), trailing: Icon( - showAdvancedFilters.value - ? Symbols.expand_less - : Symbols.expand_more, + _showAdvancedFilters ? Symbols.expand_less : Symbols.expand_more, ), onTap: () { - showAdvancedFilters.value = !showAdvancedFilters.value; + setState(() { + _showAdvancedFilters = !_showAdvancedFilters; + }); }, ), - if (showAdvancedFilters.value) ...[ + if (_showAdvancedFilters) ...[ const Divider(height: 1), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!hideSearch) + if (!widget.hideSearch) TextField( + controller: _searchController, decoration: InputDecoration( labelText: 'search'.tr(), hintText: 'searchPosts'.tr(), @@ -139,10 +204,13 @@ class PostFilterWidget extends StatelessWidget { ), ), onChanged: (value) { - queryTerm.value = value.isEmpty ? null : value; + setState(() { + _queryTerm = value.isEmpty ? null : value; + }); + _updateQuery(); }, ), - if (!hideSearch) const Gap(12), + if (!widget.hideSearch) const Gap(12), DropdownButtonFormField( decoration: InputDecoration( labelText: 'sortBy'.tr(), @@ -154,7 +222,7 @@ class PostFilterWidget extends StatelessWidget { vertical: 8, ), ), - value: order.value, + value: _order, items: [ DropdownMenuItem(value: 'date', child: Text('date'.tr())), DropdownMenuItem( @@ -163,7 +231,10 @@ class PostFilterWidget extends StatelessWidget { ), ], onChanged: (value) { - order.value = value; + setState(() { + _order = value; + }); + _updateQuery(); }, ), const Gap(12), @@ -174,9 +245,9 @@ class PostFilterWidget extends StatelessWidget { onTap: () async { final pickedDate = await showDatePicker( context: context, - initialDate: periodStart.value != null + initialDate: _periodStart != null ? DateTime.fromMillisecondsSinceEpoch( - periodStart.value! * 1000, + _periodStart! * 1000, ) : DateTime.now(), firstDate: DateTime(2000), @@ -185,8 +256,11 @@ class PostFilterWidget extends StatelessWidget { ), ); if (pickedDate != null) { - periodStart.value = - pickedDate.millisecondsSinceEpoch ~/ 1000; + setState(() { + _periodStart = + pickedDate.millisecondsSinceEpoch ~/ 1000; + }); + _updateQuery(); } }, child: InputDecorator( @@ -204,9 +278,9 @@ class PostFilterWidget extends StatelessWidget { suffixIcon: const Icon(Symbols.calendar_today), ), child: Text( - periodStart.value != null + _periodStart != null ? DateTime.fromMillisecondsSinceEpoch( - periodStart.value! * 1000, + _periodStart! * 1000, ).toString().split(' ')[0] : 'selectDate'.tr(), ), @@ -219,9 +293,9 @@ class PostFilterWidget extends StatelessWidget { onTap: () async { final pickedDate = await showDatePicker( context: context, - initialDate: periodEnd.value != null + initialDate: _periodEnd != null ? DateTime.fromMillisecondsSinceEpoch( - periodEnd.value! * 1000, + _periodEnd! * 1000, ) : DateTime.now(), firstDate: DateTime(2000), @@ -230,8 +304,11 @@ class PostFilterWidget extends StatelessWidget { ), ); if (pickedDate != null) { - periodEnd.value = - pickedDate.millisecondsSinceEpoch ~/ 1000; + setState(() { + _periodEnd = + pickedDate.millisecondsSinceEpoch ~/ 1000; + }); + _updateQuery(); } }, child: InputDecorator( @@ -249,9 +326,9 @@ class PostFilterWidget extends StatelessWidget { suffixIcon: const Icon(Symbols.calendar_today), ), child: Text( - periodEnd.value != null + _periodEnd != null ? DateTime.fromMillisecondsSinceEpoch( - periodEnd.value! * 1000, + _periodEnd! * 1000, ).toString().split(' ')[0] : 'selectDate'.tr(), ),