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> >((ref) => PostSearchNotifier(ref)); class PostSearchNotifier extends StateNotifier>> { 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()) { state = const AsyncValue.data( CursorPagingData(items: [], hasMore: false, nextCursor: null), ); } 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(); _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 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(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); 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()), ], ), // TODO: Add dropdown for type selection // TODO: Add multi-select for categories and tags ], ), ), ); } 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), ), ), ), ], ); }, ), ); } }