From f9938e393db0eeb9ff950c089709fafd229cc324 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 23 May 2026 12:15:20 +0300 Subject: [PATCH] Polish mobile UX downloads and comments --- README.md | 8 +- android/app/src/main/AndroidManifest.xml | 2 +- .../com/example/gel_rule_app/MainActivity.kt | 61 +++++- lib/app/app.dart | 2 +- lib/backend/backend.dart | 2 + lib/backend/di/backend_providers.dart | 5 + lib/backend/models/post_comment.dart | 17 ++ lib/backend/providers/content_provider.dart | 5 + lib/backend/providers/custom_provider.dart | 12 +- lib/backend/providers/danbooru_provider.dart | 35 +++- lib/backend/providers/gelbooru_provider.dart | 51 +++++- lib/backend/providers/provider_factory.dart | 24 ++- lib/backend/providers/provider_manager.dart | 36 +++- lib/backend/services/download_service.dart | 77 ++++++++ .../collection_details_screen.dart | 16 +- .../presentation/favorites_controller.dart | 11 ++ .../presentation/favorites_screen.dart | 1 + .../feed/presentation/feed_screen.dart | 24 ++- .../presentation/post_details_screen.dart | 173 ++++++++++++++++-- .../presentation/widgets/post_action_bar.dart | 8 +- lib/shared/widgets/post_card.dart | 49 ++++- lib/shared/widgets/post_masonry_grid.dart | 3 + test/backend/provider_manager_test.dart | 4 +- windows/runner/Runner.rc | 6 +- 24 files changed, 571 insertions(+), 61 deletions(-) create mode 100644 lib/backend/models/post_comment.dart create mode 100644 lib/backend/services/download_service.dart diff --git a/README.md b/README.md index 69c0943..7ea0755 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# GelRuleApp +# RuleGel -GelRuleApp is a single Flutter monolith for browsing booru/gelbooru/rule34-like providers with a Pinterest-style feed. It does not start or require a separate backend server. Provider access, caching, favorites, collections, search history, settings, and health checks all run locally inside the Flutter app. +RuleGel is a single Flutter monolith for browsing booru/gelbooru/rule34-like providers with a Pinterest-style feed. It does not start or require a separate backend server. Provider access, caching, favorites, collections, search history, settings, downloads, read-only comments where APIs allow them, and health checks all run locally inside the Flutter app. ## Stack @@ -118,8 +118,8 @@ Unsupported types return `ProviderUnavailableException` instead of crashing the Search behavior: -- Offline providers are skipped based on saved health. -- A failed provider marks itself offline. +- Enabled providers are tried even if an older health check marked them offline. +- A failed provider marks itself offline for diagnostics. - Other providers continue returning posts. - Results are combined into a single list. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 92cbe9f..c186fba 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ + if (call.method != "saveToDownloads") { + result.notImplemented() + return@setMethodCallHandler + } + try { + val path = call.argument("path") ?: error("Missing path") + val fileName = call.argument("fileName") ?: File(path).name + val mimeType = call.argument("mimeType") ?: "application/octet-stream" + result.success(saveToDownloads(path, fileName, mimeType)) + } catch (error: Throwable) { + result.error("save_failed", error.message, null) + } + } + } + + private fun saveToDownloads(path: String, fileName: String, mimeType: String): String { + val source = File(path) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, mimeType) + put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.Downloads.IS_PENDING, 1) + } + val resolver = applicationContext.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: error("Could not create Downloads entry") + resolver.openOutputStream(uri)?.use { output -> + FileInputStream(source).use { input -> input.copyTo(output) } + } ?: error("Could not open Downloads output stream") + values.clear() + values.put(MediaStore.Downloads.IS_PENDING, 0) + resolver.update(uri, values, null, null) + return fileName + } + + @Suppress("DEPRECATION") + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + downloads.mkdirs() + val destination = File(downloads, fileName) + FileInputStream(source).use { input -> + FileOutputStream(destination).use { output -> input.copyTo(output) } + } + return destination.absolutePath + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index 3b62173..87e135c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -27,7 +27,7 @@ class GelRuleApp extends ConsumerWidget { home: _StartupErrorScreen(error: error), ), data: (_) => MaterialApp.router( - title: 'GelRule', + title: 'RuleGel', debugShowCheckedModeBanner: false, theme: buildTheme(Brightness.light), darkTheme: buildTheme(Brightness.dark), diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index d01798b..7761778 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -3,12 +3,14 @@ export 'models/collection.dart'; export 'models/content_provider_config.dart'; export 'models/favorite.dart'; export 'models/post.dart'; +export 'models/post_comment.dart'; export 'models/provider_health.dart'; export 'models/search_history.dart'; export 'models/top_period_filter.dart'; export 'providers/content_provider.dart'; export 'providers/provider_manager.dart'; export 'services/collection_service.dart'; +export 'services/download_service.dart'; export 'services/favorite_service.dart'; export 'services/feed_service.dart'; export 'services/provider_check_service.dart'; diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index 8d5c4b9..a092f5b 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -14,6 +14,7 @@ import '../repositories/post_repository.dart'; import '../repositories/provider_repository.dart'; import '../repositories/search_repository.dart'; import '../services/collection_service.dart'; +import '../services/download_service.dart'; import '../services/favorite_service.dart'; import '../services/feed_service.dart'; import '../services/provider_check_service.dart'; @@ -119,3 +120,7 @@ final settingsServiceProvider = Provider((ref) { providerRepository: ref.watch(providerRepositoryProvider), ); }); + +final downloadServiceProvider = Provider((ref) { + return DownloadService(); +}); diff --git a/lib/backend/models/post_comment.dart b/lib/backend/models/post_comment.dart new file mode 100644 index 0000000..6d1be51 --- /dev/null +++ b/lib/backend/models/post_comment.dart @@ -0,0 +1,17 @@ +class PostComment { + const PostComment({ + required this.id, + required this.postId, + required this.providerId, + required this.authorName, + required this.body, + required this.createdAt, + }); + + final String id; + final String postId; + final String providerId; + final String authorName; + final String body; + final DateTime createdAt; +} diff --git a/lib/backend/providers/content_provider.dart b/lib/backend/providers/content_provider.dart index f72f88c..77582cc 100644 --- a/lib/backend/providers/content_provider.dart +++ b/lib/backend/providers/content_provider.dart @@ -1,4 +1,5 @@ import '../models/post.dart'; +import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; @@ -19,3 +20,7 @@ abstract class ContentProvider { Future checkHealth(); } + +abstract class CommentProvider { + Future> getComments(String postId); +} diff --git a/lib/backend/providers/custom_provider.dart b/lib/backend/providers/custom_provider.dart index 22680d6..7c9158b 100644 --- a/lib/backend/providers/custom_provider.dart +++ b/lib/backend/providers/custom_provider.dart @@ -1,10 +1,11 @@ import '../../core/errors/app_exception.dart'; import '../models/post.dart'; +import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; -class CustomProvider implements ContentProvider { +class CustomProvider implements ContentProvider, CommentProvider { CustomProvider(this._delegate); final ContentProvider _delegate; @@ -37,6 +38,15 @@ class CustomProvider implements ContentProvider { @override Future checkHealth() => _delegate.checkHealth(); + + @override + Future> getComments(String postId) { + final delegate = _delegate; + if (delegate is CommentProvider) { + return (delegate as CommentProvider).getComments(postId); + } + return Future.value(const []); + } } class UnsupportedCustomProvider implements ContentProvider { diff --git a/lib/backend/providers/danbooru_provider.dart b/lib/backend/providers/danbooru_provider.dart index 643b846..5720ef2 100644 --- a/lib/backend/providers/danbooru_provider.dart +++ b/lib/backend/providers/danbooru_provider.dart @@ -3,11 +3,12 @@ import 'package:dio/dio.dart'; import '../../core/http/dio_client.dart'; import '../mappers/danbooru_mapper.dart'; import '../models/post.dart'; +import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; -class DanbooruProvider implements ContentProvider { +class DanbooruProvider implements ContentProvider, CommentProvider { DanbooruProvider({ required this.id, required this.name, @@ -96,6 +97,38 @@ class DanbooruProvider implements ContentProvider { } } + @override + Future> getComments(String postId) async { + final response = await _dio.get( + '/comments.json', + queryParameters: { + 'search[post_id]': postId, + 'limit': 50, + ..._queryParameters, + }, + ); + final data = response.data; + final items = data is List ? data : const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + return PostComment( + id: (json['id'] ?? '').toString(), + postId: (json['post_id'] ?? postId).toString(), + providerId: id, + authorName: (json['creator_name'] ?? json['creator_id'] ?? 'user') + .toString(), + body: (json['body'] ?? '').toString(), + createdAt: + DateTime.tryParse((json['created_at'] ?? '').toString()) ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + }) + .where((comment) => comment.body.trim().isNotEmpty) + .toList(); + } + List _topTags(TopPeriodFilter period) { return switch (period) { TopPeriodFilter.none => const [], diff --git a/lib/backend/providers/gelbooru_provider.dart b/lib/backend/providers/gelbooru_provider.dart index 05a13bc..3228dc2 100644 --- a/lib/backend/providers/gelbooru_provider.dart +++ b/lib/backend/providers/gelbooru_provider.dart @@ -4,11 +4,12 @@ import '../../core/errors/app_exception.dart'; import '../../core/http/dio_client.dart'; import '../mappers/gelbooru_mapper.dart'; import '../models/post.dart'; +import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; -class GelbooruProvider implements ContentProvider { +class GelbooruProvider implements ContentProvider, CommentProvider { GelbooruProvider({ required this.id, required this.name, @@ -117,6 +118,22 @@ class GelbooruProvider implements ContentProvider { } } + @override + Future> getComments(String postId) async { + final response = await _dio.get( + '/index.php', + queryParameters: { + 'page': 'dapi', + 's': 'comment', + 'q': 'index', + 'json': '1', + 'post_id': postId, + ..._queryParameters, + }, + ); + return _commentsFromResponse(response.data, postId); + } + Never unavailable(String message) => throw ProviderUnavailableException(message); @@ -129,4 +146,36 @@ class GelbooruProvider implements ContentProvider { const ['sort:score:desc'], }; } + + List _commentsFromResponse(dynamic data, String postId) { + final raw = switch (data) { + List() => data, + {'comments': final List comments} => comments, + {'@attributes': _, 'comment': final List comments} => comments, + {'comment': final List comments} => comments, + _ => const [], + }; + return raw + .whereType() + .map((item) { + final json = Map.from(item); + return PostComment( + id: (json['id'] ?? json['comment_id'] ?? '').toString(), + postId: (json['post_id'] ?? postId).toString(), + providerId: id, + authorName: (json['creator'] ?? + json['creator_name'] ?? + json['author'] ?? + '') + .toString(), + body: (json['body'] ?? json['comment'] ?? '').toString(), + createdAt: DateTime.tryParse( + (json['created_at'] ?? json['created'] ?? '').toString(), + ) ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + }) + .where((comment) => comment.body.trim().isNotEmpty) + .toList(); + } } diff --git a/lib/backend/providers/provider_factory.dart b/lib/backend/providers/provider_factory.dart index 294a246..816f62b 100644 --- a/lib/backend/providers/provider_factory.dart +++ b/lib/backend/providers/provider_factory.dart @@ -20,34 +20,38 @@ class ProviderFactory { timeout: Duration(seconds: config.timeoutSeconds), headers: headers, ); - return switch (config.apiType.toLowerCase()) { - 'gelbooru' => GelbooruProvider( + switch (config.apiType.toLowerCase()) { + case 'gelbooru': + return GelbooruProvider( id: config.id, name: config.name, baseUrl: config.baseUrl, dioClient: client, queryParameters: queryParameters, - ), - 'rule34' => Rule34Provider( + ); + case 'rule34': + return Rule34Provider( id: config.id, name: config.name, baseUrl: config.baseUrl, dioClient: client, queryParameters: queryParameters, - ), - 'danbooru' => DanbooruProvider( + ); + case 'danbooru': + return DanbooruProvider( id: config.id, name: config.name, baseUrl: config.baseUrl, dioClient: client, queryParameters: queryParameters, - ), - _ => UnsupportedCustomProvider( + ); + default: + return UnsupportedCustomProvider( id: config.id, name: config.name, baseUrl: config.baseUrl, apiType: config.apiType, - ), - }; + ); + } } } diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index 91ae7bd..9c51a02 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -4,6 +4,7 @@ import '../../core/errors/failure.dart'; import '../../core/utils/result.dart'; import '../models/content_provider_config.dart'; import '../models/post.dart'; +import '../models/post_comment.dart'; import '../models/provider_health.dart'; import '../models/top_period_filter.dart'; import '../repositories/provider_repository.dart'; @@ -96,11 +97,6 @@ class ProviderManager { final posts = []; for (final provider in providers) { - final health = await _repository.getHealth(provider.id); - final offline = health is Success && - health.data?.status == ProviderStatus.offline; - if (offline) continue; - try { final providerPosts = await provider.searchPosts( tags: tags, @@ -125,6 +121,36 @@ class ProviderManager { return Success(posts); } + Future>> getComments( + String providerId, + String postId, + ) async { + final providersResult = await activeProviders(); + if (providersResult is Error>) { + return Error(providersResult.failure); + } + final providers = (providersResult as Success>).data; + final matches = providers.where((provider) => provider.id == providerId); + if (matches.isEmpty) { + return const Error( + Failure(code: 'not_found', message: 'Provider not found'), + ); + } + final provider = matches.first; + if (provider is! CommentProvider) return const Success([]); + try { + return Success(await (provider as CommentProvider).getComments(postId)); + } catch (error) { + return Error( + Failure( + code: 'comments_unavailable', + message: 'Comments unavailable', + details: error, + ), + ); + } + } + Future> getPost(String providerId, String postId) async { final providersResult = await activeProviders(); if (providersResult is Error>) { diff --git a/lib/backend/services/download_service.dart b/lib/backend/services/download_service.dart new file mode 100644 index 0000000..91bc1e4 --- /dev/null +++ b/lib/backend/services/download_service.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../models/post.dart'; + +class DownloadService { + DownloadService({Dio? dio}) : _dio = dio ?? Dio(); + + static const _channel = MethodChannel('rulegel/downloads'); + + final Dio _dio; + + Future downloadPost( + Post post, { + void Function(int received, int total)? onProgress, + }) async { + final url = _downloadUrl(post); + if (url == null) { + throw StateError('No downloadable URL for this post.'); + } + final fileName = _fileName(post, url); + if (Platform.isAndroid) { + final tempDir = await getTemporaryDirectory(); + final tempPath = p.join(tempDir.path, fileName); + await _dio.download(url, tempPath, onReceiveProgress: onProgress); + final saved = await _channel.invokeMethod('saveToDownloads', { + 'path': tempPath, + 'fileName': fileName, + 'mimeType': _mimeType(fileName, post.fileType), + }); + return saved ?? fileName; + } + + final location = await getSaveLocation(suggestedName: fileName); + if (location == null) return null; + await _dio.download(url, location.path, onReceiveProgress: onProgress); + return location.path; + } + + String? _downloadUrl(Post post) { + for (final url in [post.sampleUrl, post.fileUrl, post.previewUrl]) { + if (url.trim().isNotEmpty) return url; + } + return null; + } + + String _fileName(Post post, String url) { + final parsed = Uri.tryParse(url); + final fromUrl = parsed == null ? '' : p.basename(parsed.path); + if (fromUrl.contains('.') && fromUrl.length > 3) return fromUrl; + final extension = _extension(post.fileType); + return '${post.providerId}_${post.id}$extension'; + } + + String _extension(String fileType) { + final value = fileType.toLowerCase(); + if (value.contains('webm')) return '.webm'; + if (value.contains('mp4') || value.contains('video')) return '.mp4'; + if (value.contains('gif')) return '.gif'; + if (value.contains('png')) return '.png'; + return '.jpg'; + } + + String _mimeType(String fileName, String fileType) { + final value = '${fileName.toLowerCase()} ${fileType.toLowerCase()}'; + if (value.contains('.webm') || value.contains('webm')) return 'video/webm'; + if (value.contains('.mp4') || value.contains('mp4')) return 'video/mp4'; + if (value.contains('.gif') || value.contains('gif')) return 'image/gif'; + if (value.contains('.png') || value.contains('png')) return 'image/png'; + return 'image/jpeg'; + } +} diff --git a/lib/features/collections/presentation/collection_details_screen.dart b/lib/features/collections/presentation/collection_details_screen.dart index 2849d57..6669232 100644 --- a/lib/features/collections/presentation/collection_details_screen.dart +++ b/lib/features/collections/presentation/collection_details_screen.dart @@ -9,6 +9,7 @@ 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/presentation/favorites_controller.dart'; import 'collections_controller.dart'; class CollectionDetailsScreen extends ConsumerWidget { @@ -21,6 +22,7 @@ class CollectionDetailsScreen extends ConsumerWidget { final posts = ref.watch(collectionPostsProvider(collectionId)); final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; return AdaptiveScaffold( title: 'Collection', body: posts.when( @@ -38,12 +40,22 @@ class CollectionDetailsScreen extends ConsumerWidget { blurExplicit: settings.blurExplicitContent, showBadges: settings.showPostBadges, nsfwEnabled: settings.nsfwEnabled, + favoriteKeys: favoriteKeys, onOpen: (post) => context.push( '/post/${post.providerId}/${post.id}', extra: post, ), - onFavorite: (post) => - ref.read(favoriteServiceProvider).addFavorite(post), + onFavorite: (post) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite(post.id, post.providerId); + } else { + await ref.read(favoriteServiceProvider).addFavorite(post); + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + }, ), ), ); diff --git a/lib/features/favorites/presentation/favorites_controller.dart b/lib/features/favorites/presentation/favorites_controller.dart index 71c843c..e9345f3 100644 --- a/lib/features/favorites/presentation/favorites_controller.dart +++ b/lib/features/favorites/presentation/favorites_controller.dart @@ -9,6 +9,16 @@ final favoritesControllerProvider = FavoritesController.new, ); +final favoriteKeysProvider = FutureProvider>((ref) async { + final result = await ref.watch(favoriteServiceProvider).getFavorites(); + return result.fold( + onSuccess: (favorites) => favorites + .map((favorite) => '${favorite.providerId}:${favorite.postId}') + .toSet(), + onError: (_) => {}, + ); +}); + class FavoritesController extends AsyncNotifier { @override Future build() async => FavoritesState(posts: await _load()); @@ -17,6 +27,7 @@ class FavoritesController extends AsyncNotifier { await ref .read(favoriteServiceProvider) .removeFavorite(post.id, post.providerId); + ref.invalidate(favoriteKeysProvider); state = AsyncData(FavoritesState(posts: await _load())); } diff --git a/lib/features/favorites/presentation/favorites_screen.dart b/lib/features/favorites/presentation/favorites_screen.dart index 5339bb6..49e453e 100644 --- a/lib/features/favorites/presentation/favorites_screen.dart +++ b/lib/features/favorites/presentation/favorites_screen.dart @@ -36,6 +36,7 @@ class FavoritesScreen extends ConsumerWidget { blurExplicit: settings.blurExplicitContent, showBadges: settings.showPostBadges, nsfwEnabled: settings.nsfwEnabled, + favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(), onOpen: (post) => context.push( '/post/${post.providerId}/${post.id}', extra: post, diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index bb7e70f..121a7aa 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -13,6 +13,7 @@ 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 '../../favorites/presentation/favorites_controller.dart'; import 'feed_controller.dart'; import 'widgets/feed_toolbar.dart'; @@ -50,6 +51,7 @@ class _FeedScreenState extends ConsumerState { final feed = ref.watch(feedControllerProvider); final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) { _usedInitialQuery = true; @@ -162,13 +164,13 @@ class _FeedScreenState extends ConsumerState { showBadges: settings.showPostBadges, nsfwEnabled: settings.nsfwEnabled, loading: state.isLoadingMore, + favoriteKeys: favoriteKeys, onOpen: (post) => context.push( '/post/${post.providerId}/${post.id}', extra: post, ), - onFavorite: (post) => ref - .read(favoriteServiceProvider) - .addFavorite(post), + onFavorite: (post) => + _toggleFavorite(ref, post, favoriteKeys), onAddToCollection: (post) => _addToCollection(context, ref, post), ), @@ -202,6 +204,22 @@ class _FeedScreenState extends ConsumerState { onCreate: () => showCollectionFormDialog(context, ref), ); } + + Future _toggleFavorite( + WidgetRef ref, + Post post, + Set favoriteKeys, + ) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite(post.id, post.providerId); + } else { + await ref.read(favoriteServiceProvider).addFavorite(post); + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + } } class _RefreshIntent extends Intent { diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index b515374..47c359b 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -1,10 +1,7 @@ -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'; @@ -17,12 +14,22 @@ 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 '../../favorites/presentation/favorites_controller.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'; +final postCommentsProvider = + FutureProvider.family, PostDetailsArgs>( + (ref, args) async { + final result = await ref + .watch(providerManagerProvider) + .getComments(args.providerId, args.postId); + return result is Success> ? result.data : const []; +}); + class PostDetailsScreen extends ConsumerWidget { const PostDetailsScreen({ required this.providerId, @@ -47,6 +54,7 @@ class PostDetailsScreen extends ConsumerWidget { ref.watch(appSettingsProvider).value ?? AppSettings.defaults; final feedPosts = ref.watch(feedControllerProvider).value?.posts ?? const []; + final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; return AdaptiveScaffold( title: 'Post', actions: [ @@ -70,7 +78,28 @@ class PostDetailsScreen extends ConsumerWidget { ? feedPosts[currentIndex + 1] : null; if (Responsive.isMobile(context)) { - return _buildMobileDetails(context, ref, post, settings); + if (currentIndex >= 0 && feedPosts.length > 1) { + return PageView.builder( + controller: PageController(initialPage: currentIndex), + itemCount: feedPosts.length, + onPageChanged: (index) => + _replacePost(context, feedPosts[index]), + itemBuilder: (context, index) => _buildMobileDetails( + context, + ref, + feedPosts[index], + settings, + favoriteKeys, + ), + ); + } + return _buildMobileDetails( + context, + ref, + post, + settings, + favoriteKeys, + ); } return ListView( padding: const EdgeInsets.all(16), @@ -103,14 +132,14 @@ class PostDetailsScreen extends ConsumerWidget { ), const SizedBox(height: 16), PostActionBar( - onFavorite: () => - ref.read(favoriteServiceProvider).addFavorite(post), + isFavorite: favoriteKeys.contains(post.cacheKey), + onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), onCollection: () => _addToCollection(context, ref, post), onOpen: () => launchUrl(Uri.parse(post.fileUrl)), onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)), onDownload: settings.allowDownloads - ? () => _download(context, post) + ? () => _download(context, ref, post) : null, ), const SizedBox(height: 16), @@ -133,6 +162,8 @@ class PostDetailsScreen extends ConsumerWidget { Text('Tags', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), PostTagsPanel(post: post), + const SizedBox(height: 16), + _CommentsSection(post: post), ], ); }, @@ -145,6 +176,7 @@ class PostDetailsScreen extends ConsumerWidget { WidgetRef ref, Post post, AppSettings settings, + Set favoriteKeys, ) { return ListView( padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), @@ -157,12 +189,14 @@ class PostDetailsScreen extends ConsumerWidget { ), const SizedBox(height: 12), PostActionBar( - onFavorite: () => ref.read(favoriteServiceProvider).addFavorite(post), + isFavorite: favoriteKeys.contains(post.cacheKey), + onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), 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, + onDownload: settings.allowDownloads + ? () => _download(context, ref, post) + : null, ), const SizedBox(height: 12), Wrap( @@ -191,6 +225,7 @@ class PostDetailsScreen extends ConsumerWidget { ), ], ), + _CommentsSection(post: post), ], ); } @@ -216,23 +251,47 @@ class PostDetailsScreen extends ConsumerWidget { ); } - Future _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; + Future _download(BuildContext context, WidgetRef ref, Post post) async { 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')), - ); + try { + final saved = await ref.read(downloadServiceProvider).downloadPost(post); + if (!context.mounted || saved == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download complete: $saved')), + ); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download failed: $error')), + ); + } + } + + Future _toggleFavorite( + WidgetRef ref, + Post post, + Set favoriteKeys, + ) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite(post.id, post.providerId); + } else { + await ref.read(favoriteServiceProvider).addFavorite(post); + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); } void _openPost(BuildContext context, Post post) { - context.go('/post/${post.providerId}/${post.id}', extra: post); + _replacePost(context, post); + } + + void _replacePost(BuildContext context, Post post) { + context.replace('/post/${post.providerId}/${post.id}', extra: post); } void _close(BuildContext context) { @@ -243,3 +302,77 @@ class PostDetailsScreen extends ConsumerWidget { context.go('/'); } } + +class _CommentsSection extends ConsumerStatefulWidget { + const _CommentsSection({required this.post}); + + final Post post; + + @override + ConsumerState<_CommentsSection> createState() => _CommentsSectionState(); +} + +class _CommentsSectionState extends ConsumerState<_CommentsSection> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final comments = _expanded + ? ref.watch( + postCommentsProvider( + PostDetailsArgs( + providerId: widget.post.providerId, + postId: widget.post.id, + ), + ), + ) + : null; + return ExpansionTile( + tilePadding: EdgeInsets.zero, + initiallyExpanded: false, + onExpansionChanged: (value) => setState(() => _expanded = value), + title: Text('Comments', style: Theme.of(context).textTheme.titleMedium), + children: [ + (comments ?? const AsyncValue>.data([])).when( + loading: () => const Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(), + ), + error: (_, __) => const Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text('Comments unavailable'), + ), + ), + data: (items) { + if (items.isEmpty) { + return const Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text('Comments unavailable'), + ), + ); + } + return Column( + children: [ + for (final comment in items) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + comment.authorName.isEmpty + ? 'Anonymous' + : comment.authorName, + ), + subtitle: Text(comment.body), + ), + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/post/presentation/widgets/post_action_bar.dart b/lib/features/post/presentation/widgets/post_action_bar.dart index 3bfc112..ed288bd 100644 --- a/lib/features/post/presentation/widgets/post_action_bar.dart +++ b/lib/features/post/presentation/widgets/post_action_bar.dart @@ -6,6 +6,7 @@ class PostActionBar extends StatelessWidget { required this.onCollection, required this.onOpen, required this.onCopy, + required this.isFavorite, this.onDownload, super.key, }); @@ -14,6 +15,7 @@ class PostActionBar extends StatelessWidget { final VoidCallback onCollection; final VoidCallback onOpen; final VoidCallback onCopy; + final bool isFavorite; final VoidCallback? onDownload; @override @@ -24,8 +26,10 @@ class PostActionBar extends StatelessWidget { children: [ FilledButton.icon( onPressed: onFavorite, - icon: const Icon(Icons.favorite_rounded), - label: const Text('Favorite'), + icon: Icon( + isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded, + ), + label: Text(isFavorite ? 'Unfavorite' : 'Favorite'), ), FilledButton.tonalIcon( onPressed: onCollection, diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index b64fb14..6fe101a 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -11,6 +11,7 @@ class PostCard extends StatefulWidget { required this.post, required this.blurExplicit, required this.showBadges, + required this.isFavorite, required this.onOpen, required this.onFavorite, this.onAddToCollection, @@ -20,6 +21,7 @@ class PostCard extends StatefulWidget { final Post post; final bool blurExplicit; final bool showBadges; + final bool isFavorite; final VoidCallback onOpen; final VoidCallback onFavorite; final VoidCallback? onAddToCollection; @@ -37,6 +39,7 @@ class _PostCardState extends State { final sensitive = _isSensitive(post.rating); final aspect = post.width > 0 && post.height > 0 ? post.width / post.height : 0.72; + final mobile = MediaQuery.sizeOf(context).width < 700; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), @@ -108,11 +111,20 @@ class _PostCardState extends State { child: _MediaBadge(fileType: post.fileType), ), ], + if (mobile) + Positioned( + right: 8, + bottom: 8, + child: _FavoriteButton( + isFavorite: widget.isFavorite, + onPressed: widget.onFavorite, + ), + ), Positioned.fill( child: IgnorePointer( - ignoring: !_hovered, + ignoring: !_hovered || mobile, child: AnimatedOpacity( - opacity: _hovered ? 1 : 0, + opacity: _hovered && !mobile ? 1 : 0, duration: const Duration(milliseconds: 140), child: DecoratedBox( decoration: BoxDecoration( @@ -133,9 +145,15 @@ class _PostCardState extends State { mainAxisSize: MainAxisSize.min, children: [ IconButton.filledTonal( - tooltip: 'Favorite', + tooltip: widget.isFavorite + ? 'Remove favorite' + : 'Favorite', onPressed: widget.onFavorite, - icon: const Icon(Icons.favorite_rounded), + icon: Icon( + widget.isFavorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + ), ), const SizedBox(width: 6), IconButton.filledTonal( @@ -167,6 +185,29 @@ class _PostCardState extends State { } } +class _FavoriteButton extends StatelessWidget { + const _FavoriteButton({ + required this.isFavorite, + required this.onPressed, + }); + + final bool isFavorite; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton.filledTonal( + visualDensity: VisualDensity.compact, + tooltip: isFavorite ? 'Remove favorite' : 'Favorite', + onPressed: onPressed, + icon: Icon( + isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded, + size: 18, + ), + ); + } +} + class _ProviderBadge extends StatelessWidget { const _ProviderBadge({required this.name}); diff --git a/lib/shared/widgets/post_masonry_grid.dart b/lib/shared/widgets/post_masonry_grid.dart index c5296c5..b094453 100644 --- a/lib/shared/widgets/post_masonry_grid.dart +++ b/lib/shared/widgets/post_masonry_grid.dart @@ -16,6 +16,7 @@ class PostMasonryGrid extends StatelessWidget { required this.onOpen, required this.onFavorite, this.onAddToCollection, + this.favoriteKeys = const {}, this.loading = false, this.controller, super.key, @@ -29,6 +30,7 @@ class PostMasonryGrid extends StatelessWidget { final ValueChanged onOpen; final ValueChanged onFavorite; final ValueChanged? onAddToCollection; + final Set favoriteKeys; final bool loading; final ScrollController? controller; @@ -49,6 +51,7 @@ class PostMasonryGrid extends StatelessWidget { post: post, blurExplicit: blurExplicit && !nsfwEnabled, showBadges: showBadges, + isFavorite: favoriteKeys.contains(post.cacheKey), onOpen: () => onOpen(post), onFavorite: () => onFavorite(post), onAddToCollection: diff --git a/test/backend/provider_manager_test.dart b/test/backend/provider_manager_test.dart index e9dff89..2015bb3 100644 --- a/test/backend/provider_manager_test.dart +++ b/test/backend/provider_manager_test.dart @@ -155,7 +155,7 @@ void main() { expect(result.data.map((provider) => provider.id), ['a', 'b']); }); - test('offline provider is skipped and partial failures do not break feed', + test('saved offline health does not block enabled provider retry', () async { final repository = FakeProviderRepository() ..configs['a'] = config('a', 0) @@ -178,7 +178,7 @@ void main() { final result = await manager.searchAcrossProviders(tags: [], page: 0) as Success>; - expect(result.data.map((item) => item.providerId), ['a']); + expect(result.data.map((item) => item.providerId), ['a', 'b']); expect(repository.health['c']?.status, ProviderStatus.offline); }); diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 48e372f..a843b3b 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "gel_rule_app" "\0" + VALUE "FileDescription", "RuleGel" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "gel_rule_app" "\0" + VALUE "InternalName", "RuleGel" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" VALUE "OriginalFilename", "gel_rule_app.exe" "\0" - VALUE "ProductName", "gel_rule_app" "\0" + VALUE "ProductName", "RuleGel" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END