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