167 lines
4.9 KiB
Dart
167 lines
4.9 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.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:riverpod_paging_utils/riverpod_paging_utils.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 = '';
|
|
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) async {
|
|
if (_isLoading) return;
|
|
|
|
_currentQuery = query.trim();
|
|
if (_currentQuery.isEmpty) {
|
|
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(
|
|
'/posts/search',
|
|
queryParameters: {
|
|
'query': _currentQuery,
|
|
'offset': offset,
|
|
'take': _pageSize,
|
|
'useVector': true,
|
|
},
|
|
);
|
|
|
|
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 ConsumerStatefulWidget {
|
|
const PostSearchScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<PostSearchScreen> createState() => _PostSearchScreenState();
|
|
}
|
|
|
|
class _PostSearchScreenState extends ConsumerState<PostSearchScreen> {
|
|
final _searchController = TextEditingController();
|
|
final _debounce = Duration(milliseconds: 500);
|
|
Timer? _debounceTimer;
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_debounceTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onSearchChanged(String query) {
|
|
if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel();
|
|
|
|
_debounceTimer = Timer(_debounce, () {
|
|
ref.read(postSearchNotifierProvider.notifier).search(query);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AppScaffold(
|
|
appBar: AppBar(
|
|
title: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search posts...',
|
|
border: InputBorder.none,
|
|
hintStyle: TextStyle(
|
|
color: Theme.of(context).appBarTheme.foregroundColor,
|
|
),
|
|
),
|
|
style: TextStyle(
|
|
color: Theme.of(context).appBarTheme.foregroundColor,
|
|
),
|
|
onChanged: _onSearchChanged,
|
|
onSubmitted: (value) {
|
|
ref.read(postSearchNotifierProvider.notifier).search(value);
|
|
},
|
|
autofocus: true,
|
|
),
|
|
),
|
|
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(
|
|
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());
|
|
}
|
|
|
|
final post = data.items[index];
|
|
return Column(
|
|
children: [PostItem(item: post), const Divider(height: 1)],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, stack) => Center(child: Text('Error: $error')),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|