More search filter

This commit is contained in:
2025-09-02 00:01:29 +08:00
parent bb1846e462
commit 66918521f8
2 changed files with 296 additions and 75 deletions

View File

@@ -961,5 +961,13 @@
"searchAttachments": "Attachments", "searchAttachments": "Attachments",
"noMessagesFound": "No messages found", "noMessagesFound": "No messages found",
"openInBrowser": "Open in Browser", "openInBrowser": "Open in Browser",
"highlightPost": "Highlight Post" "highlightPost": "Highlight Post",
"filters": "Filters",
"apply": "Apply",
"pubName": "Pub Name",
"realm": "Realm",
"shuffle": "Shuffle",
"pinned": "Pinned",
"noResultsFound": "No results found",
"toggleFilters": "Toggle filters"
} }

View File

@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/pods/network.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/post/post_item.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
final postSearchNotifierProvider = StateNotifierProvider.autoDispose< final postSearchNotifierProvider = StateNotifierProvider.autoDispose<
PostSearchNotifier, PostSearchNotifier,
@@ -18,6 +21,13 @@ class PostSearchNotifier
final AutoDisposeRef ref; final AutoDisposeRef ref;
static const int _pageSize = 20; static const int _pageSize = 20;
String _currentQuery = ''; String _currentQuery = '';
String? _pubName;
String? _realm;
int? _type;
List<String>? _categories;
List<String>? _tags;
bool _shuffle = false;
bool? _pinned;
bool _isLoading = false; bool _isLoading = false;
PostSearchNotifier(this.ref) : super(const AsyncValue.loading()) { PostSearchNotifier(this.ref) : super(const AsyncValue.loading()) {
@@ -26,11 +36,38 @@ class PostSearchNotifier
); );
} }
Future<void> search(String query) async { 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; if (_isLoading) return;
_currentQuery = query.trim(); _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( state = AsyncValue.data(
CursorPagingData(items: [], hasMore: false, nextCursor: null), CursorPagingData(items: [], hasMore: false, nextCursor: null),
); );
@@ -57,6 +94,13 @@ class PostSearchNotifier
'offset': offset, 'offset': offset,
'take': _pageSize, 'take': _pageSize,
'vector': false, '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}); const PostSearchScreen({super.key});
@override @override
ConsumerState<PostSearchScreen> createState() => _PostSearchScreenState(); 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);
class _PostSearchScreenState extends ConsumerState<PostSearchScreen> { useEffect(() {
final _searchController = TextEditingController(); return () {
final _debounce = Duration(milliseconds: 500); searchController.dispose();
Timer? _debounceTimer; pubNameController.dispose();
realmController.dispose();
debounceTimer.value?.cancel();
};
}, []);
@override void onSearchChanged(String query) {
void dispose() { if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
_searchController.dispose();
_debounceTimer?.cancel();
super.dispose();
}
void _onSearchChanged(String query) { debounceTimer.value = Timer(debounce, () {
if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); ref.read(postSearchNotifierProvider.notifier).search(query);
});
}
_debounceTimer = Timer(_debounce, () { void onSearchWithFilters(String query) {
ref.read(postSearchNotifierProvider.notifier).search(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( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: TextField( title: Row(
controller: _searchController, children: [
decoration: InputDecoration( Expanded(
hintText: 'Search posts...', child: TextField(
border: InputBorder.none, controller: searchController,
hintStyle: TextStyle( decoration: InputDecoration(
color: Theme.of(context).appBarTheme.foregroundColor, 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(
style: TextStyle( icon: Icon(
color: Theme.of(context).appBarTheme.foregroundColor, showFilters.value
), ? Icons.filter_alt
onChanged: _onSearchChanged, : Icons.filter_alt_outlined,
onSubmitted: (value) { ),
ref.read(postSearchNotifierProvider.notifier).search(value); onPressed: toggleFilters,
}, tooltip: 'toggleFilters'.tr(),
autofocus: true, ),
],
), ),
), ),
body: Consumer( body: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final searchState = ref.watch(postSearchNotifierProvider); final searchState = ref.watch(postSearchNotifierProvider);
return searchState.when( return CustomScrollView(
data: (data) { slivers: [
if (data.items.isEmpty && _searchController.text.isNotEmpty) { if (showFilters.value)
return const Center(child: Text('No results found')); SliverToBoxAdapter(
} child: Center(
child: ConstrainedBox(
return ListView.builder( constraints: const BoxConstraints(maxWidth: 600),
padding: EdgeInsets.zero, child: buildFilterPanel(),
itemCount: data.items.length + (data.hasMore ? 1 : 0), ),
itemBuilder: (context, index) { ),
if (index >= data.items.length) { ),
ref searchState.when(
.read(postSearchNotifierProvider.notifier) data: (data) {
.fetch(cursor: data.nextCursor); if (data.items.isEmpty && searchController.text.isNotEmpty) {
return const Center(child: CircularProgressIndicator()); return SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())),
);
} }
final post = data.items[index]; return SliverList(
return Center( delegate: SliverChildBuilderDelegate((context, index) {
child: ConstrainedBox( if (index >= data.items.length) {
constraints: BoxConstraints(maxWidth: 600), ref
child: Card( .read(postSearchNotifierProvider.notifier)
margin: EdgeInsets.symmetric( .fetch(cursor: data.nextCursor);
horizontal: 8, return Center(child: CircularProgressIndicator());
vertical: 4, }
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:
}, () => SliverFillRemaining(
loading: () => const Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
error: ),
(error, stack) => ResponseErrorWidget( error:
error: error, (error, stack) => SliverFillRemaining(
onRetry: () => ref.invalidate(postSearchNotifierProvider), child: ResponseErrorWidget(
), error: error,
onRetry:
() => ref.invalidate(postSearchNotifierProvider),
),
),
),
],
); );
}, },
), ),