394 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			394 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| 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';
 | |
| 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,
 | |
|   AsyncValue<CursorPagingData<SnPost>>
 | |
| >((ref) => PostSearchNotifier(ref));
 | |
| 
 | |
| class PostSearchNotifier
 | |
|     extends StateNotifier<AsyncValue<CursorPagingData<SnPost>>> {
 | |
|   final AutoDisposeRef ref;
 | |
|   static const int _pageSize = 20;
 | |
|   String _currentQuery = '';
 | |
|   String? _pubName;
 | |
|   String? _realm;
 | |
|   int? _type;
 | |
|   List<String>? _categories;
 | |
|   List<String>? _tags;
 | |
|   bool _shuffle = false;
 | |
|   bool? _pinned;
 | |
|   bool _isLoading = false;
 | |
| 
 | |
|   PostSearchNotifier(this.ref) : super(const AsyncValue.loading()) {
 | |
|     state = const AsyncValue.data(
 | |
|       CursorPagingData(items: [], hasMore: false, nextCursor: null),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> search(
 | |
|     String query, {
 | |
|     String? pubName,
 | |
|     String? realm,
 | |
|     int? type,
 | |
|     List<String>? categories,
 | |
|     List<String>? tags,
 | |
|     bool shuffle = false,
 | |
|     bool? pinned,
 | |
|   }) async {
 | |
|     if (_isLoading) return;
 | |
| 
 | |
|     _currentQuery = query.trim();
 | |
|     _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),
 | |
|       );
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     await fetch(cursor: null);
 | |
|   }
 | |
| 
 | |
|   Future<void> fetch({String? cursor}) async {
 | |
|     if (_isLoading) return;
 | |
| 
 | |
|     _isLoading = true;
 | |
|     state = const AsyncValue.loading();
 | |
| 
 | |
|     try {
 | |
|       final client = ref.read(apiClientProvider);
 | |
|       final offset = cursor == null ? 0 : int.parse(cursor);
 | |
| 
 | |
|       final response = await client.get(
 | |
|         '/sphere/posts',
 | |
|         queryParameters: {
 | |
|           'query': _currentQuery,
 | |
|           '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,
 | |
|         },
 | |
|       );
 | |
| 
 | |
|       final data = response.data as List;
 | |
|       final posts = data.map((json) => SnPost.fromJson(json)).toList();
 | |
|       final hasMore = posts.length == _pageSize;
 | |
|       final nextCursor = hasMore ? (offset + posts.length).toString() : null;
 | |
| 
 | |
|       state = AsyncValue.data(
 | |
|         CursorPagingData(
 | |
|           items: posts,
 | |
|           hasMore: hasMore,
 | |
|           nextCursor: nextCursor,
 | |
|         ),
 | |
|       );
 | |
|     } catch (e, stack) {
 | |
|       state = AsyncValue.error(e, stack);
 | |
|     } finally {
 | |
|       _isLoading = false;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| class PostSearchScreen extends HookConsumerWidget {
 | |
|   const PostSearchScreen({super.key});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final searchController = useTextEditingController();
 | |
|     final debounce = useMemoized(() => Duration(milliseconds: 500));
 | |
|     final debounceTimer = useRef<Timer?>(null);
 | |
|     final showFilters = useState(false);
 | |
|     final pubNameController = useTextEditingController();
 | |
|     final realmController = useTextEditingController();
 | |
|     final typeValue = useState<int?>(null);
 | |
|     final selectedCategories = useState<List<String>>([]);
 | |
|     final selectedTags = useState<List<String>>([]);
 | |
|     final shuffleValue = useState(false);
 | |
|     final pinnedValue = useState<bool?>(null);
 | |
| 
 | |
|     useEffect(() {
 | |
|       return () {
 | |
|         searchController.dispose();
 | |
|         pubNameController.dispose();
 | |
|         realmController.dispose();
 | |
|         debounceTimer.value?.cancel();
 | |
|       };
 | |
|     }, []);
 | |
| 
 | |
|     void onSearchChanged(String query) {
 | |
|       if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
 | |
| 
 | |
|       debounceTimer.value = 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()),
 | |
|                 ],
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return AppScaffold(
 | |
|       isNoBackground: false,
 | |
|       appBar: AppBar(
 | |
|         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,
 | |
|               ),
 | |
|             ),
 | |
|             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 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())),
 | |
|                     );
 | |
|                   }
 | |
| 
 | |
|                   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,
 | |
|                             ),
 | |
|                           ),
 | |
|                         ),
 | |
|                       );
 | |
|                     }, childCount: data.items.length + (data.hasMore ? 1 : 0)),
 | |
|                   );
 | |
|                 },
 | |
|                 loading:
 | |
|                     () => SliverFillRemaining(
 | |
|                       child: Center(child: CircularProgressIndicator()),
 | |
|                     ),
 | |
|                 error:
 | |
|                     (error, stack) => SliverFillRemaining(
 | |
|                       child: ResponseErrorWidget(
 | |
|                         error: error,
 | |
|                         onRetry:
 | |
|                             () => ref.invalidate(postSearchNotifierProvider),
 | |
|                       ),
 | |
|                     ),
 | |
|               ),
 | |
|             ],
 | |
|           );
 | |
|         },
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |