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",
"noMessagesFound": "No messages found",
"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 '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<String>? _categories;
List<String>? _tags;
bool _shuffle = false;
bool? _pinned;
bool _isLoading = false;
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;
_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<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> {
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),
),
),
),
],
);
},
),