From f603c37c91e34e0169ea144f637f3b7aa784ab9f Mon Sep 17 00:00:00 2001 From: RarDog Date: Fri, 4 Sep 2026 11:40:19 +0300 Subject: [PATCH] feat(offline+post): Offline Mode Overhaul & Post Details Screen Redesign (v2.2.0) --- README.md | 4 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 11 + lib/backend/di/backend_providers.dart | 5 + .../services/downloaded_media_service.dart | 35 ++ .../presentation/favorites_screen.dart | 383 ++++++++++-- .../presentation/post_details_screen.dart | 582 +++++++++++++++--- .../presentation/widgets/post_action_bar.dart | 319 ++++++++-- .../widgets/post_media_viewer.dart | 107 ++-- pubspec.yaml | 2 +- .../offline_and_post_redesign_test.dart | 106 ++++ 11 files changed, 1305 insertions(+), 253 deletions(-) create mode 100644 test/features/offline_and_post_redesign_test.dart diff --git a/README.md b/README.md index 4b4a5be..33f8eff 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.1.0 + Version 2.2.0 Flutter Platforms Gitea @@ -20,6 +20,8 @@ ## ✨ Основные возможности +- **💾 Офлайн 2.0 и Sync Hub**: 100% честное воспроизведение видео и просмотр фото прямо с диска без единого запроса в сеть, карточка аналитики хранилища (`💾 X.X ГБ на устройстве`), кнопка пакетного скачивания недостающих в 1 тап и удобная сортировка по дате, размеру и авторам. +- **🖼️ Экран поста 2.0**: Action Dock 2.0 (чистый док из 5 главных действий + модальный лист вторичных функций), современная карточка инфо с автором, провайдером, разрешением, форматом и оценкой, лента соседних постов на мобильных и ПК, стильные блоки тегов и комментарии в виде чат-бабблов. - **🔍 Поиск 2.0**: Hero-панель поиска, быстрые чипы операторов (`and`, `type:video`, `rating:safe`), облако недавних поисков с быстрым удалением крестиком `✕`, карточки популярных категорий и шпаргалка по синтаксису запросов. - **❤️ Избранное 2.0**: Живые счетчики в сегментах (Все, Офлайн, Авторы), быстрый фильтр по типу медиа (Все / Видео / Фото), мгновенный поиск среди сохраненных работ и карточки авторов в стиле Spotify с мини-коллажем из 3 превью. - **🕒 История 2.0**: Группировка по дням (Сегодня, Вчера, даты), переключение между подробным списком (с 68x68 миниатюрами, бэйджами провайдеров и быстрым удалением из истории) и галерейной Masonry-сеткой, а также диалог подтверждения очистки. diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index b6e56d0..baa7dcb 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.1.0'; -const appBuildNumber = 38; +const appDisplayVersion = '2.2.0'; +const appBuildNumber = 39; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 8f903b7..a6aef3e 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,15 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.2.0', + title: 'Offline Mode Overhaul & Post Details Screen Redesign', + bullets: [ + '100% Genuine Offline Playback: local images and videos play directly from disk storage via media_kit and cached files without initiating network requests.', + 'Offline Storage & Sync Hub: live storage size indicator (💾 X.X MB/GB on device), 1-tap "Download all missing" batch sync button, real-time downloading task progress bar, and sorting (by date, size, artist).', + 'Action Dock 2.0: redesigned post action bar with primary dock buttons (Like, Download with status/progress, Collection, Share) and clean "More" modal bottom sheet for secondary actions.', + 'Modern Post Info Card: author row with palette icon and clickable search, booru provider badge with relative timestamp, spec capsules (resolution, file format, safety rating badge, score, offline size).', + 'Unified Neighbor Strip & Tags Card: post neighbor thumbnail strip now available on both mobile and desktop, alongside modern rounded tag blocks and chat-bubble comments.', + ], + ), LunarisChange( version: '2.1.0', title: '4-Tab Major Redesign: Search, Favorites, History & Collections', diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index d9f2a99..cb0a2f0 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -194,6 +194,11 @@ final downloadedMediaByKeysProvider = : const {}; }); +final offlineDiskSizeProvider = + FutureProvider.family>((ref, items) { + return DownloadedMediaService.totalDiskSizeBytes(items); +}); + final downloadedMediaByKeyProvider = FutureProvider.family((ref, cacheKey) async { final result = diff --git a/lib/backend/services/downloaded_media_service.dart b/lib/backend/services/downloaded_media_service.dart index 8a3f98a..d994be5 100644 --- a/lib/backend/services/downloaded_media_service.dart +++ b/lib/backend/services/downloaded_media_service.dart @@ -48,4 +48,39 @@ class DownloadedMediaService { Future> clearMissingFiles() => _repository.clearMissingFiles(); Future> count() => _repository.count(); + + static int getFileSizeSync(DownloadedMedia media) { + if (media.savedPath.isEmpty) return 0; + final file = File(media.savedPath); + if (!file.existsSync()) return 0; + try { + return file.lengthSync(); + } catch (_) { + return 0; + } + } + + static Future totalDiskSizeBytes(Iterable items) async { + int total = 0; + for (final media in items) { + if (media.savedPath.isNotEmpty) { + final file = File(media.savedPath); + if (await file.exists()) { + try { + total += await file.length(); + } catch (_) {} + } + } + } + return total; + } + + static String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } } diff --git a/lib/features/favorites/presentation/favorites_screen.dart b/lib/features/favorites/presentation/favorites_screen.dart index 941cce9..de08eb2 100644 --- a/lib/features/favorites/presentation/favorites_screen.dart +++ b/lib/features/favorites/presentation/favorites_screen.dart @@ -13,6 +13,7 @@ import '../../../shared/widgets/post_masonry_grid.dart'; import 'favorites_controller.dart'; enum FavoriteMediaType { all, video, image } +enum OfflineSortOption { dateAddedDesc, sizeDesc, artist } class FavoritesScreen extends ConsumerStatefulWidget { const FavoritesScreen({super.key}); @@ -25,6 +26,7 @@ class _FavoritesScreenState extends ConsumerState { int _tabIndex = 0; String? _selectedArtist; FavoriteMediaType _mediaFilter = FavoriteMediaType.all; + OfflineSortOption _offlineSort = OfflineSortOption.dateAddedDesc; bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; @@ -81,6 +83,17 @@ class _FavoritesScreenState extends ConsumerState { .where((post) => downloaded.containsKey(post.cacheKey)) .toList(); final artistGroups = favoriteArtistAlbums(allPosts); + final diskSizeBytes = _tabIndex == 1 + ? (ref.watch(offlineDiskSizeProvider(downloaded.values)).value ?? 0) + : 0; + final tasks = _tabIndex == 1 + ? (ref.watch(downloadTasksProvider).value ?? const []) + : const []; + final activeTasks = tasks + .where((t) => + t.status == DownloadTaskStatus.running || + t.status == DownloadTaskStatus.queued) + .toList(); return AdaptiveScaffold( title: isRu ? 'Избранное' : 'Favorites', @@ -287,10 +300,13 @@ class _FavoritesScreenState extends ConsumerState { Expanded( child: switch (_tabIndex) { 1 => _buildOfflineView( - offlinePosts, - settings, - downloaded.keys.toSet(), - isRu, + offlinePosts: offlinePosts, + allPosts: allPosts, + downloaded: downloaded, + diskSizeBytes: diskSizeBytes, + activeTasks: activeTasks, + settings: settings, + isRu: isRu, ), 2 => _buildArtistsView( allPosts, @@ -353,63 +369,332 @@ class _FavoritesScreenState extends ConsumerState { return _postsGrid(filtered, settings, downloadedKeys); } - Widget _buildOfflineView( - List posts, - AppSettings settings, - Set downloadedKeys, - bool isRu, - ) { - if (posts.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, + Widget _buildOfflineView({ + required List offlinePosts, + required List allPosts, + required Map downloaded, + required int diskSizeBytes, + required List activeTasks, + required AppSettings settings, + required bool isRu, + }) { + final pendingPosts = + allPosts.where((p) => !downloaded.containsKey(p.cacheKey)).toList(); + + final sorted = List.from(offlinePosts); + if (_offlineSort == OfflineSortOption.sizeDesc) { + sorted.sort((a, b) { + final mA = downloaded[a.cacheKey]; + final mB = downloaded[b.cacheKey]; + final sA = mA != null ? DownloadedMediaService.getFileSizeSync(mA) : 0; + final sB = mB != null ? DownloadedMediaService.getFileSizeSync(mB) : 0; + return sB.compareTo(sA); + }); + } else if (_offlineSort == OfflineSortOption.artist) { + sorted.sort((a, b) { + final artA = (a.tagGroups['artist'] ?? const []).firstOrNull ?? ''; + final artB = (b.tagGroups['artist'] ?? const []).firstOrNull ?? ''; + return artA.toLowerCase().compareTo(artB.toLowerCase()); + }); + } else { + sorted.sort((a, b) { + final dA = downloaded[a.cacheKey]?.downloadedAt ?? + DateTime.fromMillisecondsSinceEpoch(0); + final dB = downloaded[b.cacheKey]?.downloadedAt ?? + DateTime.fromMillisecondsSinceEpoch(0); + return dB.compareTo(dA); + }); + } + + final filtered = _applyFilters(sorted); + + return Column( + children: [ + _buildOfflineSyncHeader( + downloadedCount: offlinePosts.length, + totalCount: allPosts.length, + pendingCount: pendingPosts.length, + diskSizeBytes: diskSizeBytes, + activeTasks: activeTasks, + pendingPosts: pendingPosts, + isRu: isRu, + ), + Expanded( + child: offlinePosts.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .secondaryContainer + .withValues(alpha: 0.35), + shape: BoxShape.circle, + ), + child: Icon( + Icons.cloud_off_rounded, + size: 48, + color: Theme.of(context).colorScheme.secondary, + ), + ), + const SizedBox(height: 16), + Text( + isRu ? 'Нет офлайн постов' : 'No offline posts', + style: + Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + isRu + ? 'Нажмите «Скачать всё», чтобы загрузить избранные посты на устройство' + : 'Tap "Download all" above to save favorites for offline viewing', + textAlign: TextAlign.center, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ) + : filtered.isEmpty + ? Center( + child: Text(isRu + ? 'Ничего не найдено по фильтрам' + : 'No matching offline posts'), + ) + : _postsGrid(filtered, settings, downloaded.keys.toSet()), + ), + ], + ); + } + + Widget _buildOfflineSyncHeader({ + required int downloadedCount, + required int totalCount, + required int pendingCount, + required int diskSizeBytes, + required List activeTasks, + required List pendingPosts, + required bool isRu, + }) { + final scheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .secondaryContainer - .withValues(alpha: 0.35), - shape: BoxShape.circle, + color: scheme.primaryContainer.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), ), child: Icon( - Icons.cloud_off_rounded, - size: 48, - color: Theme.of(context).colorScheme.secondary, + Icons.offline_pin_rounded, + color: scheme.onPrimaryContainer, + size: 22, ), ), - const SizedBox(height: 16), - Text( - isRu ? 'Нет офлайн постов' : 'No offline posts', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$downloadedCount ${isRu ? 'из' : 'of'} $totalCount ${isRu ? 'офлайн' : 'saved'}', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), ), - ), - const SizedBox(height: 8), - Text( - isRu - ? 'Загружайте понравившиеся работы для просмотра без подключения к сети' - : 'Download posts to view them anytime without an internet connection', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.sd_storage_rounded, + size: 14, + color: scheme.primary, + ), + const SizedBox(width: 4), + Text( + '${DownloadedMediaService.formatBytes(diskSizeBytes)} ${isRu ? 'на устройстве' : 'on disk'}', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], ), + ], + ), ), + if (pendingCount > 0) + FilledButton.tonalIcon( + onPressed: () async { + final dm = ref.read(downloadManagerServiceProvider); + for (final p in pendingPosts) { + await dm.start(p); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isRu + ? 'В очередь загрузки добавлено: $pendingCount' + : 'Queued $pendingCount downloads', + ), + behavior: SnackBarBehavior.floating, + ), + ); + }, + icon: const Icon(Icons.download_rounded, size: 16), + label: Text( + isRu + ? 'Скачать всё ($pendingCount)' + : 'Download ($pendingCount)', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + visualDensity: VisualDensity.compact, + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: scheme.secondaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_rounded, + size: 16, + color: scheme.primary, + ), + const SizedBox(width: 4), + Text( + isRu ? 'Всё готово' : 'Synced', + style: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), ], ), - ), - ); - } - final filtered = _applyFilters(posts); - if (filtered.isEmpty) { - return Center( - child: Text(isRu ? 'Ничего не найдено' : 'No matching offline posts'), - ); - } - return _postsGrid(filtered, settings, downloadedKeys); + if (activeTasks.isNotEmpty) ...[ + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: const LinearProgressIndicator(minHeight: 3), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${isRu ? 'Загрузка...' : 'Downloading...'} (${activeTasks.length})', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + Flexible( + child: Text( + activeTasks.first.fileName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Text( + '${isRu ? 'Сортировка' : 'Sort'}:', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + ChoiceChip( + label: Text(isRu ? 'По дате' : 'Date'), + selected: _offlineSort == OfflineSortOption.dateAddedDesc, + onSelected: (_) => setState( + () => _offlineSort = OfflineSortOption.dateAddedDesc, + ), + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 6), + ChoiceChip( + label: Text(isRu ? 'По размеру' : 'Size'), + selected: _offlineSort == OfflineSortOption.sizeDesc, + onSelected: (_) => setState( + () => _offlineSort = OfflineSortOption.sizeDesc, + ), + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 6), + ChoiceChip( + label: Text(isRu ? 'По автору' : 'Artist'), + selected: _offlineSort == OfflineSortOption.artist, + onSelected: (_) => setState( + () => _offlineSort = OfflineSortOption.artist, + ), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ], + ), + ); } Widget _buildArtistsView( diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 324ef01..6c3276a 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -94,6 +94,21 @@ class PostDetailsScreen extends ConsumerWidget { : null; final qualityMode = MediaQualityMode.fromName(settings.mediaQualityMode); + final localMedia = ref + .watch(downloadedMediaByKeyProvider(post.cacheKey)) + .value; + final fileSizeBytes = localMedia != null + ? DownloadedMediaService.getFileSizeSync(localMedia) + : null; + final isDownloading = ref.watch(downloadTasksProvider).value?.any( + (t) => + (t.post?.cacheKey == post.cacheKey || + t.id == post.cacheKey) && + (t.status == DownloadTaskStatus.running || + t.status == DownloadTaskStatus.queued), + ) ?? + false; + if (Responsive.isMobile(context)) { if (currentIndex >= 0 && feedPosts.length > 1) { return _MobilePostPager( @@ -111,6 +126,7 @@ class PostDetailsScreen extends ConsumerWidget { strings, mediaGestureLocked, onMediaGestureLockChanged, + feedPosts, ), ); } @@ -124,6 +140,7 @@ class PostDetailsScreen extends ConsumerWidget { strings, false, null, + feedPosts, ); } return Shortcuts( @@ -207,6 +224,7 @@ class PostDetailsScreen extends ConsumerWidget { child: PostMediaViewer( key: ValueKey(post.cacheKey), post: post, + localFilePath: localMedia?.savedPath, qualityMode: qualityMode, mediaHeaders: ref .watch(postMediaHeadersProvider(post)) @@ -251,11 +269,8 @@ class PostDetailsScreen extends ConsumerWidget { PostActionBar( isFavorite: favoriteKeys.contains(post.cacheKey), labels: _postActionLabels(strings), - downloaded: ref - .watch( - downloadedMediaByKeyProvider(post.cacheKey)) - .value != - null, + downloaded: localMedia != null, + isDownloading: isDownloading, onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), onCollection: () => _addToCollection(context, ref, post), @@ -273,26 +288,51 @@ class PostDetailsScreen extends ConsumerWidget { onShare: () => _sharePost(context, ref, post), ), 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: Text(strings.source), - onPressed: () => launchUrl(Uri.parse(post.source!)), - ), - ], + _PostInfoCard( + post: post, + strings: strings, + localMedia: localMedia, + fileSizeBytes: fileSizeBytes, ), const SizedBox(height: 16), - Text(strings.tags, - style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - PostTagsPanel(post: post), + Container( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outlineVariant + .withValues(alpha: 0.35), + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.tag_rounded, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + '${strings.tags} (${post.tags.length})', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 12), + PostTagsPanel(post: post), + ], + ), + ), const SizedBox(height: 16), _CommentsSection(post: post), ], @@ -314,9 +354,24 @@ class PostDetailsScreen extends ConsumerWidget { MediaQualityMode qualityMode, AppStrings strings, bool mediaGestureLocked, - ValueChanged? onMediaGestureLockChanged, - ) { + ValueChanged? onMediaGestureLockChanged, [ + List? feedPosts, + ]) { final isVideo = MediaUrlSelector.isVideo(post); + final localMedia = + ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value; + final fileSizeBytes = localMedia != null + ? DownloadedMediaService.getFileSizeSync(localMedia) + : null; + final isDownloading = ref.watch(downloadTasksProvider).value?.any( + (t) => + (t.post?.cacheKey == post.cacheKey || + t.id == post.cacheKey) && + (t.status == DownloadTaskStatus.running || + t.status == DownloadTaskStatus.queued), + ) ?? + false; + return GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragEnd: (details) { @@ -340,6 +395,7 @@ class PostDetailsScreen extends ConsumerWidget { child: PostMediaViewer( key: ValueKey(post.cacheKey), post: post, + localFilePath: localMedia?.savedPath, qualityMode: qualityMode, mediaHeaders: ref.watch(postMediaHeadersProvider(post)).value ?? const {}, @@ -365,9 +421,8 @@ class PostDetailsScreen extends ConsumerWidget { PostActionBar( isFavorite: favoriteKeys.contains(post.cacheKey), labels: _postActionLabels(strings), - downloaded: - ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value != - null, + downloaded: localMedia != null, + isDownloading: isDownloading, onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), onCollection: () => _addToCollection(context, ref, post), onOpen: () => launchUrl(Uri.parse(post.fileUrl)), @@ -382,33 +437,62 @@ class PostDetailsScreen extends ConsumerWidget { onShare: () => _sharePost(context, ref, post), ), 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: Text(strings.source), - onPressed: () => launchUrl(Uri.parse(post.source!)), - ), - ], + _PostInfoCard( + post: post, + strings: strings, + localMedia: localMedia, + fileSizeBytes: fileSizeBytes, ), - const SizedBox(height: 8), - ExpansionTile( - tilePadding: EdgeInsets.zero, - initiallyExpanded: false, - title: Text(strings.tags, - style: Theme.of(context).textTheme.titleMedium), - children: [ - Align( - alignment: Alignment.centerLeft, - child: PostTagsPanel(post: post), + if (feedPosts != null && feedPosts.length > 1) ...[ + const SizedBox(height: 12), + _NeighborStrip( + posts: feedPosts, + currentIndex: feedPosts.indexWhere( + (item) => + item.providerId == post.providerId && item.id == post.id, ), - ], + onOpen: (p) => _openPost(context, p), + ), + ], + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outlineVariant + .withValues(alpha: 0.35), + ), + ), + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + tilePadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 2), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + initiallyExpanded: false, + shape: const Border(), + collapsedShape: const Border(), + leading: Icon( + Icons.tag_rounded, + color: Theme.of(context).colorScheme.primary, + ), + title: Text( + '${strings.tags} (${post.tags.length})', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + children: [ + Align( + alignment: Alignment.centerLeft, + child: PostTagsPanel(post: post), + ), + ], + ), ), + const SizedBox(height: 12), _CommentsSection(post: post), ], ), @@ -939,53 +1023,355 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> { ), ) : null; - return ExpansionTile( - tilePadding: EdgeInsets.zero, - initiallyExpanded: false, - onExpansionChanged: (value) => setState(() => _expanded = value), - title: Text(strings.comments, - style: Theme.of(context).textTheme.titleMedium), - children: [ - (comments ?? const AsyncValue>.data([])).when( - loading: () => const Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(), - ), - error: (_, __) => Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(strings.commentsUnavailable), - ), - ), - data: (items) { - if (items.isEmpty) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(strings.noComments), - ), - ); - } - return Column( - children: [ - for (final comment in items) - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: Text( - comment.authorName.isEmpty - ? strings.anonymous - : comment.authorName, - ), - subtitle: Text(comment.body), - ), - ], - ); - }, + final scheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.35), ), - ], + ), + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + initiallyExpanded: false, + shape: const Border(), + collapsedShape: const Border(), + leading: Icon( + Icons.chat_bubble_outline_rounded, + color: scheme.primary, + ), + onExpansionChanged: (value) => setState(() => _expanded = value), + title: Text( + strings.comments, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + children: [ + (comments ?? const AsyncValue>.data([])).when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (_, __) => Padding( + padding: const EdgeInsets.all(12), + child: Text( + strings.commentsUnavailable, + style: TextStyle(color: scheme.outline), + ), + ), + data: (items) { + if (items.isEmpty) { + return Padding( + padding: const EdgeInsets.all(12), + child: Text( + strings.noComments, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ); + } + return Column( + children: [ + for (final comment in items) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: scheme.primaryContainer, + child: Text( + (comment.authorName.isNotEmpty + ? comment.authorName[0] + : '?') + .toUpperCase(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: scheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + comment.authorName.isEmpty + ? strings.anonymous + : comment.authorName, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + comment.body, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +class _PostInfoCard extends StatelessWidget { + const _PostInfoCard({ + required this.post, + required this.strings, + this.localMedia, + this.fileSizeBytes, + }); + + final Post post; + final AppStrings strings; + final DownloadedMedia? localMedia; + final int? fileSizeBytes; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final artists = post.tagGroups['artist'] ?? + post.tags + .where((t) => t.startsWith('artist:')) + .map((t) => t.replaceFirst('artist:', '')) + .toList(); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Artist / Provider Row + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [scheme.primaryContainer, scheme.tertiaryContainer], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Icon( + artists.isNotEmpty + ? Icons.palette_rounded + : Icons.hub_rounded, + size: 18, + color: scheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (artists.isNotEmpty) + InkWell( + onTap: () { + context.push( + '/search?q=${Uri.encodeComponent('artist:${artists.first}')}', + ); + }, + borderRadius: BorderRadius.circular(6), + child: Text( + artists.join(', '), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: scheme.primary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + else + Text( + strings.ru ? 'Автор не указан' : 'Unknown Artist', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + post.providerName, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + Text( + ' • ', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.outline, + ), + ), + Text( + _formatDate(post.createdAt), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + if (localMedia != null) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.offline_pin_rounded, + size: 14, + color: scheme.onPrimaryContainer, + ), + const SizedBox(width: 4), + Text( + fileSizeBytes != null && fileSizeBytes! > 0 + ? DownloadedMediaService.formatBytes(fileSizeBytes!) + : (strings.ru ? 'Офлайн' : 'Offline'), + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + // Metadata Spec Pills + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _SpecBadge( + icon: Icons.aspect_ratio_rounded, + label: '${post.width} × ${post.height}', + ), + if (post.fileType.isNotEmpty) + _SpecBadge( + icon: Icons.insert_drive_file_outlined, + label: post.fileType.toUpperCase(), + ), + RatingBadge(rating: post.rating), + if (post.score != 0) + _SpecBadge( + icon: Icons.star_rounded, + label: '${post.score}', + iconColor: Colors.amber, + ), + if (post.source != null && post.source!.isNotEmpty) + InkWell( + onTap: () => launchUrl(Uri.parse(post.source!)), + borderRadius: BorderRadius.circular(10), + child: _SpecBadge( + icon: Icons.open_in_new_rounded, + label: strings.source, + color: scheme.surfaceContainerHigh, + ), + ), + ], + ), + ], + ), + ); + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} + +class _SpecBadge extends StatelessWidget { + const _SpecBadge({ + required this.icon, + required this.label, + this.iconColor, + this.color, + }); + + final IconData icon; + final String label; + final Color? iconColor; + final Color? color; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: color ?? scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.25), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: iconColor ?? scheme.onSurfaceVariant), + const SizedBox(width: 5), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), ); } } diff --git a/lib/features/post/presentation/widgets/post_action_bar.dart b/lib/features/post/presentation/widgets/post_action_bar.dart index fa2ed86..c5a87f1 100644 --- a/lib/features/post/presentation/widgets/post_action_bar.dart +++ b/lib/features/post/presentation/widgets/post_action_bar.dart @@ -12,6 +12,7 @@ class PostActionBar extends StatelessWidget { this.onDeleteLocalFile, this.onShare, this.downloaded = false, + this.isDownloading = false, this.labels, this.onSimilar, this.onHide, @@ -30,76 +31,268 @@ class PostActionBar extends StatelessWidget { final VoidCallback? onSimilar; final VoidCallback? onHide; final bool downloaded; + final bool isDownloading; final PostActionLabels? labels; + void _showMoreActions(BuildContext context, PostActionLabels text) { + final scheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Copy Link + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.copy_rounded, size: 20), + ), + title: Text(text.copyLink), + onTap: () { + Navigator.pop(sheetContext); + onCopy(); + }, + ), + // Open Original in Browser + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.open_in_new_rounded, size: 20), + ), + title: Text(text.openOriginal), + onTap: () { + Navigator.pop(sheetContext); + onOpen(); + }, + ), + // Open Source Page + if (onOpenSource != null) + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.article_rounded, size: 20), + ), + title: Text(text.openSource), + onTap: () { + Navigator.pop(sheetContext); + onOpenSource!(); + }, + ), + // Find Similar + if (onSimilar != null) + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.auto_awesome_rounded, + size: 20, color: scheme.primary), + ), + title: Text(text.similar), + onTap: () { + Navigator.pop(sheetContext); + onSimilar!(); + }, + ), + const Divider(height: 16), + // Delete Local File (if downloaded) + if (downloaded && onDeleteLocalFile != null) + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.errorContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.delete_outline_rounded, + size: 20, color: scheme.error), + ), + title: Text( + text.deleteLocalFile, + style: TextStyle(color: scheme.error), + ), + onTap: () { + Navigator.pop(sheetContext); + onDeleteLocalFile!(); + }, + ), + // Hide Post + if (onHide != null) + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.visibility_off_rounded, size: 20), + ), + title: Text(text.hidePost), + onTap: () { + Navigator.pop(sheetContext); + onHide!(); + }, + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; final text = labels ?? const PostActionLabels(); - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: onFavorite, - icon: Icon( - isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded, - ), - label: Text(isFavorite ? text.unfavorite : text.favorite), + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.3), ), - FilledButton.tonalIcon( - onPressed: onCollection, - icon: const Icon(Icons.add_rounded), - label: Text(text.collection), - ), - if (onSimilar != null) - FilledButton.tonalIcon( - onPressed: onSimilar, - icon: const Icon(Icons.auto_awesome_rounded), - label: Text(text.similar), - ), - IconButton.filledTonal( - tooltip: text.openOriginal, - onPressed: onOpen, - icon: const Icon(Icons.open_in_new_rounded), - ), - if (onOpenSource != null) - IconButton.filledTonal( - tooltip: text.openSource, - onPressed: onOpenSource, - icon: const Icon(Icons.article_rounded), - ), - IconButton.filledTonal( - tooltip: text.copyLink, - onPressed: onCopy, - icon: const Icon(Icons.copy_rounded), - ), - if (onDownload != null) - IconButton.filledTonal( - tooltip: text.download, - onPressed: onDownload, - icon: Icon( - downloaded ? Icons.download_done_rounded : Icons.download_rounded, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Favorite / Like Button + Expanded( + flex: 3, + child: FilledButton.tonalIcon( + style: FilledButton.styleFrom( + backgroundColor: isFavorite + ? Colors.redAccent.withValues(alpha: 0.18) + : scheme.secondaryContainer.withValues(alpha: 0.5), + foregroundColor: + isFavorite ? Colors.redAccent : scheme.onSecondaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: onFavorite, + icon: Icon( + isFavorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + size: 20, + color: isFavorite ? Colors.redAccent : null, + ), + label: Text( + isFavorite ? text.unfavorite : text.favorite, + style: const TextStyle(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ), - if (downloaded && onDeleteLocalFile != null) - IconButton.filledTonal( - tooltip: text.deleteLocalFile, - onPressed: onDeleteLocalFile, - icon: const Icon(Icons.delete_sweep_rounded), + const SizedBox(width: 8), + + // Download Button with Status + if (onDownload != null) ...[ + _ActionButton( + tooltip: downloaded ? text.downloaded : text.download, + icon: isDownloading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: scheme.primary, + ), + ) + : Icon( + downloaded + ? Icons.offline_pin_rounded + : Icons.download_rounded, + color: downloaded ? Colors.green : null, + size: 22, + ), + onTap: onDownload!, + ), + const SizedBox(width: 6), + ], + + // Add to Collection + _ActionButton( + tooltip: text.collection, + icon: const Icon(Icons.bookmark_add_rounded, size: 22), + onTap: onCollection, ), - if (onHide != null) - IconButton.filledTonal( - tooltip: text.hidePost, - onPressed: onHide, - icon: const Icon(Icons.visibility_off_rounded), + const SizedBox(width: 6), + + // Share + if (onShare != null) ...[ + _ActionButton( + tooltip: text.share, + icon: const Icon(Icons.share_rounded, size: 22), + onTap: onShare!, + ), + const SizedBox(width: 6), + ], + + // More Options Bottom Sheet + _ActionButton( + tooltip: text.more, + icon: const Icon(Icons.more_horiz_rounded, size: 22), + onTap: () => _showMoreActions(context, text), ), - if (onShare != null) - IconButton.filledTonal( - tooltip: text.share, - onPressed: onShare, - icon: const Icon(Icons.share_rounded), - ), - ], + ], + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.tooltip, + required this.icon, + required this.onTap, + }); + + final String tooltip; + final Widget icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return IconButton( + tooltip: tooltip, + onPressed: onTap, + icon: icon, + style: IconButton.styleFrom( + backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.35), + foregroundColor: scheme.onSecondaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + minimumSize: const Size(44, 44), + ), ); } } @@ -107,16 +300,18 @@ class PostActionBar extends StatelessWidget { class PostActionLabels { const PostActionLabels({ this.favorite = 'Favorite', - this.unfavorite = 'Unfavorite', + this.unfavorite = 'Saved', this.collection = 'Collection', this.similar = 'Similar', this.openOriginal = 'Open original', this.openSource = 'Open source/page', this.copyLink = 'Copy link', this.download = 'Download', + this.downloaded = 'Downloaded', this.deleteLocalFile = 'Delete local file', this.hidePost = 'Hide post locally', this.share = 'Share', + this.more = 'More', }); final String favorite; @@ -127,7 +322,9 @@ class PostActionLabels { final String openSource; final String copyLink; final String download; + final String downloaded; final String deleteLocalFile; final String hidePost; final String share; + final String more; } diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 130dd99..6b34cb0 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; @@ -17,6 +18,7 @@ final Map _playbackMemory = class PostMediaViewer extends StatefulWidget { const PostMediaViewer({ required this.post, + this.localFilePath, this.fullscreen = false, this.initialPosition = Duration.zero, this.autoplay = false, @@ -33,6 +35,7 @@ class PostMediaViewer extends StatefulWidget { }); final Post post; + final String? localFilePath; final bool fullscreen; final Duration initialPosition; final bool autoplay; @@ -208,23 +211,40 @@ class _PostMediaViewerState extends State } final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; + final isLocal = url.startsWith('/') || url.startsWith('file://'); final headers = _headersFor(widget.post); - final image = CachedNetworkImage( - key: ValueKey(url), - imageUrl: url, - httpHeaders: headers, - fit: BoxFit.contain, - placeholder: (context, url) => - const Center(child: CircularProgressIndicator()), - errorWidget: (context, url, error) { - return _DioImageFallback( - imageUrl: url, + final Widget image; + if (isLocal) { + final cleanPath = + url.startsWith('file://') ? url.replaceFirst('file://', '') : url; + image = Image.file( + File(cleanPath), + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => _DioImageFallback( + imageUrl: _imageUrls.length > 1 ? _imageUrls[1] : '', headers: headers, fit: BoxFit.contain, onFailed: _advanceImageFallback, - ); - }, - ); + ), + ); + } else { + image = CachedNetworkImage( + key: ValueKey(url), + imageUrl: url, + httpHeaders: headers, + fit: BoxFit.contain, + placeholder: (context, url) => + const Center(child: CircularProgressIndicator()), + errorWidget: (context, url, error) { + return _DioImageFallback( + imageUrl: url, + headers: headers, + fit: BoxFit.contain, + onFailed: _advanceImageFallback, + ); + }, + ); + } final child = Stack( alignment: Alignment.center, children: [ @@ -325,10 +345,13 @@ class _PostMediaViewerState extends State try { setState(() => _videoError = null); await player.stop(); + final currentSource = _videoUrls[_videoIndex]; + final isLocal = + currentSource.startsWith('/') || currentSource.startsWith('file://'); await player.open( Media( - _videoUrls[_videoIndex], - httpHeaders: _headersFor(widget.post), + currentSource, + httpHeaders: isLocal ? null : _headersFor(widget.post), ), play: false, ); @@ -349,43 +372,35 @@ class _PostMediaViewerState extends State } // If format error occurred on initial load (often due to async referer headers), retry once automatically if (!_retriedFormatError && - (message.toLowerCase().contains('format') || - message.toLowerCase().contains('recognize'))) { + (message.contains('Failed to recognize file format') || + message.contains('Demuxer error') || + message.contains('Could not open') || + message.contains('Stream format not recognized') || + message.contains('format unrecognized'))) { _retriedFormatError = true; - await Future.delayed(const Duration(milliseconds: 350)); - if (!mounted) return; - _videoIndex = 0; - await _openVideo(play: play); - return; + await Future.delayed(const Duration(milliseconds: 300)); + if (mounted && _player != null) { + _videoIndex = 0; + await _openVideo(play: play); + return; + } + } + if (mounted) { + setState(() => _videoError = message); } - if (!mounted) return; - setState(() => _videoError = message); - _showControls(); } Future _retryVideo() async { - final player = _player; - if (player == null) return; - setState(() { - _videoError = null; - _videoIndex = 0; - _retriedFormatError = false; - _controlsVisible = true; - }); - await player.stop(); + _videoIndex = 0; + _retriedFormatError = false; await _openVideo(play: true); - _showControls(); } void _disposeVideo() { _hideTimer?.cancel(); - _hideTimer = null; _errorSubscription?.cancel(); - _errorSubscription = null; _positionSubscription?.cancel(); - _positionSubscription = null; _playingSubscription?.cancel(); - _playingSubscription = null; final snapshot = _snapshot(); _playbackMemory[widget.post.cacheKey] = snapshot; widget.onPlaybackSnapshot?.call(snapshot); @@ -395,11 +410,21 @@ class _PostMediaViewerState extends State } List _buildImageUrls(Post post) { - return MediaUrlSelector.details(post, mode: widget.qualityMode); + final list = MediaUrlSelector.details(post, mode: widget.qualityMode); + final local = widget.localFilePath; + if (local != null && local.isNotEmpty && File(local).existsSync()) { + return [local, ...list]; + } + return list; } List _buildVideoUrls(Post post) { - return MediaUrlSelector.video(post); + final list = MediaUrlSelector.video(post); + final local = widget.localFilePath; + if (local != null && local.isNotEmpty && File(local).existsSync()) { + return [local, ...list]; + } + return list; } bool _isVideo(Post post) { diff --git a/pubspec.yaml b/pubspec.yaml index 4fbf3a8..3225fa8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.1.0+38 +version: 2.2.0+39 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/features/offline_and_post_redesign_test.dart b/test/features/offline_and_post_redesign_test.dart new file mode 100644 index 0000000..8a245b1 --- /dev/null +++ b/test/features/offline_and_post_redesign_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/downloaded_media.dart'; +import 'package:gel_rule_app/backend/services/downloaded_media_service.dart'; +import 'package:gel_rule_app/features/post/presentation/widgets/post_action_bar.dart'; + +void main() { + group('DownloadedMediaService formatting & utility tests', () { + test('formatBytes formats B, KB, MB, GB properly', () { + expect(DownloadedMediaService.formatBytes(500), '500 B'); + expect(DownloadedMediaService.formatBytes(1536), '1.5 KB'); + expect(DownloadedMediaService.formatBytes(2 * 1024 * 1024), '2.0 MB'); + expect( + DownloadedMediaService.formatBytes( + (2.5 * 1024 * 1024 * 1024).toInt()), + '2.50 GB', + ); + }); + + test('getFileSizeSync handles empty or missing path safely', () { + final media = DownloadedMedia( + cacheKey: 'danbooru:1', + providerId: 'danbooru', + postId: '1', + savedPath: '/non/existent/path.jpg', + fileName: 'path.jpg', + downloadedAt: DateTime(2026, 1, 1), + status: 'completed', + ); + + expect(DownloadedMediaService.getFileSizeSync(media), 0); + }); + }); + + group('PostActionBar Action Dock 2.0 tests', () { + testWidgets('renders primary action dock buttons', (tester) async { + bool favoriteTapped = false; + bool downloadTapped = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PostActionBar( + isFavorite: false, + downloaded: false, + labels: const PostActionLabels(), + onFavorite: () => favoriteTapped = true, + onDownload: () => downloadTapped = true, + onCollection: () {}, + onShare: () {}, + onOpen: () {}, + onOpenSource: () {}, + onCopy: () {}, + onSimilar: () {}, + onHide: () {}, + onDeleteLocalFile: () {}, + ), + ), + ), + ); + + // Verify buttons exist + expect(find.byIcon(Icons.favorite_border_rounded), findsOneWidget); + expect(find.byIcon(Icons.download_rounded), findsOneWidget); + expect(find.byIcon(Icons.bookmark_add_rounded), findsOneWidget); + expect(find.byIcon(Icons.share_rounded), findsOneWidget); + expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); + + // Tap favorite + await tester.tap(find.byIcon(Icons.favorite_border_rounded)); + expect(favoriteTapped, isTrue); + + // Tap download + await tester.tap(find.byIcon(Icons.download_rounded)); + expect(downloadTapped, isTrue); + }); + + testWidgets('shows downloaded status badge when downloaded is true', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PostActionBar( + isFavorite: true, + downloaded: true, + labels: const PostActionLabels(), + onFavorite: () {}, + onDownload: () {}, + onCollection: () {}, + onShare: () {}, + onOpen: () {}, + onOpenSource: () {}, + onCopy: () {}, + onSimilar: () {}, + onHide: () {}, + onDeleteLocalFile: () {}, + ), + ), + ), + ); + + expect(find.byIcon(Icons.favorite_rounded), findsOneWidget); + expect(find.byIcon(Icons.offline_pin_rounded), findsOneWidget); + }); + }); +}