Initial Flutter booru app

This commit is contained in:
2026-05-23 11:46:52 +03:00
commit 63da741295
187 changed files with 24113 additions and 0 deletions
@@ -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),
),
],
);
}
}