Initial Flutter booru app
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import 'search_state.dart';
|
||||
|
||||
final searchControllerProvider =
|
||||
AsyncNotifierProvider<SearchController, SearchState>(SearchController.new);
|
||||
|
||||
class SearchController extends AsyncNotifier<SearchState> {
|
||||
@override
|
||||
Future<SearchState> build() async {
|
||||
final recent = await _recent();
|
||||
return SearchState(recent: recent);
|
||||
}
|
||||
|
||||
Future<void> updateQuery(String query) async {
|
||||
final lastToken =
|
||||
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
||||
final suggestionsResult =
|
||||
await ref.read(searchServiceProvider).autocomplete(lastToken);
|
||||
final suggestions = suggestionsResult is Success<List<String>>
|
||||
? suggestionsResult.data
|
||||
: <String>[];
|
||||
state = AsyncData(
|
||||
(state.value ?? const SearchState()).copyWith(
|
||||
query: query,
|
||||
suggestions: suggestions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearHistory() async {
|
||||
await ref.read(searchServiceProvider).clearHistory();
|
||||
state = const AsyncData(SearchState());
|
||||
}
|
||||
|
||||
Future<List<SearchHistory>> _recent() async {
|
||||
final result = await ref.read(searchServiceProvider).recentSearches();
|
||||
return result is Success<List<SearchHistory>> ? result.data : const [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/app_search_bar.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'search_controller.dart';
|
||||
import 'widgets/recent_searches.dart';
|
||||
import 'widgets/search_suggestions.dart';
|
||||
|
||||
class SearchScreen extends ConsumerWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(searchControllerProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Search',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear history',
|
||||
onPressed: () =>
|
||||
ref.read(searchControllerProvider.notifier).clearHistory(),
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
),
|
||||
],
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (data) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
AppSearchBar(
|
||||
onSubmitted: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onChanged: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SearchSuggestions(
|
||||
suggestions: data.suggestions,
|
||||
onTap: (suggestion) {
|
||||
final query = _applySuggestion(data.query, suggestion);
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Recent', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
RecentSearches(
|
||||
items: data.recent,
|
||||
onTap: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _applySuggestion(String query, String suggestion) {
|
||||
final tokens =
|
||||
query.trim().isEmpty ? <String>[] : query.trim().split(RegExp(r'\s+'));
|
||||
if (tokens.isEmpty) {
|
||||
tokens.add(suggestion);
|
||||
} else {
|
||||
tokens[tokens.length - 1] = suggestion;
|
||||
}
|
||||
return tokens.toSet().join(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import '../../../backend/backend.dart';
|
||||
|
||||
class SearchState {
|
||||
const SearchState({
|
||||
this.recent = const [],
|
||||
this.suggestions = const [],
|
||||
this.query = '',
|
||||
});
|
||||
|
||||
final List<SearchHistory> recent;
|
||||
final List<String> suggestions;
|
||||
final String query;
|
||||
|
||||
SearchState copyWith({
|
||||
List<SearchHistory>? recent,
|
||||
List<String>? suggestions,
|
||||
String? query,
|
||||
}) {
|
||||
return SearchState(
|
||||
recent: recent ?? this.recent,
|
||||
suggestions: suggestions ?? this.suggestions,
|
||||
query: query ?? this.query,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class RecentSearches extends StatelessWidget {
|
||||
const RecentSearches({required this.items, required this.onTap, super.key});
|
||||
|
||||
final List<SearchHistory> items;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const Text('No recent searches');
|
||||
return Column(
|
||||
children: [
|
||||
for (final item in items)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history_rounded),
|
||||
title: Text(item.query),
|
||||
subtitle:
|
||||
Text('${item.tags.join(' ')} · ${item.resultCount} results'),
|
||||
onTap: () => onTap(item.query),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SearchSuggestions extends StatelessWidget {
|
||||
const SearchSuggestions(
|
||||
{required this.suggestions, required this.onTap, super.key});
|
||||
|
||||
final List<String> suggestions;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (suggestions.isEmpty) return const SizedBox.shrink();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final suggestion in suggestions)
|
||||
ActionChip(
|
||||
label: Text(suggestion),
|
||||
onPressed: () => onTap(suggestion),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user