From 66918521f8026b6898b43754c8ac13c6a0f12266 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 2 Sep 2025 00:01:29 +0800 Subject: [PATCH] :sparkles: More search filter --- assets/i18n/en-US.json | 12 +- lib/screens/posts/post_search.dart | 359 +++++++++++++++++++++++------ 2 files changed, 296 insertions(+), 75 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 650b12db..b7613ee2 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -961,5 +961,13 @@ "searchAttachments": "Attachments", "noMessagesFound": "No messages found", "openInBrowser": "Open in Browser", - "highlightPost": "Highlight Post" -} \ No newline at end of file + "highlightPost": "Highlight Post", + "filters": "Filters", + "apply": "Apply", + "pubName": "Pub Name", + "realm": "Realm", + "shuffle": "Shuffle", + "pinned": "Pinned", + "noResultsFound": "No results found", + "toggleFilters": "Toggle filters" +} diff --git a/lib/screens/posts/post_search.dart b/lib/screens/posts/post_search.dart index 801fcb94..4796324e 100644 --- a/lib/screens/posts/post_search.dart +++ b/lib/screens/posts/post_search.dart @@ -1,5 +1,7 @@ import 'dart:async'; +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'; @@ -7,6 +9,7 @@ import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/response.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; +import 'package:styled_widget/styled_widget.dart'; final postSearchNotifierProvider = StateNotifierProvider.autoDispose< PostSearchNotifier, @@ -18,6 +21,13 @@ class PostSearchNotifier final AutoDisposeRef ref; static const int _pageSize = 20; String _currentQuery = ''; + String? _pubName; + String? _realm; + int? _type; + List? _categories; + List? _tags; + bool _shuffle = false; + bool? _pinned; bool _isLoading = false; PostSearchNotifier(this.ref) : super(const AsyncValue.loading()) { @@ -26,11 +36,38 @@ class PostSearchNotifier ); } - Future search(String query) async { + Future search( + String query, { + String? pubName, + String? realm, + int? type, + List? categories, + List? tags, + bool shuffle = false, + bool? pinned, + }) async { if (_isLoading) return; _currentQuery = query.trim(); - if (_currentQuery.isEmpty) { + _pubName = pubName; + _realm = realm; + _type = type; + _categories = categories; + _tags = tags; + _shuffle = shuffle; + _pinned = pinned; + + // Allow search even with empty query if any filters are applied + final hasFilters = + pubName != null || + realm != null || + type != null || + categories != null || + tags != null || + shuffle || + pinned != null; + + if (_currentQuery.isEmpty && !hasFilters) { state = AsyncValue.data( CursorPagingData(items: [], hasMore: false, nextCursor: null), ); @@ -57,6 +94,13 @@ class PostSearchNotifier 'offset': offset, '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, }, ); @@ -80,100 +124,269 @@ class PostSearchNotifier } } -class PostSearchScreen extends ConsumerStatefulWidget { +class PostSearchScreen extends HookConsumerWidget { const PostSearchScreen({super.key}); @override - ConsumerState createState() => _PostSearchScreenState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final searchController = useTextEditingController(); + final debounce = useMemoized(() => Duration(milliseconds: 500)); + final debounceTimer = useRef(null); + 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); -class _PostSearchScreenState extends ConsumerState { - final _searchController = TextEditingController(); - final _debounce = Duration(milliseconds: 500); - Timer? _debounceTimer; + useEffect(() { + return () { + searchController.dispose(); + pubNameController.dispose(); + realmController.dispose(); + debounceTimer.value?.cancel(); + }; + }, []); - @override - void dispose() { - _searchController.dispose(); - _debounceTimer?.cancel(); - super.dispose(); - } + void onSearchChanged(String query) { + if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel(); - void _onSearchChanged(String query) { - if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + debounceTimer.value = Timer(debounce, () { + ref.read(postSearchNotifierProvider.notifier).search(query); + }); + } - _debounceTimer = Timer(_debounce, () { - ref.read(postSearchNotifierProvider.notifier).search(query); - }); - } + void onSearchWithFilters(String query) { + if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel(); + + debounceTimer.value = Timer(debounce, () { + ref + .read(postSearchNotifierProvider.notifier) + .search( + query, + pubName: + pubNameController.text.isNotEmpty + ? pubNameController.text + : null, + realm: + realmController.text.isNotEmpty ? realmController.text : null, + type: typeValue.value, + categories: + selectedCategories.value.isNotEmpty + ? selectedCategories.value + : null, + tags: selectedTags.value.isNotEmpty ? selectedTags.value : null, + shuffle: shuffleValue.value, + pinned: pinnedValue.value, + ); + }); + } + + void toggleFilters() { + showFilters.value = !showFilters.value; + } + + void applyFilters() { + onSearchWithFilters(searchController.text); + } + + void clearFilters() { + pubNameController.clear(); + realmController.clear(); + typeValue.value = null; + selectedCategories.value = []; + selectedTags.value = []; + shuffleValue.value = false; + pinnedValue.value = null; + onSearchChanged(searchController.text); + } + + Widget buildFilterPanel() { + return Card( + margin: EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'filters'.tr(), + style: Theme.of(context).textTheme.titleMedium, + ).padding(left: 4), + Row( + children: [ + TextButton( + onPressed: applyFilters, + child: Text('apply'.tr()), + ), + TextButton( + onPressed: clearFilters, + child: Text('clear'.tr()), + ), + ], + ), + ], + ), + SizedBox(height: 16), + TextField( + controller: pubNameController, + decoration: InputDecoration( + labelText: 'pubName'.tr(), + border: OutlineInputBorder(), + ), + onChanged: + (value) => onSearchWithFilters(searchController.text), + ), + SizedBox(height: 8), + TextField( + controller: realmController, + decoration: InputDecoration( + labelText: 'realm'.tr(), + border: OutlineInputBorder(), + ), + onChanged: + (value) => onSearchWithFilters(searchController.text), + ), + SizedBox(height: 8), + Row( + children: [ + Checkbox( + value: shuffleValue.value, + onChanged: (value) { + shuffleValue.value = value ?? false; + onSearchWithFilters(searchController.text); + }, + ), + Text('shuffle'.tr()), + ], + ), + Row( + children: [ + Checkbox( + value: pinnedValue.value ?? false, + onChanged: (value) { + pinnedValue.value = value; + onSearchWithFilters(searchController.text); + }, + ), + Text('pinned'.tr()), + ], + ), + // TODO: Add dropdown for type selection + // TODO: Add multi-select for categories and tags + ], + ), + ), + ); + } - @override - Widget build(BuildContext context) { return AppScaffold( isNoBackground: false, appBar: AppBar( - title: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search posts...', - border: InputBorder.none, - hintStyle: TextStyle( - color: Theme.of(context).appBarTheme.foregroundColor, + title: Row( + children: [ + Expanded( + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'search'.tr(), + border: InputBorder.none, + hintStyle: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + ), + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor, + ), + onChanged: onSearchChanged, + onSubmitted: (value) { + onSearchWithFilters(value); + }, + autofocus: true, + ), ), - ), - style: TextStyle( - color: Theme.of(context).appBarTheme.foregroundColor, - ), - onChanged: _onSearchChanged, - onSubmitted: (value) { - ref.read(postSearchNotifierProvider.notifier).search(value); - }, - autofocus: true, + IconButton( + icon: Icon( + showFilters.value + ? Icons.filter_alt + : Icons.filter_alt_outlined, + ), + onPressed: toggleFilters, + tooltip: 'toggleFilters'.tr(), + ), + ], ), ), body: Consumer( builder: (context, ref, child) { final searchState = ref.watch(postSearchNotifierProvider); - return searchState.when( - data: (data) { - if (data.items.isEmpty && _searchController.text.isNotEmpty) { - return const Center(child: Text('No results found')); - } - - return ListView.builder( - padding: EdgeInsets.zero, - itemCount: data.items.length + (data.hasMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= data.items.length) { - ref - .read(postSearchNotifierProvider.notifier) - .fetch(cursor: data.nextCursor); - return const Center(child: CircularProgressIndicator()); + return CustomScrollView( + slivers: [ + if (showFilters.value) + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: buildFilterPanel(), + ), + ), + ), + searchState.when( + data: (data) { + if (data.items.isEmpty && searchController.text.isNotEmpty) { + return SliverFillRemaining( + child: Center(child: Text('noResultsFound'.tr())), + ); } - final post = data.items[index]; - return Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 600), - child: Card( - margin: EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index >= data.items.length) { + ref + .read(postSearchNotifierProvider.notifier) + .fetch(cursor: data.nextCursor); + return Center(child: CircularProgressIndicator()); + } + + final post = data.items[index]; + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: Card( + margin: EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: PostActionableItem( + item: post, + borderRadius: 8, + ), + ), ), - child: PostActionableItem(item: post, borderRadius: 8), - ), - ), + ); + }, childCount: data.items.length + (data.hasMore ? 1 : 0)), ); }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, stack) => ResponseErrorWidget( - error: error, - onRetry: () => ref.invalidate(postSearchNotifierProvider), - ), + loading: + () => SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), + error: + (error, stack) => SliverFillRemaining( + child: ResponseErrorWidget( + error: error, + onRetry: + () => ref.invalidate(postSearchNotifierProvider), + ), + ), + ), + ], ); }, ),