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,170 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import 'feed_state.dart';
final feedControllerProvider =
AsyncNotifierProvider<FeedController, FeedState>(FeedController.new);
class FeedController extends AsyncNotifier<FeedState> {
@override
Future<FeedState> build() async {
final providers = await _loadProviders();
final settings = await _settings();
final providerIds = providers.map((provider) => provider.id).toSet();
final initial = FeedState(
providers: providers,
ratingFilter: settings.defaultRatingFilter,
topPeriodFilter: TopPeriodFilter.values.firstWhere(
(value) => value.name == settings.defaultTopPeriodFilter,
orElse: () => TopPeriodFilter.none,
),
selectedProviderIds:
settings.selectedFeedProviderIds.where(providerIds.contains).toList(),
);
state = AsyncData(initial);
await loadInitial();
return state.value ?? initial;
}
Future<void> loadInitial() async {
final current = state.value ?? const FeedState();
state = const AsyncLoading();
final posts = await _load(refresh: true, current: current);
state = AsyncData(current.copyWith(posts: posts, clearError: true));
}
Future<void> refresh() async {
final current = state.value ?? const FeedState();
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
final posts = await _load(refresh: true, current: current);
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
}
Future<void> loadNextPage() async {
final current = state.value;
if (current == null || current.isLoadingMore || !current.hasMore) return;
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
final posts = await _load(refresh: false, current: current);
state = AsyncData(
current.copyWith(
posts: [...current.posts, ...posts],
isLoadingMore: false,
hasMore: posts.isNotEmpty,
),
);
}
Future<void> search(String query) async {
final tags = ref.read(searchServiceProvider).parseTags(query);
final current = state.value ?? const FeedState();
state = AsyncData(current.copyWith(selectedTags: tags, posts: []));
await ref.read(searchServiceProvider).saveSearch(query, 0);
await refresh();
final count = state.value?.posts.length ?? 0;
await ref.read(searchServiceProvider).saveSearch(query, count);
}
Future<void> updateTagSuggestions(String query) async {
final current = state.value ?? const FeedState();
if (query.trim().isEmpty) {
state = AsyncData(current.copyWith(tagSuggestions: []));
return;
}
final lastToken = query.trim().split(RegExp(r'\s+')).last;
final result =
await ref.read(searchServiceProvider).autocomplete(lastToken);
final suggestions =
result is Success<List<String>> ? result.data : const <String>[];
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
}
Future<void> setProviders(List<String> providerIds) async {
final current = state.value ?? const FeedState();
state = AsyncData(
current.copyWith(selectedProviderIds: providerIds, posts: []));
final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(selectedFeedProviderIds: providerIds));
await refresh();
}
Future<void> setTopPeriod(TopPeriodFilter period) async {
final current = state.value ?? const FeedState();
state = AsyncData(current.copyWith(topPeriodFilter: period, posts: []));
final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(defaultTopPeriodFilter: period.name),
);
await refresh();
}
Future<void> setRating(String? rating) async {
final current = state.value ?? const FeedState();
state = AsyncData(
current.copyWith(
ratingFilter: rating, clearRating: rating == null, posts: []),
);
await refresh();
}
Future<void> clearFilters() async {
final current = state.value ?? const FeedState();
state = AsyncData(
current.copyWith(
selectedTags: [],
selectedProviderIds: [],
tagSuggestions: [],
clearRating: true,
posts: [],
),
);
final settings = await _settings();
await ref
.read(settingsServiceProvider)
.updateSettings(settings.copyWith(selectedFeedProviderIds: []));
await refresh();
}
Future<List<Post>> _load({
required bool refresh,
required FeedState current,
}) async {
final service = ref.read(feedServiceProvider);
final providerIds = current.selectedProviderIds.isEmpty
? <String?>[null]
: current.selectedProviderIds;
final posts = <Post>[];
for (final providerId in providerIds) {
final result = refresh
? await service.refresh(
tags: current.selectedTags,
rating: current.ratingFilter,
providerId: providerId,
topPeriod: current.topPeriodFilter,
)
: await service.loadNextPage(
tags: current.selectedTags,
rating: current.ratingFilter,
providerId: providerId,
topPeriod: current.topPeriodFilter,
);
if (result is Success<List<Post>>) posts.addAll(result.data);
}
return posts;
}
Future<List<ContentProviderConfig>> _loadProviders() async {
final result =
await ref.read(providerManagerProvider).loadConfigs(enabledOnly: true);
return result is Success<List<ContentProviderConfig>>
? result.data
: const [];
}
Future<AppSettings> _settings() async {
final result = await ref.read(settingsServiceProvider).getSettings();
return result is Success<AppSettings> ? result.data : AppSettings.defaults;
}
}
@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/app.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_card.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import '../../collections/presentation/collection_form_dialog.dart';
import 'feed_controller.dart';
import 'widgets/feed_toolbar.dart';
class FeedScreen extends ConsumerStatefulWidget {
const FeedScreen({this.initialQuery, super.key});
final String? initialQuery;
@override
ConsumerState<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends ConsumerState<FeedScreen> {
final _scrollController = ScrollController();
bool _usedInitialQuery = false;
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.extentAfter < 800) {
ref.read(feedControllerProvider.notifier).loadNextPage();
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final feed = ref.watch(feedControllerProvider);
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) {
_usedInitialQuery = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(feedControllerProvider.notifier).search(widget.initialQuery!);
});
}
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
},
child: Actions(
actions: {
_RefreshIntent: CallbackAction<_RefreshIntent>(
onInvoke: (_) {
ref.read(feedControllerProvider.notifier).refresh();
return null;
},
),
},
child: AdaptiveScaffold(
title: 'Feed',
body: feed.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(
message: error.toString(),
onRetry: () =>
ref.read(feedControllerProvider.notifier).loadInitial(),
),
data: (state) => Column(
children: [
FeedToolbar(
selectedTags: state.selectedTags,
selectedProviderIds: state.selectedProviderIds,
topPeriodFilter: state.topPeriodFilter,
tagSuggestions: state.tagSuggestions,
providers: state.providers,
rating: state.ratingFilter,
onSearchChanged: (query) => ref
.read(feedControllerProvider.notifier)
.updateTagSuggestions(query),
onSuggestionTap: (query) =>
ref.read(feedControllerProvider.notifier).search(query),
onTopPeriodChanged: (period) => ref
.read(feedControllerProvider.notifier)
.setTopPeriod(period),
onSearch: (query) =>
ref.read(feedControllerProvider.notifier).search(query),
onRefresh: () =>
ref.read(feedControllerProvider.notifier).refresh(),
onClearFilters: () =>
ref.read(feedControllerProvider.notifier).clearFilters(),
onQuickProviderToggle: (providerId) {
if (providerId == '__all__') {
ref
.read(feedControllerProvider.notifier)
.setProviders([]);
return;
}
final selected =
state.selectedProviderIds.contains(providerId)
? state.selectedProviderIds
.where((id) => id != providerId)
.toList()
: [...state.selectedProviderIds, providerId];
ref
.read(feedControllerProvider.notifier)
.setProviders(selected);
},
onProviderFilter: () async {
final selected = await showProviderFilterSheet(
context,
providers: state.providers,
selectedIds: state.selectedProviderIds,
);
if (selected != null) {
await ref
.read(feedControllerProvider.notifier)
.setProviders(selected);
}
},
onRatingFilter: () async {
final rating = await showRatingFilterSheet(
context, state.ratingFilter);
await ref
.read(feedControllerProvider.notifier)
.setRating(rating);
},
),
Expanded(
child: state.posts.isEmpty && !state.isLoadingMore
? const EmptyView(
title: 'No posts yet',
message: 'Try another tag or check providers.',
)
: RefreshIndicator(
onRefresh: () => ref
.read(feedControllerProvider.notifier)
.refresh(),
child: PostMasonryGrid(
controller: _scrollController,
posts: state.posts,
columns: Responsive.columnsFor(
context,
mobileColumns: settings.mobileColumns,
desktopColumns: settings.desktopColumns,
),
blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled,
loading: state.isLoadingMore,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
),
onFavorite: (post) => ref
.read(favoriteServiceProvider)
.addFavorite(post),
onAddToCollection: (post) =>
_addToCollection(context, ref, post),
),
),
),
],
),
),
),
),
);
}
Future<void> _addToCollection(
BuildContext context,
WidgetRef ref,
Post post,
) async {
final result = await ref.read(collectionServiceProvider).getCollections();
final collections =
result is Success<List<Collection>> ? result.data : <Collection>[];
if (!context.mounted) return;
await showAddToCollectionPicker(
context,
collections: collections,
onSelected: (collection) {
ref
.read(collectionServiceProvider)
.addPostToCollection(collection.id, post);
},
onCreate: () => showCollectionFormDialog(context, ref),
);
}
}
class _RefreshIntent extends Intent {
const _RefreshIntent();
}
@@ -0,0 +1,55 @@
import '../../../backend/backend.dart';
class FeedState {
const FeedState({
this.posts = const [],
this.selectedTags = const [],
this.selectedProviderIds = const [],
this.ratingFilter,
this.topPeriodFilter = TopPeriodFilter.none,
this.tagSuggestions = const [],
this.providers = const [],
this.isLoadingMore = false,
this.hasMore = true,
this.error,
});
final List<Post> posts;
final List<String> selectedTags;
final List<String> selectedProviderIds;
final String? ratingFilter;
final TopPeriodFilter topPeriodFilter;
final List<String> tagSuggestions;
final List<ContentProviderConfig> providers;
final bool isLoadingMore;
final bool hasMore;
final String? error;
FeedState copyWith({
List<Post>? posts,
List<String>? selectedTags,
List<String>? selectedProviderIds,
String? ratingFilter,
TopPeriodFilter? topPeriodFilter,
List<String>? tagSuggestions,
bool clearRating = false,
List<ContentProviderConfig>? providers,
bool? isLoadingMore,
bool? hasMore,
String? error,
bool clearError = false,
}) {
return FeedState(
posts: posts ?? this.posts,
selectedTags: selectedTags ?? this.selectedTags,
selectedProviderIds: selectedProviderIds ?? this.selectedProviderIds,
ratingFilter: clearRating ? null : ratingFilter ?? this.ratingFilter,
topPeriodFilter: topPeriodFilter ?? this.topPeriodFilter,
tagSuggestions: tagSuggestions ?? this.tagSuggestions,
providers: providers ?? this.providers,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore,
error: clearError ? null : error ?? this.error,
);
}
}
@@ -0,0 +1,356 @@
import 'package:flutter/material.dart';
import '../../../../app/responsive.dart';
import '../../../../backend/backend.dart';
import '../../../../shared/widgets/app_search_bar.dart';
class FeedToolbar extends StatefulWidget {
const FeedToolbar({
required this.onSearch,
required this.onRefresh,
required this.onProviderFilter,
required this.onRatingFilter,
required this.onClearFilters,
required this.providers,
required this.selectedTags,
required this.selectedProviderIds,
required this.topPeriodFilter,
required this.tagSuggestions,
required this.rating,
required this.onQuickProviderToggle,
required this.onSearchChanged,
required this.onSuggestionTap,
required this.onTopPeriodChanged,
super.key,
});
final ValueChanged<String> onSearch;
final VoidCallback onRefresh;
final VoidCallback onProviderFilter;
final VoidCallback onRatingFilter;
final VoidCallback onClearFilters;
final List<ContentProviderConfig> providers;
final List<String> selectedTags;
final List<String> selectedProviderIds;
final TopPeriodFilter topPeriodFilter;
final List<String> tagSuggestions;
final String? rating;
final ValueChanged<String> onQuickProviderToggle;
final ValueChanged<String> onSearchChanged;
final ValueChanged<String> onSuggestionTap;
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
@override
State<FeedToolbar> createState() => _FeedToolbarState();
}
class _FeedToolbarState extends State<FeedToolbar> {
late String _query;
@override
void initState() {
super.initState();
_query = widget.selectedTags.join(' ');
}
@override
void didUpdateWidget(covariant FeedToolbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedTags != widget.selectedTags) {
_query = widget.selectedTags.join(' ');
}
}
@override
Widget build(BuildContext context) {
if (Responsive.isMobile(context)) return _buildMobile(context);
return _buildDesktop(context);
}
Widget _buildDesktop(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
children: [
Row(
children: [
Expanded(
child: AppSearchBar(
initialValue: _query,
onChanged: (value) {
_query = value;
widget.onSearchChanged(value);
},
onSubmitted: widget.onSearch,
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Providers',
onPressed: widget.onProviderFilter,
icon: const Icon(Icons.hub_rounded),
),
IconButton.filledTonal(
tooltip: 'Rating',
onPressed: widget.onRatingFilter,
icon: const Icon(Icons.tune_rounded),
),
IconButton(
tooltip: 'Clear filters',
onPressed: widget.onClearFilters,
icon: const Icon(Icons.filter_alt_off_rounded),
),
IconButton(
tooltip: 'Refresh',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded),
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
if (widget.providers.isNotEmpty) ...[
FilterChip(
selected: widget.selectedProviderIds.isEmpty,
label: const Text('All providers'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 8),
for (final provider in widget.providers) ...[
FilterChip(
selected:
widget.selectedProviderIds.contains(provider.id),
avatar: const Icon(Icons.hub_rounded, size: 16),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 8),
],
const SizedBox(width: 8),
],
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 8),
],
],
),
),
if (widget.tagSuggestions.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 34,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: widget.tagSuggestions.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) => ActionChip(
avatar: const Icon(Icons.tag_rounded, size: 15),
label: Text(widget.tagSuggestions[index]),
onPressed: () =>
_applySuggestion(widget.tagSuggestions[index]),
),
),
),
],
],
),
);
}
Widget _buildMobile(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Column(
children: [
AppSearchBar(
initialValue: _query,
onChanged: (value) {
_query = value;
widget.onSearchChanged(value);
},
onSubmitted: widget.onSearch,
),
const SizedBox(height: 8),
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
IconButton.filledTonal(
tooltip: 'Providers',
onPressed: widget.onProviderFilter,
icon: const Icon(Icons.hub_rounded),
),
const SizedBox(width: 6),
IconButton.filledTonal(
tooltip: 'Rating',
onPressed: widget.onRatingFilter,
icon: const Icon(Icons.tune_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: 'Clear filters',
onPressed: widget.onClearFilters,
icon: const Icon(Icons.filter_alt_off_rounded),
),
IconButton(
tooltip: 'Refresh',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 8),
if (widget.providers.isNotEmpty) ...[
FilterChip(
selected: widget.selectedProviderIds.isEmpty,
label: const Text('All'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
selected:
widget.selectedProviderIds.contains(provider.id),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
),
const SizedBox(width: 6),
],
],
],
),
),
const SizedBox(height: 6),
SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
),
const SizedBox(width: 6),
],
],
),
),
if (widget.tagSuggestions.isNotEmpty) ...[
const SizedBox(height: 6),
SizedBox(
height: 34,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: widget.tagSuggestions.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (context, index) => ActionChip(
label: Text(widget.tagSuggestions[index]),
onPressed: () =>
_applySuggestion(widget.tagSuggestions[index]),
),
),
),
],
],
),
);
}
void _applySuggestion(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;
}
final next = tokens.toSet().join(' ');
setState(() => _query = next);
widget.onSuggestionTap(next);
}
}
Future<List<String>?> showProviderFilterSheet(
BuildContext context, {
required List<ContentProviderConfig> providers,
required List<String> selectedIds,
}) {
final selected = selectedIds.toSet();
return showModalBottomSheet<List<String>>(
context: context,
showDragHandle: true,
builder: (context) => StatefulBuilder(
builder: (context, setState) => ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Providers', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
for (final provider in providers)
CheckboxListTile(
value: selected.contains(provider.id),
title: Text(provider.name),
subtitle: Text(provider.baseUrl),
onChanged: (value) {
setState(() {
if (value ?? false) {
selected.add(provider.id);
} else {
selected.remove(provider.id);
}
});
},
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => Navigator.pop(context, selected.toList()),
child: const Text('Apply'),
),
],
),
),
);
}
Future<String?> showRatingFilterSheet(BuildContext context, String? selected) {
return showModalBottomSheet<String?>(
context: context,
showDragHandle: true,
builder: (context) => ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Rating', style: Theme.of(context).textTheme.titleLarge),
ListTile(
leading: Icon(
selected == null
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
),
title: const Text('Any'),
onTap: () => Navigator.pop(context, null),
),
for (final rating in ['safe', 'questionable', 'explicit'])
ListTile(
leading: Icon(
selected == rating
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
),
title: Text(rating),
onTap: () => Navigator.pop(context, rating),
),
],
),
);
}
@@ -0,0 +1 @@
export 'feed_toolbar.dart' show showProviderFilterSheet;
@@ -0,0 +1 @@
export 'feed_toolbar.dart' show showRatingFilterSheet;