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,51 @@
import 'package:flutter/material.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 '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import 'collections_controller.dart';
class CollectionDetailsScreen extends ConsumerWidget {
const CollectionDetailsScreen({required this.collectionId, super.key});
final String collectionId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final posts = ref.watch(collectionPostsProvider(collectionId));
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
return AdaptiveScaffold(
title: 'Collection',
body: posts.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (items) => items.isEmpty
? const EmptyView(title: 'Collection is empty')
: PostMasonryGrid(
posts: items,
columns: Responsive.columnsFor(
context,
mobileColumns: settings.mobileColumns,
desktopColumns: settings.desktopColumns,
),
blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
),
onFavorite: (post) =>
ref.read(favoriteServiceProvider).addFavorite(post),
),
),
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import 'collections_controller.dart';
Future<void> showCollectionFormDialog(
BuildContext context,
WidgetRef ref, {
Collection? collection,
}) async {
final nameController = TextEditingController(text: collection?.name);
final descriptionController =
TextEditingController(text: collection?.description);
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(collection == null ? 'New collection' : 'Edit collection'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 12),
TextField(
controller: descriptionController,
decoration: const InputDecoration(labelText: 'Description'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) return;
if (collection == null) {
await ref
.read(collectionsControllerProvider.notifier)
.create(name, descriptionController.text.trim());
} else {
await ref
.read(collectionsControllerProvider.notifier)
.updateCollection(
collection.id,
name: name,
description: descriptionController.text.trim(),
coverUrl: collection.coverUrl,
);
}
if (context.mounted) Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
nameController.dispose();
descriptionController.dispose();
}
@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final collectionsControllerProvider =
AsyncNotifierProvider<CollectionsController, List<Collection>>(
CollectionsController.new,
);
class CollectionsController extends AsyncNotifier<List<Collection>> {
@override
Future<List<Collection>> build() => _load();
Future<void> create(String name, String? description) async {
await ref
.read(collectionServiceProvider)
.createCollection(name, description);
state = AsyncData(await _load());
}
Future<void> updateCollection(
String id, {
required String name,
String? description,
String? coverUrl,
}) async {
await ref.read(collectionServiceProvider).updateCollection(
id,
name: name,
description: description,
coverUrl: coverUrl,
);
state = AsyncData(await _load());
}
Future<void> delete(String id) async {
await ref.read(collectionServiceProvider).deleteCollection(id);
state = AsyncData(await _load());
}
Future<List<Collection>> _load() async {
final result = await ref.read(collectionServiceProvider).getCollections();
return result is Success<List<Collection>> ? result.data : const [];
}
}
final collectionPostsProvider =
FutureProvider.family<List<Post>, String>((ref, collectionId) async {
final result = await ref
.watch(collectionServiceProvider)
.getCollectionPosts(collectionId);
return result is Success<List<Post>> ? result.data : const [];
});
@@ -0,0 +1,98 @@
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/confirm_dialog.dart';
import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import 'collection_form_dialog.dart';
import 'collections_controller.dart';
class CollectionsScreen extends ConsumerWidget {
const CollectionsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final collections = ref.watch(collectionsControllerProvider);
return AdaptiveScaffold(
title: 'Collections',
actions: [
IconButton(
tooltip: 'New collection',
icon: const Icon(Icons.add_rounded),
onPressed: () => showCollectionFormDialog(context, ref),
),
],
body: collections.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (items) => items.isEmpty
? const EmptyView(title: 'No collections yet')
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.35,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/collections/${item.id}'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.collections_bookmark_rounded,
size: 32),
const Spacer(),
Text(item.name,
style: Theme.of(context).textTheme.titleLarge),
Text(item.description ?? ''),
Row(
children: [
IconButton(
tooltip: 'Edit',
onPressed: () => showCollectionFormDialog(
context,
ref,
collection: item,
),
icon: const Icon(Icons.edit_rounded),
),
IconButton(
tooltip: 'Delete',
onPressed: () async {
final ok = await showConfirmDialog(
context,
title: 'Delete collection',
message: 'Remove ${item.name}?',
);
if (ok) {
await ref
.read(collectionsControllerProvider
.notifier)
.delete(item.id);
}
},
icon: const Icon(Icons.delete_rounded),
),
],
),
],
),
),
),
);
},
),
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import 'favorites_state.dart';
final favoritesControllerProvider =
AsyncNotifierProvider<FavoritesController, FavoritesState>(
FavoritesController.new,
);
class FavoritesController extends AsyncNotifier<FavoritesState> {
@override
Future<FavoritesState> build() async => FavoritesState(posts: await _load());
Future<void> remove(Post post) async {
await ref
.read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId);
state = AsyncData(FavoritesState(posts: await _load()));
}
Future<List<Post>> _load() async {
final result = await ref.read(favoriteServiceProvider).getFavoritePosts();
return result is Success<List<Post>> ? result.data : const [];
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.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 '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import 'favorites_controller.dart';
class FavoritesScreen extends ConsumerWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(favoritesControllerProvider);
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
return AdaptiveScaffold(
title: 'Favorites',
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (data) => data.posts.isEmpty
? const EmptyView(title: 'No favorites yet')
: PostMasonryGrid(
posts: data.posts,
columns: Responsive.columnsFor(
context,
mobileColumns: settings.mobileColumns,
desktopColumns: settings.desktopColumns,
),
blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
),
onFavorite: (post) =>
ref.read(favoritesControllerProvider.notifier).remove(post),
),
),
);
}
}
@@ -0,0 +1,6 @@
import '../../../backend/backend.dart';
class FavoritesState {
const FavoritesState({this.posts = const []});
final List<Post> posts;
}
@@ -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;
@@ -0,0 +1,40 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final postDetailsControllerProvider =
FutureProvider.family<Post?, PostDetailsArgs>((ref, args) async {
if (args.initialPost != null) return args.initialPost;
final cached = await ref
.watch(postRepositoryProvider)
.getCachedPost(args.postId, args.providerId);
if (cached is Success<Post?> && cached.data != null) return cached.data;
final remote = await ref
.watch(providerManagerProvider)
.getPost(args.providerId, args.postId);
return remote is Success<Post?> ? remote.data : null;
});
class PostDetailsArgs {
const PostDetailsArgs({
required this.providerId,
required this.postId,
this.initialPost,
});
final String providerId;
final String postId;
final Post? initialPost;
@override
bool operator ==(Object other) {
return other is PostDetailsArgs &&
other.providerId == providerId &&
other.postId == postId &&
other.initialPost == initialPost;
}
@override
int get hashCode => Object.hash(providerId, postId, initialPost);
}
@@ -0,0 +1,245 @@
import 'package:dio/dio.dart';
import 'package:file_selector/file_selector.dart';
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 'package:path/path.dart' as p;
import 'package:url_launcher/url_launcher.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/rating_badge.dart';
import '../../collections/presentation/collection_form_dialog.dart';
import '../../feed/presentation/feed_controller.dart';
import 'post_details_controller.dart';
import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.dart';
import 'widgets/post_tags_panel.dart';
class PostDetailsScreen extends ConsumerWidget {
const PostDetailsScreen({
required this.providerId,
required this.postId,
this.initialPost,
super.key,
});
final String providerId;
final String postId;
final Post? initialPost;
@override
Widget build(BuildContext context, WidgetRef ref) {
final args = PostDetailsArgs(
providerId: providerId,
postId: postId,
initialPost: initialPost,
);
final post = ref.watch(postDetailsControllerProvider(args));
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final feedPosts =
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
return AdaptiveScaffold(
title: 'Post',
actions: [
IconButton(
tooltip: 'Close',
onPressed: () => _close(context),
icon: const Icon(Icons.close_rounded),
),
],
body: post.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (post) {
if (post == null) return const EmptyView(title: 'Post not found');
final currentIndex = feedPosts.indexWhere(
(item) => item.providerId == post.providerId && item.id == post.id,
);
final previous =
currentIndex > 0 ? feedPosts[currentIndex - 1] : null;
final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
? feedPosts[currentIndex + 1]
: null;
if (Responsive.isMobile(context)) {
return _buildMobileDetails(context, ref, post, settings);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton.filledTonal(
tooltip: 'Previous',
onPressed: previous == null
? null
: () => _openPost(context, previous),
icon: const Icon(Icons.chevron_left_rounded),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760),
child: PostMediaViewer(post: post),
),
),
),
IconButton.filledTonal(
tooltip: 'Next',
onPressed:
next == null ? null : () => _openPost(context, next),
icon: const Icon(Icons.chevron_right_rounded),
),
],
),
const SizedBox(height: 16),
PostActionBar(
onFavorite: () =>
ref.read(favoriteServiceProvider).addFavorite(post),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)),
onDownload: settings.allowDownloads
? () => _download(context, post)
: null,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: const Text('Source'),
onPressed: () => launchUrl(Uri.parse(post.source!)),
),
],
),
const SizedBox(height: 16),
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
PostTagsPanel(post: post),
],
);
},
),
);
}
Widget _buildMobileDetails(
BuildContext context,
WidgetRef ref,
Post post,
AppSettings settings,
) {
return ListView(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
),
child: Center(child: PostMediaViewer(post: post)),
),
const SizedBox(height: 12),
PostActionBar(
onFavorite: () => ref.read(favoriteServiceProvider).addFavorite(post),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
onDownload:
settings.allowDownloads ? () => _download(context, post) : null,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: const Text('Source'),
onPressed: () => launchUrl(Uri.parse(post.source!)),
),
],
),
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
initiallyExpanded: false,
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
children: [
Align(
alignment: Alignment.centerLeft,
child: PostTagsPanel(post: 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),
);
}
Future<void> _download(BuildContext context, Post post) async {
final filename = p.basename(Uri.parse(post.fileUrl).path);
final location = await getSaveLocation(suggestedName: filename);
if (location == null) return;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Downloading...')),
);
await Dio().download(post.fileUrl, location.path);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Download complete')),
);
}
void _openPost(BuildContext context, Post post) {
context.go('/post/${post.providerId}/${post.id}', extra: post);
}
void _close(BuildContext context) {
if (Navigator.of(context).canPop()) {
context.pop();
return;
}
context.go('/');
}
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class PostActionBar extends StatelessWidget {
const PostActionBar({
required this.onFavorite,
required this.onCollection,
required this.onOpen,
required this.onCopy,
this.onDownload,
super.key,
});
final VoidCallback onFavorite;
final VoidCallback onCollection;
final VoidCallback onOpen;
final VoidCallback onCopy;
final VoidCallback? onDownload;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: onFavorite,
icon: const Icon(Icons.favorite_rounded),
label: const Text('Favorite'),
),
FilledButton.tonalIcon(
onPressed: onCollection,
icon: const Icon(Icons.add_rounded),
label: const Text('Collection'),
),
IconButton.filledTonal(
tooltip: 'Open original',
onPressed: onOpen,
icon: const Icon(Icons.open_in_new_rounded),
),
IconButton.filledTonal(
tooltip: 'Copy link',
onPressed: onCopy,
icon: const Icon(Icons.copy_rounded),
),
if (onDownload != null)
IconButton.filledTonal(
tooltip: 'Download',
onPressed: onDownload,
icon: const Icon(Icons.download_rounded),
),
],
);
}
}
@@ -0,0 +1,323 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../../backend/backend.dart';
class PostMediaViewer extends StatefulWidget {
const PostMediaViewer({required this.post, super.key});
final Post post;
@override
State<PostMediaViewer> createState() => _PostMediaViewerState();
}
class _PostMediaViewerState extends State<PostMediaViewer> {
Player? _player;
VideoController? _controller;
late List<String> _imageUrls;
int _imageIndex = 0;
bool _controlsVisible = true;
bool _coverVideo = false;
bool _muted = false;
Timer? _hideTimer;
@override
void initState() {
super.initState();
_imageUrls = _buildImageUrls(widget.post);
if (widget.post.fileType == 'video') {
MediaKit.ensureInitialized();
_player = Player();
_controller = VideoController(_player!);
_player!.open(Media(widget.post.fileUrl), play: false);
_scheduleControlsHide();
}
}
@override
void dispose() {
_hideTimer?.cancel();
_player?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_controller != null) {
return _VideoSurface(
player: _player!,
controller: _controller!,
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height
: 16 / 9,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
onInteract: _showControls,
onToggleFit: () {
setState(() => _coverVideo = !_coverVideo);
_showControls();
},
onToggleMute: () async {
final nextMuted = !_muted;
setState(() => _muted = nextMuted);
await _player!.setVolume(nextMuted ? 0 : 100);
_showControls();
},
);
}
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
return InteractiveViewer(
child: CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: _headersFor(widget.post),
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) {
if (_imageIndex < _imageUrls.length - 1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _imageIndex++);
});
return const Center(child: CircularProgressIndicator());
}
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image_rounded, size: 48),
SizedBox(height: 8),
Text('Could not load image'),
],
),
);
},
),
);
}
List<String> _buildImageUrls(Post post) {
return {
post.sampleUrl,
post.fileUrl,
post.previewUrl,
}.where((url) => url.trim().isNotEmpty).toList();
}
Map<String, String> _headersFor(Post post) {
return {
'User-Agent': 'GelRuleApp/0.1 Flutter local booru browser',
if (post.providerName.toLowerCase().contains('gelbooru') ||
post.fileUrl.contains('gelbooru.com') ||
post.sampleUrl.contains('gelbooru.com') ||
post.previewUrl.contains('gelbooru.com'))
'Referer': 'https://gelbooru.com/',
};
}
void _showControls() {
if (!mounted) return;
setState(() => _controlsVisible = true);
_scheduleControlsHide();
}
void _scheduleControlsHide() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _controlsVisible = false);
});
}
}
class _VideoSurface extends StatelessWidget {
const _VideoSurface({
required this.player,
required this.controller,
required this.aspectRatio,
required this.controlsVisible,
required this.coverVideo,
required this.muted,
required this.onInteract,
required this.onToggleFit,
required this.onToggleMute,
});
final Player player;
final VideoController controller;
final double aspectRatio;
final bool controlsVisible;
final bool coverVideo;
final bool muted;
final VoidCallback onInteract;
final VoidCallback onToggleFit;
final VoidCallback onToggleMute;
@override
Widget build(BuildContext context) {
return MouseRegion(
onHover: (_) => onInteract(),
child: GestureDetector(
onTap: onInteract,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: AspectRatio(
aspectRatio: aspectRatio.clamp(0.35, 2.4),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: Colors.black,
child: Video(
controller: controller,
fit: coverVideo ? BoxFit.cover : BoxFit.contain,
controls: null,
),
),
AnimatedOpacity(
opacity: controlsVisible ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: _VideoControls(
player: player,
muted: muted,
coverVideo: coverVideo,
onToggleFit: onToggleFit,
onToggleMute: onToggleMute,
),
),
],
),
),
),
),
);
}
}
class _VideoControls extends StatelessWidget {
const _VideoControls({
required this.player,
required this.muted,
required this.coverVideo,
required this.onToggleFit,
required this.onToggleMute,
});
final Player player;
final bool muted;
final bool coverVideo;
final VoidCallback onToggleFit;
final VoidCallback onToggleMute;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.black.withValues(alpha: 0.18),
Colors.transparent,
],
),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(12),
child: StreamBuilder<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
builder: (context, playingSnapshot) {
final playing = playingSnapshot.data ?? false;
return StreamBuilder<Duration>(
stream: player.stream.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final duration = durationSnapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
final maxMs =
duration.inMilliseconds.clamp(1, 1 << 31).toDouble();
final valueMs = position.inMilliseconds
.clamp(0, maxMs.toInt())
.toDouble();
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Slider(
value: valueMs,
max: maxMs,
onChanged: (value) => player.seek(
Duration(milliseconds: value.round()),
),
),
Row(
children: [
IconButton.filledTonal(
tooltip: playing ? 'Pause' : 'Play',
onPressed: player.playOrPause,
icon: Icon(
playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
),
const SizedBox(width: 8),
Text(
'${_format(position)} / ${_format(duration)}',
style: const TextStyle(color: Colors.white),
),
const Spacer(),
IconButton.filledTonal(
tooltip: muted ? 'Unmute' : 'Mute',
onPressed: onToggleMute,
icon: Icon(
muted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
),
),
const SizedBox(width: 6),
IconButton.filledTonal(
tooltip: coverVideo ? 'Fit' : 'Fill',
onPressed: onToggleFit,
icon: Icon(
coverVideo
? Icons.fit_screen_rounded
: Icons.crop_free_rounded,
),
),
],
),
],
);
},
);
},
);
},
),
),
),
);
}
String _format(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
final hours = duration.inHours;
if (hours > 0) return '$hours:$minutes:$seconds';
return '$minutes:$seconds';
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../backend/backend.dart';
import '../../../../shared/widgets/tag_chip.dart';
class PostTagsPanel extends StatelessWidget {
const PostTagsPanel({required this.post, super.key});
final Post post;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in post.tags.take(80))
TagChip(
tag: tag,
onTap: () => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
),
],
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/error_view.dart';
import 'providers_controller.dart';
import 'widgets/provider_health_tile.dart';
class ProviderCheckScreen extends ConsumerWidget {
const ProviderCheckScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final providers = ref.watch(providersControllerProvider);
final health = ref.watch(providerHealthProvider);
return AdaptiveScaffold(
title: 'Provider Check',
actions: [
IconButton(
tooltip: 'Check all',
onPressed: () => _checkAll(ref),
icon: const Icon(Icons.playlist_add_check_rounded),
),
],
body: providers.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (items) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ProviderHealthTile(
config: item,
health: health[item.id],
onCheck: () => _checkOne(ref, item.id),
);
},
),
),
);
}
Future<void> _checkAll(WidgetRef ref) async {
final result = await ref.read(providerCheckServiceProvider).checkAll();
if (result is Success<List<ProviderHealth>>) {
ref.read(providerHealthProvider.notifier).state = {
for (final item in result.data) item.providerId: item,
};
}
}
Future<void> _checkOne(WidgetRef ref, String providerId) async {
final result =
await ref.read(providerCheckServiceProvider).checkOne(providerId);
if (result is Success<ProviderHealth>) {
ref.read(providerHealthProvider.notifier).state = {
...ref.read(providerHealthProvider),
providerId: result.data,
};
}
}
}
@@ -0,0 +1,195 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../backend/backend.dart';
import 'providers_controller.dart';
class ProviderFormScreen extends ConsumerStatefulWidget {
const ProviderFormScreen({this.initialConfig, super.key});
final ContentProviderConfig? initialConfig;
@override
ConsumerState<ProviderFormScreen> createState() => _ProviderFormScreenState();
}
class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
late final TextEditingController _name;
late final TextEditingController _baseUrl;
late final TextEditingController _priority;
late final TextEditingController _timeout;
late final TextEditingController _headers;
late final TextEditingController _apiKey;
late final TextEditingController _userId;
late final TextEditingController _login;
late String _apiType;
late bool _enabled;
@override
void initState() {
super.initState();
final config = widget.initialConfig;
_name = TextEditingController(text: config?.name);
_baseUrl = TextEditingController(text: config?.baseUrl);
_priority =
TextEditingController(text: (config?.priority ?? 10).toString());
_timeout =
TextEditingController(text: (config?.timeoutSeconds ?? 20).toString());
_headers = TextEditingController(
text: jsonEncode(_visibleHeaders(config?.customHeaders ?? const {})),
);
_apiKey =
TextEditingController(text: config?.customHeaders['query.api_key']);
_userId =
TextEditingController(text: config?.customHeaders['query.user_id']);
_login = TextEditingController(text: config?.customHeaders['query.login']);
_apiType = config?.apiType ?? 'gelbooru';
_enabled = config?.enabled ?? true;
}
@override
void dispose() {
_name.dispose();
_baseUrl.dispose();
_priority.dispose();
_timeout.dispose();
_headers.dispose();
_apiKey.dispose();
_userId.dispose();
_login.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.initialConfig == null ? 'New provider' : 'Edit provider')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _name,
decoration: const InputDecoration(labelText: 'Name')),
const SizedBox(height: 12),
TextField(
controller: _baseUrl,
decoration: const InputDecoration(labelText: 'Base URL')),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _apiType,
decoration: const InputDecoration(labelText: 'API type'),
items: const [
DropdownMenuItem(
value: 'gelbooru', child: Text('gelbooru-compatible')),
DropdownMenuItem(
value: 'rule34', child: Text('rule34-compatible')),
DropdownMenuItem(
value: 'danbooru', child: Text('danbooru-compatible')),
DropdownMenuItem(value: 'custom', child: Text('custom')),
],
onChanged: (value) =>
setState(() => _apiType = value ?? 'gelbooru'),
),
const SizedBox(height: 12),
SwitchListTile(
value: _enabled,
title: const Text('Enabled'),
onChanged: (value) => setState(() => _enabled = value),
),
TextField(
controller: _priority,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Priority'),
),
const SizedBox(height: 12),
TextField(
controller: _timeout,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Timeout seconds'),
),
const SizedBox(height: 12),
Text('Provider API credentials',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _apiKey,
decoration: const InputDecoration(
labelText: 'API key',
helperText: 'Gelbooru: api_key, Danbooru: api_key',
),
),
const SizedBox(height: 12),
TextField(
controller: _userId,
decoration: const InputDecoration(
labelText: 'User ID',
helperText: 'Gelbooru-compatible APIs may require user_id',
),
),
const SizedBox(height: 12),
TextField(
controller: _login,
decoration: const InputDecoration(
labelText: 'Login',
helperText: 'Danbooru-compatible APIs may require login',
),
),
const SizedBox(height: 12),
TextField(
controller: _headers,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(labelText: 'Custom headers JSON'),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.save_rounded),
label: const Text('Save provider'),
),
],
),
);
}
Future<void> _save() async {
final now = DateTime.now();
final id = widget.initialConfig?.id ??
_name.text.trim().toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_');
final headersJson =
jsonDecode(_headers.text.trim().isEmpty ? '{}' : _headers.text);
final customHeaders = Map<String, String>.from(headersJson as Map);
void putQuery(String key, String value) {
final trimmed = value.trim();
if (trimmed.isNotEmpty) customHeaders['query.$key'] = trimmed;
}
putQuery('api_key', _apiKey.text);
putQuery('user_id', _userId.text);
putQuery('login', _login.text);
final config = ContentProviderConfig(
id: id,
name: _name.text.trim(),
baseUrl: _baseUrl.text.trim(),
apiType: _apiType,
enabled: _enabled,
priority: int.tryParse(_priority.text) ?? 10,
timeoutSeconds: int.tryParse(_timeout.text) ?? 20,
customHeaders: customHeaders,
createdAt: widget.initialConfig?.createdAt ?? now,
updatedAt: now,
);
await ref.read(providersControllerProvider.notifier).save(config);
if (mounted) context.go('/providers');
}
Map<String, String> _visibleHeaders(Map<String, String> headers) {
return Map<String, String>.from(headers)
..removeWhere((key, _) => key.startsWith('query.'));
}
}
@@ -0,0 +1,40 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final providersControllerProvider =
AsyncNotifierProvider<ProvidersController, List<ContentProviderConfig>>(
ProvidersController.new,
);
class ProvidersController extends AsyncNotifier<List<ContentProviderConfig>> {
@override
Future<List<ContentProviderConfig>> build() => _load();
Future<void> toggle(String id, bool enabled) async {
await ref.read(providerManagerProvider).enableProvider(id, enabled);
state = AsyncData(await _load());
}
Future<void> save(ContentProviderConfig config) async {
await ref.read(providerManagerProvider).addCustomProvider(config);
state = AsyncData(await _load());
}
Future<void> delete(String id) async {
await ref.read(providerManagerProvider).deleteProvider(id);
state = AsyncData(await _load());
}
Future<List<ContentProviderConfig>> _load() async {
final result =
await ref.read(providerManagerProvider).loadConfigs(enabledOnly: false);
return result is Success<List<ContentProviderConfig>>
? result.data
: const [];
}
}
final providerHealthProvider =
StateProvider<Map<String, ProviderHealth>>((ref) => {});
@@ -0,0 +1,63 @@
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/confirm_dialog.dart';
import '../../../shared/widgets/error_view.dart';
import 'providers_controller.dart';
import 'widgets/provider_card.dart';
class ProvidersScreen extends ConsumerWidget {
const ProvidersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final providers = ref.watch(providersControllerProvider);
return AdaptiveScaffold(
title: 'Providers',
actions: [
IconButton(
tooltip: 'Check providers',
onPressed: () => context.go('/providers/check'),
icon: const Icon(Icons.network_check_rounded),
),
IconButton(
tooltip: 'Add provider',
onPressed: () => context.go('/providers/new'),
icon: const Icon(Icons.add_rounded),
),
],
body: providers.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (items) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ProviderCard(
config: item,
onToggle: (enabled) => ref
.read(providersControllerProvider.notifier)
.toggle(item.id, enabled),
onEdit: () => context.go('/providers/new', extra: item),
onDelete: () async {
final ok = await showConfirmDialog(
context,
title: 'Delete provider',
message: 'Remove ${item.name}?',
);
if (ok) {
await ref
.read(providersControllerProvider.notifier)
.delete(item.id);
}
},
);
},
),
),
);
}
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
class ProviderCard extends StatelessWidget {
const ProviderCard({
required this.config,
required this.onToggle,
required this.onEdit,
required this.onDelete,
super.key,
});
final ContentProviderConfig config;
final ValueChanged<bool> onToggle;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Switch(value: config.enabled, onChanged: onToggle),
title: Text(config.name),
subtitle: Text(
'${config.apiType} · ${config.baseUrl}\nPriority ${config.priority}'),
isThreeLine: true,
trailing: Wrap(
spacing: 4,
children: [
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit_rounded),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon: const Icon(Icons.delete_rounded),
),
],
),
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
import '../../../../shared/widgets/provider_status_badge.dart';
class ProviderHealthTile extends StatelessWidget {
const ProviderHealthTile({
required this.config,
required this.health,
required this.onCheck,
super.key,
});
final ContentProviderConfig config;
final ProviderHealth? health;
final VoidCallback onCheck;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(config.name),
subtitle: Text(
[
if (health != null) 'Ping ${health!.pingMs} ms',
if (health?.lastCheckedAt != null)
'Checked ${health!.lastCheckedAt}',
if (health?.errorMessage != null) health!.errorMessage!,
].join('\n'),
),
leading: ProviderStatusBadge(health: health),
trailing: IconButton(
tooltip: 'Check',
onPressed: onCheck,
icon: const Icon(Icons.network_check_rounded),
),
),
);
}
}
@@ -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),
),
],
);
}
}
@@ -0,0 +1,39 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/app.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final settingsControllerProvider =
AsyncNotifierProvider<SettingsController, AppSettings>(
SettingsController.new);
class SettingsController extends AsyncNotifier<AppSettings> {
@override
Future<AppSettings> build() async {
final result = await ref.read(settingsServiceProvider).getSettings();
return result is Success<AppSettings> ? result.data : AppSettings.defaults;
}
Future<void> saveSettings(AppSettings settings) async {
await ref.read(settingsServiceProvider).updateSettings(settings);
state = AsyncData(settings);
ref.invalidate(appSettingsProvider);
}
Future<void> clearCache() async {
await ref.read(cacheServiceProvider).clear();
}
Future<String> exportJson() async {
final result =
await ref.read(settingsServiceProvider).exportSettingsToJson();
return result is Success<String> ? result.data : '{}';
}
Future<void> importJson(String json) async {
await ref.read(settingsServiceProvider).importSettingsFromJson(json);
ref.invalidate(appSettingsProvider);
ref.invalidateSelf();
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/error_view.dart';
import 'settings_controller.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsControllerProvider);
return AdaptiveScaffold(
title: 'Settings',
body: settings.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (settings) => ListView(
padding: const EdgeInsets.all(16),
children: [
DropdownButtonFormField<String>(
initialValue: settings.themeMode,
decoration: const InputDecoration(labelText: 'Theme'),
items: const [
DropdownMenuItem(value: 'dark', child: Text('Dark')),
DropdownMenuItem(value: 'light', child: Text('Light')),
DropdownMenuItem(value: 'system', child: Text('System')),
],
onChanged: (value) =>
_update(ref, settings.copyWith(themeMode: value ?? 'dark')),
),
const SizedBox(height: 12),
SwitchListTile(
value: settings.blurExplicitContent,
title: const Text('Blur sensitive previews'),
onChanged: (value) =>
_update(ref, settings.copyWith(blurExplicitContent: value)),
),
SwitchListTile(
value: settings.nsfwEnabled,
title: const Text('Allow NSFW content'),
onChanged: (value) =>
_update(ref, settings.copyWith(nsfwEnabled: value)),
),
SwitchListTile(
value: settings.showPostBadges,
title: const Text('Show post badges'),
onChanged: (value) =>
_update(ref, settings.copyWith(showPostBadges: value)),
),
SwitchListTile(
value: settings.allowDownloads,
title: const Text('Allow manual downloads'),
onChanged: (value) =>
_update(ref, settings.copyWith(allowDownloads: value)),
),
_StepperTile(
title: 'Desktop columns',
value: settings.desktopColumns,
min: 3,
max: 8,
onChanged: (value) => _update(
ref,
settings.copyWith(desktopColumns: value),
),
),
_StepperTile(
title: 'Mobile columns',
value: settings.mobileColumns,
min: 1,
max: 3,
onChanged: (value) => _update(
ref,
settings.copyWith(mobileColumns: value),
),
),
_StepperTile(
title: 'Cache max items',
value: settings.cacheMaxItems,
min: 100,
max: 10000,
step: 100,
onChanged: (value) =>
_update(ref, settings.copyWith(cacheMaxItems: value)),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () =>
ref.read(settingsControllerProvider.notifier).clearCache(),
icon: const Icon(Icons.cleaning_services_rounded),
label: const Text('Clear cache'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () async {
final json = await ref
.read(settingsControllerProvider.notifier)
.exportJson();
await Clipboard.setData(ClipboardData(text: json));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings JSON copied')),
);
}
},
icon: const Icon(Icons.upload_file_rounded),
label: const Text('Export settings JSON'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => _importDialog(context, ref),
icon: const Icon(Icons.download_for_offline_rounded),
label: const Text('Import settings JSON'),
),
const SizedBox(height: 24),
const Text('Version 0.1.0'),
],
),
),
);
}
Future<void> _update(WidgetRef ref, AppSettings settings) {
return ref.read(settingsControllerProvider.notifier).saveSettings(settings);
}
Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Import settings'),
content: TextField(
controller: controller,
minLines: 6,
maxLines: 12,
decoration: const InputDecoration(labelText: 'JSON'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
await ref
.read(settingsControllerProvider.notifier)
.importJson(controller.text);
if (context.mounted) Navigator.pop(context);
},
child: const Text('Import'),
),
],
),
);
controller.dispose();
}
}
class _StepperTile extends StatelessWidget {
const _StepperTile({
required this.title,
required this.value,
required this.min,
required this.max,
required this.onChanged,
this.step = 1,
});
final String title;
final int value;
final int min;
final int max;
final int step;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: value <= min ? null : () => onChanged(value - step),
icon: const Icon(Icons.remove_rounded),
),
SizedBox(width: 52, child: Center(child: Text('$value'))),
IconButton(
onPressed: value >= max ? null : () => onChanged(value + step),
icon: const Icon(Icons.add_rounded),
),
],
),
);
}
}