Initial Flutter booru app
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user