diff --git a/lib/app/app.dart b/lib/app/app.dart index 87e135c..fd66dd4 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -36,11 +36,121 @@ class GelRuleApp extends ConsumerWidget { orElse: () => ThemeMode.dark, ), routerConfig: router, + builder: (context, child) => _DownloadOverlay(child: child), ), ); } } +class _DownloadOverlay extends ConsumerWidget { + const _DownloadOverlay({required this.child}); + + final Widget? child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tasks = ref.watch(downloadTasksProvider).value ?? const []; + final visible = tasks + .where((task) => + task.status == DownloadTaskStatus.running || + task.status == DownloadTaskStatus.queued || + task.status == DownloadTaskStatus.failed || + task.status == DownloadTaskStatus.completed) + .toList(); + return Stack( + children: [ + if (child != null) child!, + if (visible.isNotEmpty) + Positioned( + right: 16, + bottom: 16, + child: SafeArea( + child: _DownloadPanel(tasks: visible.take(3).toList()), + ), + ), + ], + ); + } +} + +class _DownloadPanel extends ConsumerWidget { + const _DownloadPanel({required this.tasks}); + + final List tasks; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + return Material( + elevation: 12, + borderRadius: BorderRadius.circular(16), + color: scheme.surface, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final task in tasks) ...[ + Row( + children: [ + Icon(_icon(task.status), size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + task.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (task.status == DownloadTaskStatus.failed) + IconButton( + tooltip: 'Retry', + onPressed: () => ref + .read(downloadManagerServiceProvider) + .retry(task.id), + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + const SizedBox(height: 6), + LinearProgressIndicator( + value: task.status == DownloadTaskStatus.running + ? task.progress.clamp(0, 1) + : task.status == DownloadTaskStatus.completed + ? 1 + : null, + ), + if (task.error != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + task.error!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: scheme.error), + ), + ), + if (task != tasks.last) const SizedBox(height: 10), + ], + ], + ), + ), + ), + ); + } + + IconData _icon(DownloadTaskStatus status) { + return switch (status) { + DownloadTaskStatus.completed => Icons.download_done_rounded, + DownloadTaskStatus.failed => Icons.error_outline_rounded, + DownloadTaskStatus.canceled => Icons.cancel_rounded, + _ => Icons.download_rounded, + }; + } +} + final appSettingsProvider = FutureProvider((ref) { return ref.watch(settingsServiceProvider).getSettings().then( (result) => result.fold( diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index c7bda10..bcfe531 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -1,6 +1,7 @@ export 'di/backend_providers.dart'; export 'models/collection.dart'; export 'models/content_provider_config.dart'; +export 'models/download_task.dart'; export 'models/favorite.dart'; export 'models/post.dart'; export 'models/post_comment.dart'; @@ -14,6 +15,7 @@ export 'providers/content_provider.dart'; export 'providers/provider_manager.dart'; export 'services/collection_service.dart'; export 'services/download_service.dart'; +export 'services/download_manager_service.dart'; export 'services/favorite_service.dart'; export 'services/feed_service.dart'; export 'services/provider_check_service.dart'; @@ -21,3 +23,4 @@ export 'services/search_service.dart'; export 'services/settings_service.dart'; export 'services/viewed_history_service.dart'; export 'utils/smart_blacklist.dart'; +export 'utils/media_quality.dart'; diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index d84419a..573a234 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -9,6 +9,7 @@ import '../../core/http/network_info.dart'; import '../../core/utils/result.dart'; import '../providers/provider_factory.dart'; import '../providers/provider_manager.dart'; +import '../models/download_task.dart'; import '../repositories/collection_repository.dart'; import '../repositories/favorite_repository.dart'; import '../repositories/post_repository.dart'; @@ -17,6 +18,7 @@ import '../repositories/search_repository.dart'; import '../repositories/viewed_post_repository.dart'; import '../services/collection_service.dart'; import '../services/download_service.dart'; +import '../services/download_manager_service.dart'; import '../services/favorite_service.dart'; import '../services/feed_service.dart'; import '../services/provider_check_service.dart'; @@ -147,3 +149,12 @@ final settingsServiceProvider = Provider((ref) { final downloadServiceProvider = Provider((ref) { return DownloadService(); }); + +final downloadManagerServiceProvider = Provider((ref) { + return DownloadManagerService(ref.watch(downloadServiceProvider)); +}); + +final downloadTasksProvider = StreamProvider>((ref) { + final manager = ref.watch(downloadManagerServiceProvider); + return manager.stream; +}); diff --git a/lib/backend/models/download_task.dart b/lib/backend/models/download_task.dart new file mode 100644 index 0000000..98e3907 --- /dev/null +++ b/lib/backend/models/download_task.dart @@ -0,0 +1,46 @@ +import 'post.dart'; + +enum DownloadTaskStatus { + queued, + running, + completed, + failed, + canceled, +} + +class DownloadTask { + const DownloadTask({ + required this.id, + required this.post, + required this.fileName, + required this.progress, + required this.status, + this.savedPath, + this.error, + }); + + final String id; + final Post post; + final String fileName; + final double progress; + final DownloadTaskStatus status; + final String? savedPath; + final String? error; + + DownloadTask copyWith({ + double? progress, + DownloadTaskStatus? status, + String? savedPath, + String? error, + }) { + return DownloadTask( + id: id, + post: post, + fileName: fileName, + progress: progress ?? this.progress, + status: status ?? this.status, + savedPath: savedPath ?? this.savedPath, + error: error, + ); + } +} diff --git a/lib/backend/providers/danbooru_provider.dart b/lib/backend/providers/danbooru_provider.dart index 39c122b..a9daf6e 100644 --- a/lib/backend/providers/danbooru_provider.dart +++ b/lib/backend/providers/danbooru_provider.dart @@ -147,10 +147,24 @@ class DanbooruProvider implements ContentProvider, CommentProvider { } List _topTags(TopPeriodFilter period) { + final now = DateTime.now(); return switch (period) { TopPeriodFilter.none => const [], - TopPeriodFilter.month || TopPeriodFilter.year => const ['order:rank'], + TopPeriodFilter.month => [ + 'order:score', + 'date:>${_date(now.subtract(const Duration(days: 31)))}', + ], + TopPeriodFilter.year => [ + 'order:score', + 'date:>${_date(DateTime(now.year - 1, now.month, now.day))}', + ], TopPeriodFilter.allTime => const ['order:score'], }; } + + String _date(DateTime value) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + } } diff --git a/lib/backend/providers/e621_provider.dart b/lib/backend/providers/e621_provider.dart index bad41b9..f1f9303 100644 --- a/lib/backend/providers/e621_provider.dart +++ b/lib/backend/providers/e621_provider.dart @@ -168,13 +168,27 @@ class E621Provider } List _topTags(TopPeriodFilter period) { + final now = DateTime.now(); return switch (period) { TopPeriodFilter.none => const [], - TopPeriodFilter.month => const ['order:rank'], - TopPeriodFilter.year || TopPeriodFilter.allTime => const ['order:score'], + TopPeriodFilter.month => [ + 'order:score', + 'date:>${_date(now.subtract(const Duration(days: 31)))}', + ], + TopPeriodFilter.year => [ + 'order:score', + 'date:>${_date(DateTime(now.year - 1, now.month, now.day))}', + ], + TopPeriodFilter.allTime => const ['order:score'], }; } + String _date(DateTime value) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + } + int _int(dynamic value) { if (value is int) return value; if (value is num) return value.toInt(); diff --git a/lib/backend/providers/gelbooru_provider.dart b/lib/backend/providers/gelbooru_provider.dart index 35e3a84..327dfe2 100644 --- a/lib/backend/providers/gelbooru_provider.dart +++ b/lib/backend/providers/gelbooru_provider.dart @@ -140,10 +140,8 @@ class GelbooruProvider implements ContentProvider, CommentProvider { List _topTags(TopPeriodFilter period) { return switch (period) { TopPeriodFilter.none => const [], - TopPeriodFilter.month || - TopPeriodFilter.year || - TopPeriodFilter.allTime => - const ['sort:score:desc'], + TopPeriodFilter.month || TopPeriodFilter.year => const [], + TopPeriodFilter.allTime => const ['sort:score:desc'], }; } diff --git a/lib/backend/providers/moebooru_provider.dart b/lib/backend/providers/moebooru_provider.dart index 96bdf5e..b06f2f5 100644 --- a/lib/backend/providers/moebooru_provider.dart +++ b/lib/backend/providers/moebooru_provider.dart @@ -166,10 +166,8 @@ class MoebooruProvider List _topTags(TopPeriodFilter period) { return switch (period) { TopPeriodFilter.none => const [], - TopPeriodFilter.month || - TopPeriodFilter.year || - TopPeriodFilter.allTime => - const ['order:score'], + TopPeriodFilter.month || TopPeriodFilter.year => const [], + TopPeriodFilter.allTime => const ['order:score'], }; } diff --git a/lib/backend/providers/rule34_provider.dart b/lib/backend/providers/rule34_provider.dart index 5405948..434128d 100644 --- a/lib/backend/providers/rule34_provider.dart +++ b/lib/backend/providers/rule34_provider.dart @@ -22,10 +22,8 @@ class Rule34Provider extends GelbooruProvider { }) async { final topTags = switch (topPeriod) { TopPeriodFilter.none => const [], - TopPeriodFilter.month || - TopPeriodFilter.year || - TopPeriodFilter.allTime => - const ['sort:score:desc'], + TopPeriodFilter.month || TopPeriodFilter.year => const [], + TopPeriodFilter.allTime => const ['sort:score:desc'], }; final response = await dio.get( '/index.php', diff --git a/lib/backend/repositories/collection_repository.dart b/lib/backend/repositories/collection_repository.dart index 686f782..5db2a94 100644 --- a/lib/backend/repositories/collection_repository.dart +++ b/lib/backend/repositories/collection_repository.dart @@ -70,6 +70,23 @@ class CollectionRepository { }); } + Future> addPosts(String collectionId, List posts) async { + if (posts.isEmpty) return const Success(null); + await _postRepository.cachePosts(posts); + return _databaseService.safeWrite((isar) async { + final now = DateTime.now(); + await isar.collectionPostEntitys.putAll([ + for (final post in posts) + CollectionPostEntity() + ..linkKey = '$collectionId:${post.providerId}:${post.id}' + ..collectionId = collectionId + ..postId = post.id + ..providerId = post.providerId + ..addedAt = now, + ]); + }); + } + Future> removePost( String collectionId, String postId, diff --git a/lib/backend/services/collection_service.dart b/lib/backend/services/collection_service.dart index 87895b4..532efa1 100644 --- a/lib/backend/services/collection_service.dart +++ b/lib/backend/services/collection_service.dart @@ -56,6 +56,13 @@ class CollectionService { return _repository.addPost(collectionId, post); } + Future> addPostsToCollection( + String collectionId, + List posts, + ) { + return _repository.addPosts(collectionId, posts); + } + Future> removePostFromCollection( String collectionId, String postId, diff --git a/lib/backend/services/download_manager_service.dart b/lib/backend/services/download_manager_service.dart new file mode 100644 index 0000000..0aa2040 --- /dev/null +++ b/lib/backend/services/download_manager_service.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import '../models/download_task.dart'; +import '../models/post.dart'; +import '../utils/media_quality.dart'; +import 'download_service.dart'; + +class DownloadManagerService { + DownloadManagerService(this._downloadService); + + final DownloadService _downloadService; + final _controller = StreamController>.broadcast(); + final Map _tasks = {}; + + Stream> get stream => _controller.stream; + List get tasks => List.unmodifiable(_tasks.values); + + Future start(Post post) async { + final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}'; + final task = DownloadTask( + id: id, + post: post, + fileName: _suggestedFileName(post), + progress: 0, + status: DownloadTaskStatus.queued, + ); + _set(task); + unawaited(_run(task)); + return task; + } + + Future retry(String taskId) async { + final existing = _tasks[taskId]; + if (existing == null) return; + final task = existing.copyWith( + progress: 0, + status: DownloadTaskStatus.queued, + error: null, + ); + _set(task); + await _run(task); + } + + void cancel(String taskId) { + final existing = _tasks[taskId]; + if (existing == null) return; + _set(existing.copyWith(status: DownloadTaskStatus.canceled)); + } + + Future _run(DownloadTask task) async { + if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return; + _set(task.copyWith(status: DownloadTaskStatus.running)); + try { + final saved = await _downloadService.downloadPost( + task.post, + onProgress: (received, total) { + if (total <= 0) return; + final current = _tasks[task.id]; + if (current == null || + current.status == DownloadTaskStatus.canceled) { + return; + } + _set(current.copyWith(progress: received / total)); + }, + ); + final current = _tasks[task.id]; + if (current == null || current.status == DownloadTaskStatus.canceled) { + return; + } + _set( + current.copyWith( + progress: 1, + status: DownloadTaskStatus.completed, + savedPath: saved, + ), + ); + _scheduleAutoRemove(task.id, const Duration(seconds: 6)); + } catch (error) { + final current = _tasks[task.id]; + if (current == null || current.status == DownloadTaskStatus.canceled) { + return; + } + _set( + current.copyWith( + status: DownloadTaskStatus.failed, + error: error.toString(), + ), + ); + } + } + + void _set(DownloadTask task) { + _tasks[task.id] = task; + _controller.add(tasks); + } + + void _scheduleAutoRemove(String taskId, Duration delay) { + Future.delayed(delay, () { + final current = _tasks[taskId]; + if (current?.status != DownloadTaskStatus.completed) return; + _tasks.remove(taskId); + _controller.add(tasks); + }); + } + + String _suggestedFileName(Post post) { + final url = MediaUrlSelector.download(post) ?? post.fileUrl; + final parsed = Uri.tryParse(url); + final last = + parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last; + if (last.contains('.')) return last; + return '${post.providerId}_${post.id}'; + } +} diff --git a/lib/backend/services/download_service.dart b/lib/backend/services/download_service.dart index 91bc1e4..f338372 100644 --- a/lib/backend/services/download_service.dart +++ b/lib/backend/services/download_service.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../models/post.dart'; +import '../utils/media_quality.dart'; class DownloadService { DownloadService({Dio? dio}) : _dio = dio ?? Dio(); @@ -43,10 +44,7 @@ class DownloadService { } String? _downloadUrl(Post post) { - for (final url in [post.sampleUrl, post.fileUrl, post.previewUrl]) { - if (url.trim().isNotEmpty) return url; - } - return null; + return MediaUrlSelector.download(post); } String _fileName(Post post, String url) { diff --git a/lib/backend/services/feed_service.dart b/lib/backend/services/feed_service.dart index c2eb8f7..4d71431 100644 --- a/lib/backend/services/feed_service.dart +++ b/lib/backend/services/feed_service.dart @@ -78,7 +78,7 @@ class FeedService { final viewedKeys = viewedKeysResult is Success> ? viewedKeysResult.data : {}; - final filteredPosts = posts + final filteredPosts = _applyTopPeriod(posts, topPeriod) .where((post) => postMatchesRequestedTags(post, tags)) .where((post) => postPassesTagFilters(post, settings)) .where((post) => @@ -90,6 +90,25 @@ class FeedService { ); return Success(filteredPosts); } + + List _applyTopPeriod(List posts, TopPeriodFilter period) { + if (period == TopPeriodFilter.none) return posts; + + final sorted = [...posts]..sort((a, b) => b.score.compareTo(a.score)); + if (period == TopPeriodFilter.allTime) return sorted; + + final now = DateTime.now(); + final minDate = period == TopPeriodFilter.month + ? now.subtract(const Duration(days: 31)) + : DateTime(now.year - 1, now.month, now.day); + final inPeriod = sorted + .where((post) => + post.createdAt.isAfter(minDate) && + post.createdAt.isBefore(now.add(const Duration(days: 1)))) + .toList(growable: false); + + return inPeriod.isEmpty ? sorted : inPeriod; + } } bool postMatchesRequestedTags(Post post, List requestedTags) { diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 944a85a..1fabd2d 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -28,7 +28,13 @@ class AppSettings { required this.whitelistedTags, required this.smartBlacklistRules, required this.hideViewedPosts, + required this.mediaQualityMode, + required this.lastFeedTags, + required this.lastFeedProviderIds, + required this.lastFeedTopPeriod, + required this.lastFeedScrollOffset, this.defaultRatingFilter, + this.lastFeedRating, }); final List enabledProviderIds; @@ -48,7 +54,13 @@ class AppSettings { final List whitelistedTags; final List smartBlacklistRules; final bool hideViewedPosts; + final String mediaQualityMode; + final List lastFeedTags; + final List lastFeedProviderIds; + final String lastFeedTopPeriod; + final double lastFeedScrollOffset; final String? defaultRatingFilter; + final String? lastFeedRating; static const defaults = AppSettings( enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'], @@ -68,7 +80,13 @@ class AppSettings { whitelistedTags: [], smartBlacklistRules: [], hideViewedPosts: false, + mediaQualityMode: 'auto', + lastFeedTags: [], + lastFeedProviderIds: [], + lastFeedTopPeriod: 'none', + lastFeedScrollOffset: 0, defaultRatingFilter: null, + lastFeedRating: null, ); AppSettings copyWith({ @@ -89,7 +107,14 @@ class AppSettings { List? whitelistedTags, List? smartBlacklistRules, bool? hideViewedPosts, + String? mediaQualityMode, + List? lastFeedTags, + List? lastFeedProviderIds, + String? lastFeedTopPeriod, + double? lastFeedScrollOffset, String? defaultRatingFilter, + String? lastFeedRating, + bool clearLastFeedRating = false, }) { return AppSettings( enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds, @@ -111,7 +136,14 @@ class AppSettings { whitelistedTags: whitelistedTags ?? this.whitelistedTags, smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules, hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts, + mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode, + lastFeedTags: lastFeedTags ?? this.lastFeedTags, + lastFeedProviderIds: lastFeedProviderIds ?? this.lastFeedProviderIds, + lastFeedTopPeriod: lastFeedTopPeriod ?? this.lastFeedTopPeriod, + lastFeedScrollOffset: lastFeedScrollOffset ?? this.lastFeedScrollOffset, defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter, + lastFeedRating: + clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating, ); } @@ -133,7 +165,13 @@ class AppSettings { 'whitelistedTags': whitelistedTags, 'smartBlacklistRules': smartBlacklistRules, 'hideViewedPosts': hideViewedPosts, + 'mediaQualityMode': mediaQualityMode, + 'lastFeedTags': lastFeedTags, + 'lastFeedProviderIds': lastFeedProviderIds, + 'lastFeedTopPeriod': lastFeedTopPeriod, + 'lastFeedScrollOffset': lastFeedScrollOffset, 'defaultRatingFilter': defaultRatingFilter, + 'lastFeedRating': lastFeedRating, }; factory AppSettings.fromJson(Map json) => AppSettings( @@ -178,7 +216,24 @@ class AppSettings { ), hideViewedPosts: (json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts, + mediaQualityMode: + (json['mediaQualityMode'] as String?) ?? defaults.mediaQualityMode, + lastFeedTags: List.from( + (json['lastFeedTags'] as List?) ?? defaults.lastFeedTags, + ), + lastFeedProviderIds: List.from( + (json['lastFeedProviderIds'] as List?) ?? + (json['selectedFeedProviderIds'] as List?) ?? + defaults.lastFeedProviderIds, + ), + lastFeedTopPeriod: (json['lastFeedTopPeriod'] as String?) ?? + (json['defaultTopPeriodFilter'] as String?) ?? + defaults.lastFeedTopPeriod, + lastFeedScrollOffset: + (json['lastFeedScrollOffset'] as num?)?.toDouble() ?? + defaults.lastFeedScrollOffset, defaultRatingFilter: json['defaultRatingFilter'] as String?, + lastFeedRating: json['lastFeedRating'] as String?, ); } diff --git a/lib/backend/utils/media_quality.dart b/lib/backend/utils/media_quality.dart new file mode 100644 index 0000000..579cded --- /dev/null +++ b/lib/backend/utils/media_quality.dart @@ -0,0 +1,102 @@ +import '../models/post.dart'; + +enum MediaQualityMode { + auto, + dataSaver, + highQuality; + + static MediaQualityMode fromName(String value) { + return MediaQualityMode.values.firstWhere( + (mode) => mode.name == value, + orElse: () => MediaQualityMode.auto, + ); + } + + String get label { + return switch (this) { + MediaQualityMode.auto => 'Auto', + MediaQualityMode.dataSaver => 'Data saver', + MediaQualityMode.highQuality => 'High quality', + }; + } +} + +class MediaUrlSelector { + const MediaUrlSelector._(); + + static List feed(Post post) { + return _compact([post.previewUrl, post.sampleUrl]); + } + + static List preview(Post post) { + return _compact([post.sampleUrl, post.previewUrl, post.fileUrl]); + } + + static List details( + Post post, { + MediaQualityMode mode = MediaQualityMode.auto, + }) { + if (_isVideo(post)) { + return _compact([post.fileUrl, post.sampleUrl, post.previewUrl]); + } + if (_isGif(post)) { + return switch (mode) { + MediaQualityMode.dataSaver => + _compact([post.sampleUrl, post.previewUrl, post.fileUrl]), + _ => _compact([post.fileUrl, post.sampleUrl, post.previewUrl]), + }; + } + return switch (mode) { + MediaQualityMode.dataSaver => + _compact([post.sampleUrl, post.previewUrl, post.fileUrl]), + MediaQualityMode.highQuality => + _compact([post.fileUrl, post.sampleUrl, post.previewUrl]), + MediaQualityMode.auto => + _compact([post.sampleUrl, post.fileUrl, post.previewUrl]), + }; + } + + static String? download(Post post) { + return _compact([post.fileUrl, post.sampleUrl, post.previewUrl]) + .firstOrNull; + } + + static List video(Post post) { + return _compact([ + post.fileUrl, + if (_looksLikeVideoUrl(post.sampleUrl)) post.sampleUrl, + if (_looksLikeVideoUrl(post.previewUrl)) post.previewUrl, + ]); + } + + static bool isVideo(Post post) => _isVideo(post); + static bool isGif(Post post) => _isGif(post); + + static List _compact(Iterable urls) { + final seen = {}; + return urls + .map((url) => url.trim()) + .where((url) => url.isNotEmpty && seen.add(url)) + .toList(growable: false); + } + + static bool _isVideo(Post post) { + final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); + return value.contains('video') || + value.contains('.webm') || + value.contains('.mp4') || + value.contains('.mov'); + } + + static bool _isGif(Post post) { + final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); + return value.contains('gif') || value.contains('.gif'); + } + + static bool _looksLikeVideoUrl(String url) { + final value = url.toLowerCase(); + return value.contains('.webm') || + value.contains('.mp4') || + value.contains('.mov'); + } +} diff --git a/lib/features/feed/presentation/feed_controller.dart b/lib/features/feed/presentation/feed_controller.dart index 264faf3..42487b5 100644 --- a/lib/features/feed/presentation/feed_controller.dart +++ b/lib/features/feed/presentation/feed_controller.dart @@ -15,13 +15,14 @@ class FeedController extends AsyncNotifier { 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, + (value) => value.name == settings.lastFeedTopPeriod, orElse: () => TopPeriodFilter.none, ), + selectedTags: settings.lastFeedTags, selectedProviderIds: - settings.selectedFeedProviderIds.where(providerIds.contains).toList(), + settings.lastFeedProviderIds.where(providerIds.contains).toList(), + ratingFilter: settings.lastFeedRating ?? settings.defaultRatingFilter, ); state = AsyncData(initial); await loadInitial(); @@ -66,6 +67,7 @@ class FeedController extends AsyncNotifier { state = AsyncData( current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []), ); + await saveSession(scrollOffset: 0); await ref.read(searchServiceProvider).saveSearch(query, 0); await refresh(); final count = state.value?.posts.length ?? 0; @@ -99,7 +101,11 @@ class FeedController extends AsyncNotifier { current.copyWith(selectedProviderIds: providerIds, posts: [])); final settings = await _settings(); await ref.read(settingsServiceProvider).updateSettings( - settings.copyWith(selectedFeedProviderIds: providerIds)); + settings.copyWith( + selectedFeedProviderIds: providerIds, + lastFeedProviderIds: providerIds, + ), + ); await refresh(); } @@ -110,6 +116,7 @@ class FeedController extends AsyncNotifier { await ref.read(settingsServiceProvider).updateSettings( settings.copyWith(defaultTopPeriodFilter: period.name), ); + await saveSession(scrollOffset: 0); await refresh(); } @@ -120,6 +127,7 @@ class FeedController extends AsyncNotifier { ratingFilter: rating, clearRating: rating == null, posts: []), ); await refresh(); + await saveSession(scrollOffset: 0); } Future clearFilters() async { @@ -134,12 +142,32 @@ class FeedController extends AsyncNotifier { ), ); final settings = await _settings(); - await ref - .read(settingsServiceProvider) - .updateSettings(settings.copyWith(selectedFeedProviderIds: [])); + await ref.read(settingsServiceProvider).updateSettings(settings.copyWith( + selectedFeedProviderIds: [], + lastFeedTags: [], + lastFeedProviderIds: [], + clearLastFeedRating: true, + lastFeedTopPeriod: TopPeriodFilter.none.name, + lastFeedScrollOffset: 0, + )); await refresh(); } + Future saveSession({double? scrollOffset}) async { + final current = state.value; + if (current == null) return; + final settings = await _settings(); + await ref.read(settingsServiceProvider).updateSettings( + settings.copyWith( + lastFeedTags: current.selectedTags, + lastFeedProviderIds: current.selectedProviderIds, + lastFeedRating: current.ratingFilter, + lastFeedTopPeriod: current.topPeriodFilter.name, + lastFeedScrollOffset: scrollOffset ?? settings.lastFeedScrollOffset, + ), + ); + } + Future> _load({ required bool refresh, required FeedState current, diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index bd5d828..5941cc0 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -2,6 +2,8 @@ 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 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import '../../../app/app.dart'; import '../../../app/responsive.dart'; @@ -28,7 +30,10 @@ class FeedScreen extends ConsumerStatefulWidget { class _FeedScreenState extends ConsumerState { final _scrollController = ScrollController(); + final Set _selectedKeys = {}; + bool _selectionMode = false; bool _usedInitialQuery = false; + Timer? _scrollSaveDebounce; @override void initState() { @@ -37,11 +42,30 @@ class _FeedScreenState extends ConsumerState { if (_scrollController.position.extentAfter < 800) { ref.read(feedControllerProvider.notifier).loadNextPage(); } + _scrollSaveDebounce?.cancel(); + _scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () { + ref + .read(feedControllerProvider.notifier) + .saveSession(scrollOffset: _scrollController.offset); + }); + }); + WidgetsBinding.instance.addPostFrameCallback((_) async { + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) { + _scrollController.jumpTo( + settings.lastFeedScrollOffset.clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ), + ); + } }); } @override void dispose() { + _scrollSaveDebounce?.cancel(); _scrollController.dispose(); super.dispose(); } @@ -64,6 +88,10 @@ class _FeedScreenState extends ConsumerState { return Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(), + LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA): + const _SelectVisibleIntent(), }, child: Actions( actions: { @@ -73,6 +101,26 @@ class _FeedScreenState extends ConsumerState { return null; }, ), + _ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>( + onInvoke: (_) { + _clearSelection(); + return null; + }, + ), + _SelectVisibleIntent: CallbackAction<_SelectVisibleIntent>( + onInvoke: (_) { + final state = feed.value; + if (state != null) { + setState(() { + _selectionMode = true; + _selectedKeys + ..clear() + ..addAll(state.posts.map((post) => post.cacheKey)); + }); + } + return null; + }, + ), }, child: AdaptiveScaffold( title: 'Feed', @@ -106,6 +154,13 @@ class _FeedScreenState extends ConsumerState { ref.read(feedControllerProvider.notifier).refresh(), onClearFilters: () => ref.read(feedControllerProvider.notifier).clearFilters(), + selectionMode: _selectionMode, + onToggleSelectionMode: () { + setState(() { + _selectionMode = !_selectionMode; + if (!_selectionMode) _selectedKeys.clear(); + }); + }, onQuickProviderToggle: (providerId) { if (providerId == '__all__') { ref @@ -167,10 +222,14 @@ class _FeedScreenState extends ConsumerState { loading: state.isLoadingMore, favoriteKeys: favoriteKeys, viewedKeys: viewedKeys, + selectionMode: _selectionMode, + selectedKeys: _selectedKeys, onOpen: (post) => context.push( '/post/${post.providerId}/${post.id}', extra: post, ), + onPreview: (post) => _showPreview(context, post), + onToggleSelected: (post) => _toggleSelected(post), onFavorite: (post) => _toggleFavorite(ref, post, favoriteKeys), onAddToCollection: (post) => @@ -178,6 +237,21 @@ class _FeedScreenState extends ConsumerState { ), ), ), + if (_selectionMode && _selectedKeys.isNotEmpty) + _BatchActionBar( + count: _selectedKeys.length, + onFavorite: () => _favoriteSelected( + ref, + state.posts, + favoriteKeys, + ), + onCollection: () => _addSelectedToCollection( + context, + ref, + state.posts, + ), + onClear: _clearSelection, + ), ], ), ), @@ -186,6 +260,95 @@ class _FeedScreenState extends ConsumerState { ); } + void _toggleSelected(Post post) { + setState(() { + _selectionMode = true; + if (!_selectedKeys.add(post.cacheKey)) { + _selectedKeys.remove(post.cacheKey); + } + if (_selectedKeys.isEmpty) _selectionMode = false; + }); + } + + void _clearSelection() { + if (!_selectionMode && _selectedKeys.isEmpty) return; + setState(() { + _selectionMode = false; + _selectedKeys.clear(); + }); + } + + List _selectedPosts(List posts) { + return posts + .where((post) => _selectedKeys.contains(post.cacheKey)) + .toList(); + } + + Future _favoriteSelected( + WidgetRef ref, + List posts, + Set favoriteKeys, + ) async { + for (final post in _selectedPosts(posts)) { + if (!favoriteKeys.contains(post.cacheKey)) { + await ref.read(favoriteServiceProvider).addFavorite(post); + } + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + _clearSelection(); + } + + Future _addSelectedToCollection( + BuildContext context, + WidgetRef ref, + List posts, + ) async { + final selectedPosts = _selectedPosts(posts); + final result = await ref.read(collectionServiceProvider).getCollections(); + final collections = + result is Success> ? result.data : []; + if (!context.mounted) return; + await showAddToCollectionPicker( + context, + collections: collections, + onSelected: (collection) async { + await ref + .read(collectionServiceProvider) + .addPostsToCollection(collection.id, selectedPosts); + _clearSelection(); + }, + onCreate: () => showCollectionFormDialog(context, ref), + ); + } + + Future _showPreview(BuildContext context, Post post) { + final imageUrl = MediaUrlSelector.preview(post).firstOrNull; + final child = imageUrl == null + ? const Center(child: Icon(Icons.broken_image_rounded, size: 48)) + : CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.contain); + if (Responsive.isMobile(context)) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => SizedBox( + height: MediaQuery.sizeOf(context).height * 0.72, + child: Padding(padding: const EdgeInsets.all(8), child: child), + ), + ); + } + return showDialog( + context: context, + builder: (context) => Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900, maxHeight: 760), + child: Padding(padding: const EdgeInsets.all(8), child: child), + ), + ), + ); + } + Future _addToCollection( BuildContext context, WidgetRef ref, @@ -227,3 +390,70 @@ class _FeedScreenState extends ConsumerState { class _RefreshIntent extends Intent { const _RefreshIntent(); } + +class _ClearSelectionIntent extends Intent { + const _ClearSelectionIntent(); +} + +class _SelectVisibleIntent extends Intent { + const _SelectVisibleIntent(); +} + +class _BatchActionBar extends StatelessWidget { + const _BatchActionBar({ + required this.count, + required this.onFavorite, + required this.onCollection, + required this.onClear, + }); + + final int count; + final VoidCallback onFavorite; + final VoidCallback onCollection; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 10), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 16), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Text('$count selected', + style: Theme.of(context).textTheme.titleMedium), + const Spacer(), + IconButton.filledTonal( + tooltip: 'Favorite selected', + onPressed: onFavorite, + icon: const Icon(Icons.favorite_rounded), + ), + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: onCollection, + icon: const Icon(Icons.add_rounded), + label: const Text('Collection'), + ), + IconButton( + tooltip: 'Clear', + onPressed: onClear, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/feed/presentation/widgets/feed_toolbar.dart b/lib/features/feed/presentation/widgets/feed_toolbar.dart index 4a5672b..97714ec 100644 --- a/lib/features/feed/presentation/widgets/feed_toolbar.dart +++ b/lib/features/feed/presentation/widgets/feed_toolbar.dart @@ -22,6 +22,8 @@ class FeedToolbar extends StatefulWidget { required this.onSearchChanged, required this.onSuggestionTap, required this.onTopPeriodChanged, + this.onToggleSelectionMode, + this.selectionMode = false, super.key, }); @@ -40,6 +42,8 @@ class FeedToolbar extends StatefulWidget { final ValueChanged onSearchChanged; final ValueChanged onSuggestionTap; final ValueChanged onTopPeriodChanged; + final VoidCallback? onToggleSelectionMode; + final bool selectionMode; @override State createState() => _FeedToolbarState(); @@ -91,6 +95,13 @@ class _FeedToolbarState extends State { ), ), const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: widget.selectionMode ? 'Exit select' : 'Select posts', + onPressed: widget.onToggleSelectionMode, + icon: Icon(widget.selectionMode + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded), + ), IconButton.filledTonal( tooltip: 'Providers', onPressed: widget.onProviderFilter, @@ -184,9 +195,12 @@ class _FeedToolbarState extends State { primary: false, children: [ IconButton.filledTonal( - tooltip: 'Providers', - onPressed: widget.onProviderFilter, - icon: const Icon(Icons.hub_rounded), + tooltip: + widget.selectionMode ? 'Exit select' : 'Select posts', + onPressed: widget.onToggleSelectionMode, + icon: Icon(widget.selectionMode + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded), ), const SizedBox(width: 6), IconButton.filledTonal( diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index ade7fd8..5a7035e 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -84,6 +84,8 @@ class PostDetailsScreen extends ConsumerWidget { final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1 ? feedPosts[currentIndex + 1] : null; + final qualityMode = + MediaQualityMode.fromName(settings.mediaQualityMode); if (Responsive.isMobile(context)) { if (currentIndex >= 0 && feedPosts.length > 1) { return _MobilePostPager( @@ -95,6 +97,7 @@ class PostDetailsScreen extends ConsumerWidget { post, settings, favoriteKeys, + qualityMode, ), ); } @@ -104,6 +107,7 @@ class PostDetailsScreen extends ConsumerWidget { post, settings, favoriteKeys, + qualityMode, ); } return Shortcuts( @@ -116,6 +120,7 @@ class PostDetailsScreen extends ConsumerWidget { const _ToggleFavoriteIntent(), LogicalKeySet(LogicalKeyboardKey.keyC): const _AddCollectionIntent(), + LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(), }, child: Actions( @@ -150,6 +155,14 @@ class PostDetailsScreen extends ConsumerWidget { return null; }, ), + _DownloadIntent: CallbackAction<_DownloadIntent>( + onInvoke: (_) { + if (settings.allowDownloads) { + _download(context, ref, post); + } + return null; + }, + ), }, child: Focus( autofocus: true, @@ -170,7 +183,10 @@ class PostDetailsScreen extends ConsumerWidget { child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 760), - child: PostMediaViewer(post: post), + child: PostMediaViewer( + post: post, + qualityMode: qualityMode, + ), ), ), ), @@ -243,6 +259,7 @@ class PostDetailsScreen extends ConsumerWidget { Post post, AppSettings settings, Set favoriteKeys, + MediaQualityMode qualityMode, ) { return ListView( padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), @@ -251,7 +268,12 @@ class PostDetailsScreen extends ConsumerWidget { constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.62, ), - child: Center(child: PostMediaViewer(post: post)), + child: Center( + child: PostMediaViewer( + post: post, + qualityMode: qualityMode, + ), + ), ), const SizedBox(height: 12), PostActionBar( @@ -319,22 +341,12 @@ class PostDetailsScreen extends ConsumerWidget { } Future _download(BuildContext context, WidgetRef ref, Post post) async { + if (!context.mounted) return; + await ref.read(downloadManagerServiceProvider).start(post); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Downloading...')), + const SnackBar(content: Text('Download started')), ); - 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( @@ -583,6 +595,10 @@ class _CloseIntent extends Intent { const _CloseIntent(); } +class _DownloadIntent extends Intent { + const _DownloadIntent(); +} + class _CommentsSection extends ConsumerStatefulWidget { const _CommentsSection({required this.post}); diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 02a11e0..2f0063a 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -24,6 +24,7 @@ class PostMediaViewer extends StatefulWidget { this.initialMuted = false, this.initialCoverVideo = false, this.initialHalfVolume = false, + this.qualityMode = MediaQualityMode.auto, this.onPlaybackSnapshot, super.key, }); @@ -36,6 +37,7 @@ class PostMediaViewer extends StatefulWidget { final bool initialMuted; final bool initialCoverVideo; final bool initialHalfVolume; + final MediaQualityMode qualityMode; final ValueChanged? onPlaybackSnapshot; @override @@ -281,18 +283,11 @@ class _PostMediaViewerState extends State { } List _buildImageUrls(Post post) { - final urls = post.fileType.toLowerCase().contains('gif') - ? [post.fileUrl, post.sampleUrl, post.previewUrl] - : [post.sampleUrl, post.fileUrl, post.previewUrl]; - return urls.where((url) => url.trim().isNotEmpty).toSet().toList(); + return MediaUrlSelector.details(post, mode: widget.qualityMode); } List _buildVideoUrls(Post post) { - return { - post.fileUrl, - if (_looksLikeVideoUrl(post.sampleUrl)) post.sampleUrl, - if (_looksLikeVideoUrl(post.previewUrl)) post.previewUrl, - }.where((url) => url.trim().isNotEmpty).toList(); + return MediaUrlSelector.video(post); } bool _isVideo(Post post) { @@ -318,13 +313,6 @@ class _PostMediaViewerState extends State { return post.fileUrl.trim(); } - bool _looksLikeVideoUrl(String url) { - final value = url.toLowerCase(); - return value.contains('.webm') || - value.contains('.mp4') || - value.contains('.mov'); - } - Map _headersFor(Post post) { return { 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', @@ -796,6 +784,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { initialMuted: widget.muted, initialHalfVolume: widget.halfVolume, initialCoverVideo: widget.coverVideo, + qualityMode: MediaQualityMode.highQuality, onPlaybackSnapshot: (snapshot) => _snapshot = snapshot, ), ), diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 1786483..9a86acd 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,6 +1,7 @@ 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 '../../../backend/backend.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; @@ -57,6 +58,30 @@ class SettingsScreen extends ConsumerWidget { onChanged: (value) => _update(ref, settings.copyWith(allowDownloads: value)), ), + DropdownButtonFormField( + initialValue: settings.mediaQualityMode, + decoration: const InputDecoration(labelText: 'Media quality'), + items: [ + for (final mode in MediaQualityMode.values) + DropdownMenuItem(value: mode.name, child: Text(mode.label)), + ], + onChanged: (value) => _update( + ref, + settings.copyWith(mediaQualityMode: value ?? 'auto'), + ), + ), + const SizedBox(height: 8), + FilledButton.tonalIcon( + onPressed: () => context.go('/providers'), + icon: const Icon(Icons.hub_rounded), + label: const Text('Providers'), + ), + const SizedBox(height: 8), + FilledButton.tonalIcon( + onPressed: () => context.go('/providers/check'), + icon: const Icon(Icons.network_check_rounded), + label: const Text('Provider diagnostics'), + ), SwitchListTile( value: settings.hideViewedPosts, title: const Text('Hide viewed posts'), diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index ef79778..9db7ca0 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -27,6 +27,19 @@ class AppShell extends StatelessWidget { LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): const _NavigateIntent('/search'), LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(), + LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'), + LogicalKeySet(LogicalKeyboardKey.digit2): + const _NavigateIntent('/search'), + LogicalKeySet(LogicalKeyboardKey.digit3): + const _NavigateIntent('/favorites'), + LogicalKeySet(LogicalKeyboardKey.digit4): + const _NavigateIntent('/viewed'), + LogicalKeySet(LogicalKeyboardKey.digit5): + const _NavigateIntent('/collections'), + LogicalKeySet(LogicalKeyboardKey.digit6): + const _NavigateIntent('/providers'), + LogicalKeySet(LogicalKeyboardKey.digit7): + const _NavigateIntent('/settings'), }, child: Actions( actions: { diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index 2e5f760..0f2f7cf 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -16,6 +16,10 @@ class PostCard extends StatefulWidget { required this.onOpen, required this.onFavorite, this.onAddToCollection, + this.onPreview, + this.onToggleSelected, + this.selectionMode = false, + this.selected = false, super.key, }); @@ -27,6 +31,10 @@ class PostCard extends StatefulWidget { final VoidCallback onOpen; final VoidCallback onFavorite; final VoidCallback? onAddToCollection; + final VoidCallback? onPreview; + final VoidCallback? onToggleSelected; + final bool selectionMode; + final bool selected; @override State createState() => _PostCardState(); @@ -46,7 +54,24 @@ class _PostCardState extends State { onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( - onTap: widget.onOpen, + onTap: () { + if (widget.selectionMode) { + widget.onToggleSelected?.call(); + return; + } + widget.onOpen(); + }, + onLongPress: () { + if (widget.selectionMode && widget.onToggleSelected != null) { + widget.onToggleSelected!(); + return; + } + if (widget.onPreview != null) { + widget.onPreview!(); + return; + } + widget.onToggleSelected?.call(); + }, onSecondaryTapDown: (details) { showMenu( context: context, @@ -119,6 +144,29 @@ class _PostCardState extends State { child: _SeenBadge(), ), ], + if (widget.selected) + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.24), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + borderRadius: BorderRadius.circular(8), + ), + child: const Align( + alignment: Alignment.topRight, + child: Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.check_circle_rounded), + ), + ), + ), + ), if (mobile) Positioned( right: 8, diff --git a/lib/shared/widgets/post_masonry_grid.dart b/lib/shared/widgets/post_masonry_grid.dart index eadbfc0..a77dde9 100644 --- a/lib/shared/widgets/post_masonry_grid.dart +++ b/lib/shared/widgets/post_masonry_grid.dart @@ -16,6 +16,10 @@ class PostMasonryGrid extends StatelessWidget { required this.onOpen, required this.onFavorite, this.onAddToCollection, + this.onPreview, + this.onToggleSelected, + this.selectionMode = false, + this.selectedKeys = const {}, this.favoriteKeys = const {}, this.viewedKeys = const {}, this.loading = false, @@ -31,6 +35,10 @@ class PostMasonryGrid extends StatelessWidget { final ValueChanged onOpen; final ValueChanged onFavorite; final ValueChanged? onAddToCollection; + final ValueChanged? onPreview; + final ValueChanged? onToggleSelected; + final bool selectionMode; + final Set selectedKeys; final Set favoriteKeys; final Set viewedKeys; final bool loading; @@ -57,6 +65,11 @@ class PostMasonryGrid extends StatelessWidget { isViewed: viewedKeys.contains(post.cacheKey), onOpen: () => onOpen(post), onFavorite: () => onFavorite(post), + onPreview: onPreview == null ? null : () => onPreview!(post), + selectionMode: selectionMode, + selected: selectedKeys.contains(post.cacheKey), + onToggleSelected: + onToggleSelected == null ? null : () => onToggleSelected!(post), onAddToCollection: onAddToCollection == null ? null : () => onAddToCollection!(post), );