✨ More search filter
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
Reference in New Issue
Block a user