396 lines
12 KiB
Dart
396 lines
12 KiB
Dart
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<CursorPagingData<SnPost>>
|
|
>((ref) => PostSearchNotifier(ref));
|
|
|
|
class PostSearchNotifier
|
|
extends StateNotifier<AsyncValue<CursorPagingData<SnPost>>> {
|
|
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()) {
|
|
state = const AsyncValue.data(
|
|
CursorPagingData(items: [], hasMore: false, nextCursor: null),
|
|
);
|
|
}
|
|
|
|
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();
|
|
_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<void> 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<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);
|
|
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|