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 @@
-
+
@@ -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);
+ });
+ });
+}