feat(offline+post): Offline Mode Overhaul & Post Details Screen Redesign (v2.2.0)

This commit is contained in:
2026-09-04 11:40:19 +03:00
parent c6a9dcb4d5
commit f603c37c91
11 changed files with 1305 additions and 253 deletions
+3 -1
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-2.1.0-blueviolet?style=for-the-badge" alt="Version 2.1.0" /> <img src="https://img.shields.io/badge/Version-2.2.0-blueviolet?style=for-the-badge" alt="Version 2.2.0" />
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" /> <img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" /> <img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" /> <img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="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**: Hero-панель поиска, быстрые чипы операторов (`and`, `type:video`, `rating:safe`), облако недавних поисков с быстрым удалением крестиком `✕`, карточки популярных категорий и шпаргалка по синтаксису запросов.
- **❤️ Избранное 2.0**: Живые счетчики в сегментах (Все, Офлайн, Авторы), быстрый фильтр по типу медиа (Все / Видео / Фото), мгновенный поиск среди сохраненных работ и карточки авторов в стиле Spotify с мини-коллажем из 3 превью. - **❤️ Избранное 2.0**: Живые счетчики в сегментах (Все, Офлайн, Авторы), быстрый фильтр по типу медиа (Все / Видео / Фото), мгновенный поиск среди сохраненных работ и карточки авторов в стиле Spotify с мини-коллажем из 3 превью.
- **🕒 История 2.0**: Группировка по дням (Сегодня, Вчера, даты), переключение между подробным списком (с 68x68 миниатюрами, бэйджами провайдеров и быстрым удалением из истории) и галерейной Masonry-сеткой, а также диалог подтверждения очистки. - **🕒 История 2.0**: Группировка по дням (Сегодня, Вчера, даты), переключение между подробным списком (с 68x68 миниатюрами, бэйджами провайдеров и быстрым удалением из истории) и галерейной Masonry-сеткой, а также диалог подтверждения очистки.
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.1.0'; const appDisplayVersion = '2.2.0';
const appBuildNumber = 38; const appBuildNumber = 39;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+11
View File
@@ -1,4 +1,15 @@
const lunarisChangelog = [ 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( LunarisChange(
version: '2.1.0', version: '2.1.0',
title: '4-Tab Major Redesign: Search, Favorites, History & Collections', title: '4-Tab Major Redesign: Search, Favorites, History & Collections',
+5
View File
@@ -194,6 +194,11 @@ final downloadedMediaByKeysProvider =
: const {}; : const {};
}); });
final offlineDiskSizeProvider =
FutureProvider.family<int, Iterable<DownloadedMedia>>((ref, items) {
return DownloadedMediaService.totalDiskSizeBytes(items);
});
final downloadedMediaByKeyProvider = final downloadedMediaByKeyProvider =
FutureProvider.family<DownloadedMedia?, String>((ref, cacheKey) async { FutureProvider.family<DownloadedMedia?, String>((ref, cacheKey) async {
final result = final result =
@@ -48,4 +48,39 @@ class DownloadedMediaService {
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles(); Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
Future<Result<int>> count() => _repository.count(); Future<Result<int>> 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<int> totalDiskSizeBytes(Iterable<DownloadedMedia> 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';
}
} }
@@ -13,6 +13,7 @@ import '../../../shared/widgets/post_masonry_grid.dart';
import 'favorites_controller.dart'; import 'favorites_controller.dart';
enum FavoriteMediaType { all, video, image } enum FavoriteMediaType { all, video, image }
enum OfflineSortOption { dateAddedDesc, sizeDesc, artist }
class FavoritesScreen extends ConsumerStatefulWidget { class FavoritesScreen extends ConsumerStatefulWidget {
const FavoritesScreen({super.key}); const FavoritesScreen({super.key});
@@ -25,6 +26,7 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
int _tabIndex = 0; int _tabIndex = 0;
String? _selectedArtist; String? _selectedArtist;
FavoriteMediaType _mediaFilter = FavoriteMediaType.all; FavoriteMediaType _mediaFilter = FavoriteMediaType.all;
OfflineSortOption _offlineSort = OfflineSortOption.dateAddedDesc;
bool _isSearching = false; bool _isSearching = false;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _searchQuery = ''; String _searchQuery = '';
@@ -81,6 +83,17 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
.where((post) => downloaded.containsKey(post.cacheKey)) .where((post) => downloaded.containsKey(post.cacheKey))
.toList(); .toList();
final artistGroups = favoriteArtistAlbums(allPosts); 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 <DownloadTask>[])
: const <DownloadTask>[];
final activeTasks = tasks
.where((t) =>
t.status == DownloadTaskStatus.running ||
t.status == DownloadTaskStatus.queued)
.toList();
return AdaptiveScaffold( return AdaptiveScaffold(
title: isRu ? 'Избранное' : 'Favorites', title: isRu ? 'Избранное' : 'Favorites',
@@ -287,10 +300,13 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
Expanded( Expanded(
child: switch (_tabIndex) { child: switch (_tabIndex) {
1 => _buildOfflineView( 1 => _buildOfflineView(
offlinePosts, offlinePosts: offlinePosts,
settings, allPosts: allPosts,
downloaded.keys.toSet(), downloaded: downloaded,
isRu, diskSizeBytes: diskSizeBytes,
activeTasks: activeTasks,
settings: settings,
isRu: isRu,
), ),
2 => _buildArtistsView( 2 => _buildArtistsView(
allPosts, allPosts,
@@ -353,14 +369,59 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
return _postsGrid(filtered, settings, downloadedKeys); return _postsGrid(filtered, settings, downloadedKeys);
} }
Widget _buildOfflineView( Widget _buildOfflineView({
List<Post> posts, required List<Post> offlinePosts,
AppSettings settings, required List<Post> allPosts,
Set<String> downloadedKeys, required Map<String, DownloadedMedia> downloaded,
bool isRu, required int diskSizeBytes,
) { required List<DownloadTask> activeTasks,
if (posts.isEmpty) { required AppSettings settings,
return Center( required bool isRu,
}) {
final pendingPosts =
allPosts.where((p) => !downloaded.containsKey(p.cacheKey)).toList();
final sorted = List<Post>.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( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@@ -384,32 +445,256 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
isRu ? 'Нет офлайн постов' : 'No offline posts', isRu ? 'Нет офлайн постов' : 'No offline posts',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style:
Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
isRu isRu
? 'Загружайте понравившиеся работы для просмотра без подключения к сети' ? 'Нажмите «Скачать всё», чтобы загрузить избранные посты на устройство'
: 'Download posts to view them anytime without an internet connection', : 'Tap "Download all" above to save favorites for offline viewing',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style:
color: Theme.of(context).colorScheme.onSurfaceVariant, 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()),
),
],
); );
} }
final filtered = _applyFilters(posts);
if (filtered.isEmpty) { Widget _buildOfflineSyncHeader({
return Center( required int downloadedCount,
child: Text(isRu ? 'Ничего не найдено' : 'No matching offline posts'), required int totalCount,
); required int pendingCount,
required int diskSizeBytes,
required List<DownloadTask> activeTasks,
required List<Post> 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(10),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.offline_pin_rounded,
color: scheme.onPrimaryContainer,
size: 22,
),
),
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: 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);
} }
return _postsGrid(filtered, settings, downloadedKeys); 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,
),
),
],
),
),
],
),
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( Widget _buildArtistsView(
@@ -94,6 +94,21 @@ class PostDetailsScreen extends ConsumerWidget {
: null; : null;
final qualityMode = final qualityMode =
MediaQualityMode.fromName(settings.mediaQualityMode); 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 (Responsive.isMobile(context)) {
if (currentIndex >= 0 && feedPosts.length > 1) { if (currentIndex >= 0 && feedPosts.length > 1) {
return _MobilePostPager( return _MobilePostPager(
@@ -111,6 +126,7 @@ class PostDetailsScreen extends ConsumerWidget {
strings, strings,
mediaGestureLocked, mediaGestureLocked,
onMediaGestureLockChanged, onMediaGestureLockChanged,
feedPosts,
), ),
); );
} }
@@ -124,6 +140,7 @@ class PostDetailsScreen extends ConsumerWidget {
strings, strings,
false, false,
null, null,
feedPosts,
); );
} }
return Shortcuts( return Shortcuts(
@@ -207,6 +224,7 @@ class PostDetailsScreen extends ConsumerWidget {
child: PostMediaViewer( child: PostMediaViewer(
key: ValueKey(post.cacheKey), key: ValueKey(post.cacheKey),
post: post, post: post,
localFilePath: localMedia?.savedPath,
qualityMode: qualityMode, qualityMode: qualityMode,
mediaHeaders: ref mediaHeaders: ref
.watch(postMediaHeadersProvider(post)) .watch(postMediaHeadersProvider(post))
@@ -251,11 +269,8 @@ class PostDetailsScreen extends ConsumerWidget {
PostActionBar( PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey), isFavorite: favoriteKeys.contains(post.cacheKey),
labels: _postActionLabels(strings), labels: _postActionLabels(strings),
downloaded: ref downloaded: localMedia != null,
.watch( isDownloading: isDownloading,
downloadedMediaByKeyProvider(post.cacheKey))
.value !=
null,
onFavorite: () => onFavorite: () =>
_toggleFavorite(ref, post, favoriteKeys), _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post), onCollection: () => _addToCollection(context, ref, post),
@@ -273,26 +288,51 @@ class PostDetailsScreen extends ConsumerWidget {
onShare: () => _sharePost(context, ref, post), onShare: () => _sharePost(context, ref, post),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Wrap( _PostInfoCard(
spacing: 8, post: post,
runSpacing: 8, strings: strings,
crossAxisAlignment: WrapCrossAlignment.center, localMedia: localMedia,
fileSizeBytes: fileSizeBytes,
),
const SizedBox(height: 16),
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: [ children: [
Chip(label: Text(post.providerName)), Row(
RatingBadge(rating: post.rating), children: [
Chip(label: Text('${post.width} x ${post.height}')), Icon(
if (post.source != null && post.source!.isNotEmpty) Icons.tag_rounded,
ActionChip( size: 20,
label: Text(strings.source), color: Theme.of(context).colorScheme.primary,
onPressed: () => launchUrl(Uri.parse(post.source!)), ),
const SizedBox(width: 8),
Text(
'${strings.tags} (${post.tags.length})',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
Text(strings.tags,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
PostTagsPanel(post: post), PostTagsPanel(post: post),
],
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
_CommentsSection(post: post), _CommentsSection(post: post),
], ],
@@ -314,9 +354,24 @@ class PostDetailsScreen extends ConsumerWidget {
MediaQualityMode qualityMode, MediaQualityMode qualityMode,
AppStrings strings, AppStrings strings,
bool mediaGestureLocked, bool mediaGestureLocked,
ValueChanged<bool>? onMediaGestureLockChanged, ValueChanged<bool>? onMediaGestureLockChanged, [
) { List<Post>? feedPosts,
]) {
final isVideo = MediaUrlSelector.isVideo(post); 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( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) { onVerticalDragEnd: (details) {
@@ -340,6 +395,7 @@ class PostDetailsScreen extends ConsumerWidget {
child: PostMediaViewer( child: PostMediaViewer(
key: ValueKey(post.cacheKey), key: ValueKey(post.cacheKey),
post: post, post: post,
localFilePath: localMedia?.savedPath,
qualityMode: qualityMode, qualityMode: qualityMode,
mediaHeaders: mediaHeaders:
ref.watch(postMediaHeadersProvider(post)).value ?? const {}, ref.watch(postMediaHeadersProvider(post)).value ?? const {},
@@ -365,9 +421,8 @@ class PostDetailsScreen extends ConsumerWidget {
PostActionBar( PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey), isFavorite: favoriteKeys.contains(post.cacheKey),
labels: _postActionLabels(strings), labels: _postActionLabels(strings),
downloaded: downloaded: localMedia != null,
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value != isDownloading: isDownloading,
null,
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post), onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)), onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
@@ -382,26 +437,53 @@ class PostDetailsScreen extends ConsumerWidget {
onShare: () => _sharePost(context, ref, post), onShare: () => _sharePost(context, ref, post),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( _PostInfoCard(
spacing: 8, post: post,
runSpacing: 8, strings: strings,
children: [ localMedia: localMedia,
Chip(label: Text(post.providerName)), fileSizeBytes: fileSizeBytes,
RatingBadge(rating: post.rating), ),
Chip(label: Text('${post.width} x ${post.height}')), if (feedPosts != null && feedPosts.length > 1) ...[
if (post.source != null && post.source!.isNotEmpty) const SizedBox(height: 12),
ActionChip( _NeighborStrip(
label: Text(strings.source), posts: feedPosts,
onPressed: () => launchUrl(Uri.parse(post.source!)), 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),
), ),
const SizedBox(height: 8), ),
ExpansionTile( clipBehavior: Clip.antiAlias,
tilePadding: EdgeInsets.zero, child: ExpansionTile(
tilePadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
initiallyExpanded: false, initiallyExpanded: false,
title: Text(strings.tags, shape: const Border(),
style: Theme.of(context).textTheme.titleMedium), 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: [ children: [
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
@@ -409,6 +491,8 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
], ],
), ),
),
const SizedBox(height: 12),
_CommentsSection(post: post), _CommentsSection(post: post),
], ],
), ),
@@ -939,53 +1023,355 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
), ),
) )
: null; : null;
return ExpansionTile( final scheme = Theme.of(context).colorScheme;
tilePadding: EdgeInsets.zero,
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, initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
leading: Icon(
Icons.chat_bubble_outline_rounded,
color: scheme.primary,
),
onExpansionChanged: (value) => setState(() => _expanded = value), onExpansionChanged: (value) => setState(() => _expanded = value),
title: Text(strings.comments, title: Text(
style: Theme.of(context).textTheme.titleMedium), strings.comments,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [ children: [
(comments ?? const AsyncValue<List<PostComment>>.data([])).when( (comments ?? const AsyncValue<List<PostComment>>.data([])).when(
loading: () => const Padding( loading: () => const Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(16),
child: CircularProgressIndicator(), child: Center(child: CircularProgressIndicator()),
), ),
error: (_, __) => Align( error: (_, __) => Padding(
alignment: Alignment.centerLeft, padding: const EdgeInsets.all(12),
child: Padding( child: Text(
padding: const EdgeInsets.only(bottom: 12), strings.commentsUnavailable,
child: Text(strings.commentsUnavailable), style: TextStyle(color: scheme.outline),
), ),
), ),
data: (items) { data: (items) {
if (items.isEmpty) { if (items.isEmpty) {
return Align( return Padding(
alignment: Alignment.centerLeft, padding: const EdgeInsets.all(12),
child: Padding( child: Text(
padding: const EdgeInsets.only(bottom: 12), strings.noComments,
child: Text(strings.noComments), style: TextStyle(color: scheme.onSurfaceVariant),
), ),
); );
} }
return Column( return Column(
children: [ children: [
for (final comment in items) for (final comment in items)
ListTile( Container(
dense: true, margin: const EdgeInsets.only(bottom: 8),
contentPadding: EdgeInsets.zero, padding: const EdgeInsets.all(10),
title: Text( 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 comment.authorName.isEmpty
? strings.anonymous ? strings.anonymous
: comment.authorName, : 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,
),
],
),
),
],
), ),
subtitle: Text(comment.body),
), ),
], ],
); );
}, },
), ),
], ],
),
);
}
}
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,
),
),
],
),
); );
} }
} }
@@ -12,6 +12,7 @@ class PostActionBar extends StatelessWidget {
this.onDeleteLocalFile, this.onDeleteLocalFile,
this.onShare, this.onShare,
this.downloaded = false, this.downloaded = false,
this.isDownloading = false,
this.labels, this.labels,
this.onSimilar, this.onSimilar,
this.onHide, this.onHide,
@@ -30,76 +31,268 @@ class PostActionBar extends StatelessWidget {
final VoidCallback? onSimilar; final VoidCallback? onSimilar;
final VoidCallback? onHide; final VoidCallback? onHide;
final bool downloaded; final bool downloaded;
final bool isDownloading;
final PostActionLabels? labels; final PostActionLabels? labels;
void _showMoreActions(BuildContext context, PostActionLabels text) {
final scheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final text = labels ?? const PostActionLabels(); final text = labels ?? const PostActionLabels();
return Wrap(
spacing: 8, return Container(
runSpacing: 8, 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),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
FilledButton.icon( // 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, onPressed: onFavorite,
icon: Icon( icon: Icon(
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded, isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 20,
color: isFavorite ? Colors.redAccent : null,
), ),
label: Text(isFavorite ? text.unfavorite : text.favorite), label: Text(
), isFavorite ? text.unfavorite : text.favorite,
FilledButton.tonalIcon( style: const TextStyle(fontWeight: FontWeight.w600),
onPressed: onCollection, maxLines: 1,
icon: const Icon(Icons.add_rounded), overflow: TextOverflow.ellipsis,
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,
), ),
), ),
if (downloaded && onDeleteLocalFile != null)
IconButton.filledTonal(
tooltip: text.deleteLocalFile,
onPressed: onDeleteLocalFile,
icon: const Icon(Icons.delete_sweep_rounded),
), ),
if (onHide != null) const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: text.hidePost, // Download Button with Status
onPressed: onHide, if (onDownload != null) ...[
icon: const Icon(Icons.visibility_off_rounded), _ActionButton(
tooltip: downloaded ? text.downloaded : text.download,
icon: isDownloading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.primary,
), ),
if (onShare != null) )
IconButton.filledTonal( : 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,
),
const SizedBox(width: 6),
// Share
if (onShare != null) ...[
_ActionButton(
tooltip: text.share, tooltip: text.share,
onPressed: onShare, icon: const Icon(Icons.share_rounded, size: 22),
icon: const Icon(Icons.share_rounded), 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),
), ),
], ],
),
);
}
}
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 { class PostActionLabels {
const PostActionLabels({ const PostActionLabels({
this.favorite = 'Favorite', this.favorite = 'Favorite',
this.unfavorite = 'Unfavorite', this.unfavorite = 'Saved',
this.collection = 'Collection', this.collection = 'Collection',
this.similar = 'Similar', this.similar = 'Similar',
this.openOriginal = 'Open original', this.openOriginal = 'Open original',
this.openSource = 'Open source/page', this.openSource = 'Open source/page',
this.copyLink = 'Copy link', this.copyLink = 'Copy link',
this.download = 'Download', this.download = 'Download',
this.downloaded = 'Downloaded',
this.deleteLocalFile = 'Delete local file', this.deleteLocalFile = 'Delete local file',
this.hidePost = 'Hide post locally', this.hidePost = 'Hide post locally',
this.share = 'Share', this.share = 'Share',
this.more = 'More',
}); });
final String favorite; final String favorite;
@@ -127,7 +322,9 @@ class PostActionLabels {
final String openSource; final String openSource;
final String copyLink; final String copyLink;
final String download; final String download;
final String downloaded;
final String deleteLocalFile; final String deleteLocalFile;
final String hidePost; final String hidePost;
final String share; final String share;
final String more;
} }
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -17,6 +18,7 @@ final Map<String, VideoPlaybackSnapshot> _playbackMemory =
class PostMediaViewer extends StatefulWidget { class PostMediaViewer extends StatefulWidget {
const PostMediaViewer({ const PostMediaViewer({
required this.post, required this.post,
this.localFilePath,
this.fullscreen = false, this.fullscreen = false,
this.initialPosition = Duration.zero, this.initialPosition = Duration.zero,
this.autoplay = false, this.autoplay = false,
@@ -33,6 +35,7 @@ class PostMediaViewer extends StatefulWidget {
}); });
final Post post; final Post post;
final String? localFilePath;
final bool fullscreen; final bool fullscreen;
final Duration initialPosition; final Duration initialPosition;
final bool autoplay; final bool autoplay;
@@ -208,8 +211,24 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
final isLocal = url.startsWith('/') || url.startsWith('file://');
final headers = _headersFor(widget.post); final headers = _headersFor(widget.post);
final image = CachedNetworkImage( 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), key: ValueKey(url),
imageUrl: url, imageUrl: url,
httpHeaders: headers, httpHeaders: headers,
@@ -225,6 +244,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
); );
}, },
); );
}
final child = Stack( final child = Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
@@ -325,10 +345,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
try { try {
setState(() => _videoError = null); setState(() => _videoError = null);
await player.stop(); await player.stop();
final currentSource = _videoUrls[_videoIndex];
final isLocal =
currentSource.startsWith('/') || currentSource.startsWith('file://');
await player.open( await player.open(
Media( Media(
_videoUrls[_videoIndex], currentSource,
httpHeaders: _headersFor(widget.post), httpHeaders: isLocal ? null : _headersFor(widget.post),
), ),
play: false, play: false,
); );
@@ -349,43 +372,35 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
// If format error occurred on initial load (often due to async referer headers), retry once automatically // If format error occurred on initial load (often due to async referer headers), retry once automatically
if (!_retriedFormatError && if (!_retriedFormatError &&
(message.toLowerCase().contains('format') || (message.contains('Failed to recognize file format') ||
message.toLowerCase().contains('recognize'))) { message.contains('Demuxer error') ||
message.contains('Could not open') ||
message.contains('Stream format not recognized') ||
message.contains('format unrecognized'))) {
_retriedFormatError = true; _retriedFormatError = true;
await Future<void>.delayed(const Duration(milliseconds: 350)); await Future<void>.delayed(const Duration(milliseconds: 300));
if (!mounted) return; if (mounted && _player != null) {
_videoIndex = 0; _videoIndex = 0;
await _openVideo(play: play); await _openVideo(play: play);
return; return;
} }
if (!mounted) return; }
if (mounted) {
setState(() => _videoError = message); setState(() => _videoError = message);
_showControls(); }
} }
Future<void> _retryVideo() async { Future<void> _retryVideo() async {
final player = _player;
if (player == null) return;
setState(() {
_videoError = null;
_videoIndex = 0; _videoIndex = 0;
_retriedFormatError = false; _retriedFormatError = false;
_controlsVisible = true;
});
await player.stop();
await _openVideo(play: true); await _openVideo(play: true);
_showControls();
} }
void _disposeVideo() { void _disposeVideo() {
_hideTimer?.cancel(); _hideTimer?.cancel();
_hideTimer = null;
_errorSubscription?.cancel(); _errorSubscription?.cancel();
_errorSubscription = null;
_positionSubscription?.cancel(); _positionSubscription?.cancel();
_positionSubscription = null;
_playingSubscription?.cancel(); _playingSubscription?.cancel();
_playingSubscription = null;
final snapshot = _snapshot(); final snapshot = _snapshot();
_playbackMemory[widget.post.cacheKey] = snapshot; _playbackMemory[widget.post.cacheKey] = snapshot;
widget.onPlaybackSnapshot?.call(snapshot); widget.onPlaybackSnapshot?.call(snapshot);
@@ -395,11 +410,21 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
List<String> _buildImageUrls(Post post) { List<String> _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<String> _buildVideoUrls(Post post) { List<String> _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) { bool _isVideo(Post post) {
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.1.0+38 version: 2.2.0+39
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
@@ -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);
});
});
}