Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f603c37c91 | ||
|
|
c6a9dcb4d5 | ||
|
|
b33248ef9d | ||
|
|
1b08194666 |
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-2.0.13-blueviolet?style=for-the-badge" alt="Version 2.0.13" />
|
<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,14 @@
|
|||||||
|
|
||||||
## ✨ Основные возможности
|
## ✨ Основные возможности
|
||||||
|
|
||||||
|
- **💾 Офлайн 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-сеткой, а также диалог подтверждения очистки.
|
||||||
|
- **📁 Коллекции 2.0**: Мозаичные обложки 2x2 с превью постов, счетчики работ со стеклянным бейджем, современный экран деталей коллекции и обновленный диалог создания.
|
||||||
|
- **⚙️ Settings 2.0**: Полный редизайн настроек с современными squircle-карточками, горизонтальной быстрой навигацией по разделам, мгновенными Segmented-кнопками выбора темы/языка/качества и стильной карточкой о приложении.
|
||||||
|
|
||||||
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
|
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
|
||||||
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
|
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
|
||||||
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
|
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.13';
|
const appDisplayVersion = '2.2.0';
|
||||||
const appBuildNumber = 35;
|
const appBuildNumber = 39;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,48 @@
|
|||||||
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(
|
||||||
|
version: '2.1.0',
|
||||||
|
title: '4-Tab Major Redesign: Search, Favorites, History & Collections',
|
||||||
|
bullets: [
|
||||||
|
'Search Tab Redesign: hero search container, quick search operator chips (and, type:video, rating:safe, etc.), interactive recent search cloud with 1-tap delete (x), popular exploratory category cards, and search tips cheat sheet.',
|
||||||
|
'Favorites Tab Redesign: live counters on segment tabs (All, Offline, Artists), quick media filter chips (All, Videos, Images), in-favorites search bar, and Spotify/Apple Music-style artist album cards with 3-thumbnail preview collages.',
|
||||||
|
'History Tab Redesign: timeline grouped by time (Today, Yesterday, Date), view mode switcher (rich list with 68x68 rounded thumbnails, provider chips, timestamps, and delete from history vs full visual Masonry gallery grid), and confirmation dialog before clearing.',
|
||||||
|
'Collections Tab Redesign: Pinterest-style collection cards with 2x2 thumbnail mosaic covers and glassmorphism post counters, modernized collection details screen with description banner, and elegant squircle creation dialog.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.15',
|
||||||
|
title: 'Settings 2.0 Layout Fixes & Pinned Quick Navigation',
|
||||||
|
bullets: [
|
||||||
|
'Fixed vertical text wrapping on mobile: separated title and full-width SegmentedButtons for Theme, Language, and Media Quality so text never collapses.',
|
||||||
|
'Pinned Category Quick Navigation: bar now stays at the top of the screen and reliably scrolls to all 7 sections.',
|
||||||
|
'SingleChildScrollView Migration: ensures all section keys remain mounted and scrollable on demand.',
|
||||||
|
'Full-width Folder Structure Tile: added clean monospace path card with dedicated change button.',
|
||||||
|
'Header text overflow protections: wrapped title texts in Expanded across all settings cards.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.14',
|
||||||
|
title: 'Settings 2.0: Modern Grouped Design & Quick Ergonomics',
|
||||||
|
bullets: [
|
||||||
|
'Settings 2.0 Layout: reimagined settings experience with modern squircle card groups, smooth borders, and curated accent badges.',
|
||||||
|
'Category Quick Navigation: horizontal capsule strip at the top to smoothly jump between General, Appearance, Feed, Filters, Storage, Diagnostics, and About.',
|
||||||
|
'Instant Segmented Controls: replaced tedious dropdowns with fast 1-tap SegmentedButtons for Theme (Dark/Light/Auto), Language (RU/EN), and Media Quality (Auto/Data saver/HQ).',
|
||||||
|
'Folder Structure Bottom Sheet: intuitive download directory template picker with preset chips and custom pattern editor.',
|
||||||
|
'Interactive Stepper & Limit Controls: modernized desktop & mobile column steppers and disk cache limits with accent pill badges.',
|
||||||
|
'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and Gitea repository link.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.0.13',
|
version: '2.0.13',
|
||||||
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
|
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ class SearchRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<bool>> delete(String historyId) {
|
||||||
|
return _databaseService.safeWrite((isar) async {
|
||||||
|
return await isar.searchHistoryEntitys.deleteByHistoryId(historyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<void>> clear() {
|
Future<Result<void>> clear() {
|
||||||
return _databaseService.safeWrite((isar) async {
|
return _databaseService.safeWrite((isar) async {
|
||||||
await isar.searchHistoryEntitys.clear();
|
await isar.searchHistoryEntitys.clear();
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ class ViewedPostRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||||
|
return _databaseService.safeWrite((isar) async {
|
||||||
|
await isar.viewedPostEntitys.deleteByViewedKey(
|
||||||
|
ViewedPost.keyFor(providerId, postId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<void>> clear() {
|
Future<Result<void>> clear() {
|
||||||
return _databaseService.safeWrite((isar) async {
|
return _databaseService.safeWrite((isar) async {
|
||||||
await isar.viewedPostEntitys.clear();
|
await isar.viewedPostEntitys.clear();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ class SearchService {
|
|||||||
return Success(sorted.take(limit).toList(growable: false));
|
return Success(sorted.take(limit).toList(growable: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<bool>> deleteHistoryItem(String historyId) =>
|
||||||
|
_repository.delete(historyId);
|
||||||
|
|
||||||
Future<Result<void>> clearHistory() => _repository.clear();
|
Future<Result<void>> clearHistory() => _repository.clear();
|
||||||
|
|
||||||
Future<void> clearTagCache() async {
|
Future<void> clearTagCache() async {
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class ViewedHistoryService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||||
|
return _repository.deleteItem(providerId, postId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<void>> clearHistory() {
|
Future<Result<void>> clearHistory() {
|
||||||
return _repository.clear();
|
return _repository.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import '../../../app/app.dart';
|
|||||||
import '../../../app/responsive.dart';
|
import '../../../app/responsive.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
import '../../favorites/presentation/favorites_controller.dart';
|
import '../../favorites/presentation/favorites_controller.dart';
|
||||||
|
import 'collection_form_dialog.dart';
|
||||||
import 'collections_controller.dart';
|
import 'collections_controller.dart';
|
||||||
|
|
||||||
class CollectionDetailsScreen extends ConsumerWidget {
|
class CollectionDetailsScreen extends ConsumerWidget {
|
||||||
@@ -19,24 +20,141 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final posts = ref.watch(collectionPostsProvider(collectionId));
|
final postsAsync = ref.watch(collectionPostsProvider(collectionId));
|
||||||
final collections = ref.watch(collectionsControllerProvider).valueOrNull;
|
final collections = ref.watch(collectionsControllerProvider).valueOrNull;
|
||||||
final title = collections
|
final collection = collections
|
||||||
?.where((collection) => collection.id == collectionId)
|
?.where((c) => c.id == collectionId)
|
||||||
.firstOrNull
|
.firstOrNull;
|
||||||
?.name ??
|
|
||||||
'Collection';
|
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: title,
|
title: collection?.name ?? (isRu ? 'Коллекция' : 'Collection'),
|
||||||
body: posts.when(
|
actions: [
|
||||||
|
if (collection != null) ...[
|
||||||
|
IconButton(
|
||||||
|
tooltip: isRu ? 'Редактировать' : 'Edit',
|
||||||
|
icon: const Icon(Icons.edit_rounded),
|
||||||
|
onPressed: () => showCollectionFormDialog(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
collection: collection,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: isRu ? 'Удалить' : 'Delete',
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
|
onPressed: () async {
|
||||||
|
final ok = await showConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||||
|
message: isRu
|
||||||
|
? 'Удалить подборку "${collection.name}"?'
|
||||||
|
: 'Remove "${collection.name}"?',
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
await ref
|
||||||
|
.read(collectionsControllerProvider.notifier)
|
||||||
|
.delete(collection.id);
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
body: postsAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (items) => items.isEmpty
|
data: (items) {
|
||||||
? const EmptyView(title: 'Collection is empty')
|
if (items.isEmpty) {
|
||||||
: PostMasonryGrid(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'В коллекции пока нет постов' : 'Collection is empty',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Добавляйте посты из ленты через меню «Добавить в коллекцию» на карточке поста'
|
||||||
|
: 'Add posts to this collection using "Add to collection" from post cards in the feed',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.go('/feed'),
|
||||||
|
icon: const Icon(Icons.explore_rounded),
|
||||||
|
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (collection?.description != null &&
|
||||||
|
collection!.description!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
collection.description!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: PostMasonryGrid(
|
||||||
posts: items,
|
posts: items,
|
||||||
columns: Responsive.columnsFor(
|
columns: Responsive.columnsFor(
|
||||||
context,
|
context,
|
||||||
@@ -74,6 +192,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../app/app.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import 'collections_controller.dart';
|
import 'collections_controller.dart';
|
||||||
|
|
||||||
@@ -9,31 +10,91 @@ Future<void> showCollectionFormDialog(
|
|||||||
WidgetRef ref, {
|
WidgetRef ref, {
|
||||||
Collection? collection,
|
Collection? collection,
|
||||||
}) async {
|
}) async {
|
||||||
|
final settings =
|
||||||
|
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
final nameController = TextEditingController(text: collection?.name);
|
final nameController = TextEditingController(text: collection?.name);
|
||||||
final descriptionController =
|
final descriptionController =
|
||||||
TextEditingController(text: collection?.description);
|
TextEditingController(text: collection?.description);
|
||||||
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) {
|
||||||
title: Text(collection == null ? 'New collection' : 'Edit collection'),
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(24, 16, 24, 20),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
collection == null
|
||||||
|
? Icons.create_new_folder_rounded
|
||||||
|
: Icons.edit_note_rounded,
|
||||||
|
color: scheme.onPrimaryContainer,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
collection == null
|
||||||
|
? (isRu ? 'Новая коллекция' : 'New collection')
|
||||||
|
: (isRu ? 'Редактировать' : 'Edit collection'),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(labelText: 'Name'),
|
autofocus: collection == null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: isRu ? 'Название' : 'Name',
|
||||||
|
hintText: isRu ? 'Например, Избранные обои' : 'e.g. Best Wallpapers',
|
||||||
|
prefixIcon: const Icon(Icons.bookmark_border_rounded),
|
||||||
|
filled: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
TextField(
|
TextField(
|
||||||
controller: descriptionController,
|
controller: descriptionController,
|
||||||
decoration: const InputDecoration(labelText: 'Description'),
|
maxLines: 2,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: isRu ? 'Описание (необязательно)' : 'Description (optional)',
|
||||||
|
hintText: isRu ? 'О чем эта подборка' : 'What is this collection about',
|
||||||
|
prefixIcon: const Icon(Icons.description_outlined),
|
||||||
|
filled: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(isRu ? 'Отмена' : 'Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -55,10 +116,11 @@ Future<void> showCollectionFormDialog(
|
|||||||
}
|
}
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text('Save'),
|
child: Text(isRu ? 'Сохранить' : 'Save'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
nameController.dispose();
|
nameController.dispose();
|
||||||
descriptionController.dispose();
|
descriptionController.dispose();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../backend/models/collection.dart';
|
import '../../../app/app.dart';
|
||||||
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/confirm_dialog.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import 'collection_form_dialog.dart';
|
import 'collection_form_dialog.dart';
|
||||||
import 'collections_controller.dart';
|
import 'collections_controller.dart';
|
||||||
@@ -16,11 +17,15 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final collections = ref.watch(collectionsControllerProvider);
|
final collections = ref.watch(collectionsControllerProvider);
|
||||||
|
final settings =
|
||||||
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Collections',
|
title: isRu ? 'Коллекции' : 'Collections',
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'New collection',
|
tooltip: isRu ? 'Новая коллекция' : 'New collection',
|
||||||
icon: const Icon(Icons.add_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
onPressed: () => showCollectionFormDialog(context, ref),
|
onPressed: () => showCollectionFormDialog(context, ref),
|
||||||
),
|
),
|
||||||
@@ -28,21 +33,76 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
body: collections.when(
|
body: collections.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (items) => items.isEmpty
|
data: (items) {
|
||||||
? const EmptyView(title: 'No collections yet')
|
if (items.isEmpty) {
|
||||||
: GridView.builder(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'Нет коллекций' : 'No collections yet',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Создавайте тематические коллекции и объединяйте в них понравившиеся работы'
|
||||||
|
: 'Create themed collections and organize your favorite posts',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => showCollectionFormDialog(context, ref),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: Text(
|
||||||
|
isRu ? 'Создать коллекцию' : 'Create collection',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 320,
|
maxCrossAxisExtent: 320,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 14,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 14,
|
||||||
childAspectRatio: 1.35,
|
childAspectRatio: 0.88,
|
||||||
),
|
),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
return _CollectionCard(
|
return _CollectionCard(
|
||||||
collection: item,
|
collection: item,
|
||||||
|
isRu: isRu,
|
||||||
onOpen: () => context.go('/collections/${item.id}'),
|
onOpen: () => context.go('/collections/${item.id}'),
|
||||||
onEdit: () => showCollectionFormDialog(
|
onEdit: () => showCollectionFormDialog(
|
||||||
context,
|
context,
|
||||||
@@ -52,8 +112,10 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
onDelete: () async {
|
onDelete: () async {
|
||||||
final ok = await showConfirmDialog(
|
final ok = await showConfirmDialog(
|
||||||
context,
|
context,
|
||||||
title: 'Delete collection',
|
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||||
message: 'Remove ${item.name}?',
|
message: isRu
|
||||||
|
? 'Удалить подборку "${item.name}"?'
|
||||||
|
: 'Remove "${item.name}"?',
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await ref
|
await ref
|
||||||
@@ -63,113 +125,225 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CollectionCard extends StatelessWidget {
|
class _CollectionCard extends ConsumerWidget {
|
||||||
const _CollectionCard({
|
const _CollectionCard({
|
||||||
required this.collection,
|
required this.collection,
|
||||||
|
required this.isRu,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Collection collection;
|
final Collection collection;
|
||||||
|
final bool isRu;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final postsAsync = ref.watch(collectionPostsProvider(collection.id));
|
||||||
|
final posts = postsAsync.value ?? const <Post>[];
|
||||||
|
final previewUrls = posts
|
||||||
|
.map((p) => MediaUrlSelector.preview(p).firstOrNull)
|
||||||
|
.whereType<String>()
|
||||||
|
.take(4)
|
||||||
|
.toList();
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: scheme.surfaceContainerLow,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
side: BorderSide(
|
||||||
|
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onOpen,
|
onTap: onOpen,
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
// Cover Collage (2x2 or Hero)
|
||||||
height: 54,
|
Expanded(
|
||||||
child: Align(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
fit: StackFit.expand,
|
||||||
child: DecoratedBox(
|
children: [
|
||||||
|
_buildCoverMosaic(previewUrls, scheme),
|
||||||
|
// Glass count badge
|
||||||
|
Positioned(
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
scheme.primaryContainer.withValues(alpha: 0.56),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: Row(
|
||||||
padding: EdgeInsets.all(12),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Icon(
|
children: [
|
||||||
Icons.collections_bookmark_rounded,
|
const Icon(
|
||||||
size: 28,
|
Icons.photo_library_outlined,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
Text(
|
||||||
collection.name,
|
'${posts.length}',
|
||||||
maxLines: 1,
|
style: const TextStyle(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: Colors.white,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
fontSize: 11,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
collection.description ?? 'No description',
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
],
|
||||||
top: 6,
|
),
|
||||||
right: 6,
|
),
|
||||||
child: PopupMenuButton<String>(
|
// Info Row
|
||||||
tooltip: 'Collection actions',
|
Padding(
|
||||||
icon: const Icon(Icons.more_horiz_rounded),
|
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
collection.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (collection.description != null &&
|
||||||
|
collection.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
collection.description!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
tooltip: isRu ? 'Действия' : 'Actions',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.more_vert_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
if (value == 'edit') onEdit();
|
if (value == 'edit') onEdit();
|
||||||
if (value == 'delete') onDelete();
|
if (value == 'delete') onDelete();
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => const [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'edit',
|
value: 'edit',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: Icon(Icons.edit_rounded),
|
leading: const Icon(Icons.edit_rounded, size: 20),
|
||||||
title: Text('Edit'),
|
title: Text(isRu ? 'Редактировать' : 'Edit'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: Icon(Icons.delete_rounded),
|
leading: const Icon(Icons.delete_rounded, size: 20),
|
||||||
title: Text('Delete'),
|
title: Text(isRu ? 'Удалить' : 'Delete'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCoverMosaic(List<String> urls, ColorScheme scheme) {
|
||||||
|
if (urls.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
scheme.primaryContainer.withValues(alpha: 0.6),
|
||||||
|
scheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: scheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.length == 1) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: urls[0],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.broken_image_rounded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mosaic grid (up to 4)
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
if (i < urls.length)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: urls[i],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) =>
|
||||||
|
Container(color: scheme.surfaceContainerHighest),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.image_not_supported_rounded, size: 16),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(color: scheme.surfaceContainerHighest),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ class SearchController extends AsyncNotifier<SearchState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteHistory(String historyId) async {
|
||||||
|
await ref.read(searchServiceProvider).deleteHistoryItem(historyId);
|
||||||
|
final recent = await _recent();
|
||||||
|
state = AsyncData(
|
||||||
|
(state.value ?? const SearchState()).copyWith(recent: recent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearHistory() async {
|
Future<void> clearHistory() async {
|
||||||
await ref.read(searchServiceProvider).clearHistory();
|
await ref.read(searchServiceProvider).clearHistory();
|
||||||
state = const AsyncData(SearchState());
|
state = const AsyncData(SearchState());
|
||||||
|
|||||||
@@ -2,39 +2,103 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../app/app.dart';
|
||||||
|
import '../../../app/app_strings.dart';
|
||||||
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/app_search_bar.dart';
|
import '../../../shared/widgets/app_search_bar.dart';
|
||||||
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import 'search_controller.dart';
|
import 'search_controller.dart';
|
||||||
import 'widgets/recent_searches.dart';
|
import 'widgets/recent_searches.dart';
|
||||||
|
|
||||||
class SearchScreen extends ConsumerWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
|
bool _showTips = false;
|
||||||
|
|
||||||
|
void _onTagTap(String tag, String currentQuery) {
|
||||||
|
final trimmed = currentQuery.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
|
||||||
|
} else {
|
||||||
|
final newQuery = '$trimmed $tag';
|
||||||
|
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||||
|
context.go('/?q=${Uri.encodeQueryComponent(newQuery)}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _appendToken(String token, String currentQuery) {
|
||||||
|
final trimmed = currentQuery.trim();
|
||||||
|
final newQuery = trimmed.isEmpty ? token : '$trimmed $token';
|
||||||
|
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(searchControllerProvider);
|
final state = ref.watch(searchControllerProvider);
|
||||||
|
final settings =
|
||||||
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final strings = AppStrings(settings.languageCode);
|
||||||
|
final isRu = strings.ru;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Search',
|
title: strings.search,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear history',
|
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||||
onPressed: () =>
|
onPressed: () async {
|
||||||
ref.read(searchControllerProvider.notifier).clearHistory(),
|
final ok = await showConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: isRu ? 'Очистить историю поиска?' : 'Clear search history?',
|
||||||
|
message: isRu
|
||||||
|
? 'Все недавние поисковые запросы будут удалены.'
|
||||||
|
: 'All recent search queries will be removed.',
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
await ref
|
||||||
|
.read(searchControllerProvider.notifier)
|
||||||
|
.clearHistory();
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: const Icon(Icons.delete_sweep_rounded),
|
icon: const Icon(Icons.delete_sweep_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
body: state.when(
|
body: state.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (data) => ListView(
|
data: (data) => Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 860),
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
children: [
|
||||||
|
// 1. Search Bar with Quick Operators
|
||||||
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TagInputSearchBar(
|
TagInputSearchBar(
|
||||||
initialValue: data.query,
|
initialValue: data.query,
|
||||||
suggestions: data.suggestions,
|
suggestions: data.suggestions,
|
||||||
onSubmitted: (query) =>
|
onSubmitted: (query) => context
|
||||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||||
onChanged: (query) => ref
|
onChanged: (query) => ref
|
||||||
.read(searchControllerProvider.notifier)
|
.read(searchControllerProvider.notifier)
|
||||||
.updateQuery(query),
|
.updateQuery(query),
|
||||||
@@ -42,18 +106,497 @@ class SearchScreen extends ConsumerWidget {
|
|||||||
.read(searchControllerProvider.notifier)
|
.read(searchControllerProvider.notifier)
|
||||||
.updateQuery(query),
|
.updateQuery(query),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'and',
|
||||||
|
icon: Icons.alt_route_rounded,
|
||||||
|
color: const Color(0xFFF59E0B),
|
||||||
|
onTap: () => _appendToken('and', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'type:video',
|
||||||
|
icon: Icons.videocam_rounded,
|
||||||
|
color: const Color(0xFF3B82F6),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('type:video', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'type:gif',
|
||||||
|
icon: Icons.gif_rounded,
|
||||||
|
color: const Color(0xFF8B5CF6),
|
||||||
|
onTap: () => _appendToken('type:gif', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'rating:safe',
|
||||||
|
icon: Icons.verified_user_rounded,
|
||||||
|
color: const Color(0xFF10B981),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('rating:safe', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'rating:explicit',
|
||||||
|
icon: Icons.explicit_rounded,
|
||||||
|
color: const Color(0xFFEF4444),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('rating:explicit', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'score:>100',
|
||||||
|
icon: Icons.star_rounded,
|
||||||
|
color: const Color(0xFFF97316),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('score:>100', data.query),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text('Recent', style: Theme.of(context).textTheme.titleLarge),
|
// 2. Recent Searches Card
|
||||||
const SizedBox(height: 8),
|
_SearchSectionCard(
|
||||||
RecentSearches(
|
icon: Icons.history_rounded,
|
||||||
|
accentColor: const Color(0xFF6366F1),
|
||||||
|
title: isRu ? 'Недавние запросы' : 'Recent searches',
|
||||||
|
badgeCount: data.recent.length,
|
||||||
|
trailing: data.recent.isEmpty
|
||||||
|
? null
|
||||||
|
: TextButton(
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(searchControllerProvider.notifier)
|
||||||
|
.clearHistory(),
|
||||||
|
child: Text(
|
||||||
|
isRu ? 'Очистить' : 'Clear',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: RecentSearches(
|
||||||
items: data.recent,
|
items: data.recent,
|
||||||
onTap: (query) =>
|
onTap: (query) =>
|
||||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||||
|
onDelete: (id) => ref
|
||||||
|
.read(searchControllerProvider.notifier)
|
||||||
|
.deleteHistory(id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 3. Explore Popular Categories & Tags
|
||||||
|
_SearchSectionCard(
|
||||||
|
icon: Icons.explore_rounded,
|
||||||
|
accentColor: const Color(0xFF10B981),
|
||||||
|
title: isRu ? 'Популярные категории' : 'Explore categories',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_CategoryTagRow(
|
||||||
|
categoryName: isRu ? 'Тематика' : 'Theme & Ambience',
|
||||||
|
color: const Color(0xFF0EA5E9),
|
||||||
|
tags: const [
|
||||||
|
'scenery',
|
||||||
|
'cyberpunk',
|
||||||
|
'monochrome',
|
||||||
|
'night',
|
||||||
|
'fantasy',
|
||||||
|
'original',
|
||||||
|
],
|
||||||
|
onTap: (tag) => _onTagTap(tag, data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_CategoryTagRow(
|
||||||
|
categoryName:
|
||||||
|
isRu ? 'Персонажи и детали' : 'Characters & Attire',
|
||||||
|
color: const Color(0xFFEC4899),
|
||||||
|
tags: const [
|
||||||
|
'1girl',
|
||||||
|
'solo',
|
||||||
|
'cat_ears',
|
||||||
|
'short_hair',
|
||||||
|
'long_hair',
|
||||||
|
'maid',
|
||||||
|
'uniform',
|
||||||
|
],
|
||||||
|
onTap: (tag) => _onTagTap(tag, data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_CategoryTagRow(
|
||||||
|
categoryName: isRu ? 'Провайдеры' : 'Providers',
|
||||||
|
color: const Color(0xFF8B5CF6),
|
||||||
|
tags: const [
|
||||||
|
'provider:gelbooru',
|
||||||
|
'provider:danbooru',
|
||||||
|
'provider:e621',
|
||||||
|
'provider:rule34',
|
||||||
|
'provider:pawchive',
|
||||||
|
],
|
||||||
|
onTap: (tag) => _onTagTap(tag, data.query),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 4. Search Tips / Cheat Sheet Card
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ExpansionTile(
|
||||||
|
shape: const Border(),
|
||||||
|
collapsedShape: const Border(),
|
||||||
|
leading: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF59E0B).withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.lightbulb_outline_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Color(0xFFF59E0B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
isRu ? 'Шпаргалка по поиску' : 'Search tips & operators',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
initiallyExpanded: _showTips,
|
||||||
|
onExpansionChanged: (exp) => setState(() => _showTips = exp),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_TipRow(
|
||||||
|
code: 'tag_a and tag_b',
|
||||||
|
description: isRu
|
||||||
|
? 'Независимый опрос обоих тегов с чередованием постов в ленте.'
|
||||||
|
: 'Interleaves results from both queries independently.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'type:video / type:gif',
|
||||||
|
description: isRu
|
||||||
|
? 'Показывает только видео или анимированные GIF.'
|
||||||
|
: 'Filters results to only videos or animated GIFs.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'rating:safe / rating:explicit',
|
||||||
|
description: isRu
|
||||||
|
? 'Фильтр по возрастному рейтингу медиа.'
|
||||||
|
: 'Filter by age rating classification.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'score:>50 / score:>100',
|
||||||
|
description: isRu
|
||||||
|
? 'Посты с оценкой пользователей выше указанной.'
|
||||||
|
: 'Posts with community score greater than threshold.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'provider:gelbooru',
|
||||||
|
description: isRu
|
||||||
|
? 'Поиск исключительно в указанном источнике.'
|
||||||
|
: 'Search specifically on the given booru provider.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchSectionCard extends StatelessWidget {
|
||||||
|
const _SearchSectionCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
this.badgeCount,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color accentColor;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final int? badgeCount;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18, color: accentColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (badgeCount != null && badgeCount! > 0) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$badgeCount',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickOperatorChip extends StatelessWidget {
|
||||||
|
const _QuickOperatorChip({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryTagRow extends StatelessWidget {
|
||||||
|
const _CategoryTagRow({
|
||||||
|
required this.categoryName,
|
||||||
|
required this.color,
|
||||||
|
required this.tags,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String categoryName;
|
||||||
|
final Color color;
|
||||||
|
final List<String> tags;
|
||||||
|
final ValueChanged<String> onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
categoryName,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final tag in tags)
|
||||||
|
Material(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => onTap(tag),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
tag,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TipRow extends StatelessWidget {
|
||||||
|
const _TipRow({required this.code, required this.description});
|
||||||
|
|
||||||
|
final String code;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
code,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
description,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
|
|
||||||
class RecentSearches extends StatefulWidget {
|
class RecentSearches extends StatefulWidget {
|
||||||
const RecentSearches({required this.items, required this.onTap, super.key});
|
const RecentSearches({
|
||||||
|
required this.items,
|
||||||
|
required this.onTap,
|
||||||
|
this.onDelete,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final List<SearchHistory> items;
|
final List<SearchHistory> items;
|
||||||
final ValueChanged<String> onTap;
|
final ValueChanged<String> onTap;
|
||||||
|
final ValueChanged<String>? onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RecentSearches> createState() => _RecentSearchesState();
|
State<RecentSearches> createState() => _RecentSearchesState();
|
||||||
@@ -17,31 +23,136 @@ class _RecentSearchesState extends State<RecentSearches> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (widget.items.isEmpty) return const Text('No recent searches');
|
final theme = Theme.of(context);
|
||||||
|
if (widget.items.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history_toggle_off_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'История поиска пуста',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final visibleItems =
|
final visibleItems =
|
||||||
_expanded ? widget.items : widget.items.take(30).toList();
|
_expanded ? widget.items : widget.items.take(20).toList();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (final item in visibleItems)
|
for (final item in visibleItems)
|
||||||
ListTile(
|
Material(
|
||||||
leading: const Icon(Icons.history_rounded),
|
color: theme.colorScheme.surfaceContainer,
|
||||||
title: Text(item.query),
|
borderRadius: BorderRadius.circular(16),
|
||||||
subtitle:
|
child: InkWell(
|
||||||
Text('${item.tags.join(' ')} - ${item.resultCount} results'),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () => widget.onTap(item.query),
|
onTap: () => widget.onTap(item.query),
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
if (widget.items.length > 30)
|
),
|
||||||
TextButton.icon(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 140),
|
||||||
|
child: Text(
|
||||||
|
item.query,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.resultCount > 0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${item.resultCount}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.onDelete != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => widget.onDelete!(item.id),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.items.length > 20) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Center(
|
||||||
|
child: TextButton.icon(
|
||||||
onPressed: () => setState(() => _expanded = !_expanded),
|
onPressed: () => setState(() => _expanded = !_expanded),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_expanded ? Icons.unfold_less_rounded : Icons.unfold_more_rounded,
|
_expanded
|
||||||
|
? Icons.unfold_less_rounded
|
||||||
|
: Icons.unfold_more_rounded,
|
||||||
|
size: 16,
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
_expanded
|
_expanded
|
||||||
? 'Show latest 30'
|
? 'Свернуть'
|
||||||
: 'Show all ${widget.items.length} searches',
|
: 'Показать все (${widget.items.length})',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,14 @@ class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
|
|||||||
state = AsyncData(await _load());
|
state = AsyncData(await _load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteItem(String providerId, String postId) async {
|
||||||
|
await ref
|
||||||
|
.read(viewedHistoryServiceProvider)
|
||||||
|
.deleteItem(providerId, postId);
|
||||||
|
ref.invalidate(viewedKeysProvider);
|
||||||
|
state = AsyncData(await _load());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await ref.read(viewedHistoryServiceProvider).clearHistory();
|
await ref.read(viewedHistoryServiceProvider).clearHistory();
|
||||||
ref.invalidate(viewedKeysProvider);
|
ref.invalidate(viewedKeysProvider);
|
||||||
|
|||||||
@@ -1,50 +1,208 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/app.dart';
|
import '../../../app/app.dart';
|
||||||
|
import '../../../app/responsive.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
import '../../favorites/presentation/favorites_controller.dart';
|
import '../../favorites/presentation/favorites_controller.dart';
|
||||||
import 'viewed_controller.dart';
|
import 'viewed_controller.dart';
|
||||||
|
|
||||||
class ViewedScreen extends ConsumerWidget {
|
class ViewedScreen extends ConsumerStatefulWidget {
|
||||||
const ViewedScreen({super.key});
|
const ViewedScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ViewedScreen> createState() => _ViewedScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ViewedScreenState extends ConsumerState<ViewedScreen> {
|
||||||
|
bool _isGridView = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(viewedControllerProvider);
|
final state = ref.watch(viewedControllerProvider);
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
title: isRu ? 'История' : 'History',
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear viewed history',
|
tooltip: _isGridView
|
||||||
onPressed: () => ref.read(viewedControllerProvider.notifier).clear(),
|
? (isRu ? 'Список' : 'List view')
|
||||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
: (isRu ? 'Сетка' : 'Grid view'),
|
||||||
|
icon: Icon(_isGridView
|
||||||
|
? Icons.view_agenda_rounded
|
||||||
|
: Icons.grid_view_rounded),
|
||||||
|
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||||
|
onPressed: () async {
|
||||||
|
final confirm = await showConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: isRu ? 'Очистить историю' : 'Clear history',
|
||||||
|
message: isRu
|
||||||
|
? 'Вы действительно хотите очистить всю историю просмотров?'
|
||||||
|
: 'Are you sure you want to clear your entire viewing history?',
|
||||||
|
);
|
||||||
|
if (confirm) {
|
||||||
|
await ref.read(viewedControllerProvider.notifier).clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_sweep_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
body: state.when(
|
body: state.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (groups) => groups.isEmpty
|
data: (groups) {
|
||||||
? const EmptyView(title: 'No viewed posts yet')
|
if (groups.isEmpty) {
|
||||||
: RefreshIndicator(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.history_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'История просмотров пуста' : 'No viewed posts yet',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Посты, которые вы открываете в ленте, будут сохраняться здесь'
|
||||||
|
: 'Posts you open from the feed will appear here',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.go('/feed'),
|
||||||
|
icon: const Icon(Icons.explore_rounded),
|
||||||
|
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allPosts = [
|
||||||
|
for (final g in groups)
|
||||||
|
for (final i in g.items) i.post
|
||||||
|
];
|
||||||
|
|
||||||
|
if (_isGridView) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () =>
|
||||||
|
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||||
|
child: PostMasonryGrid(
|
||||||
|
posts: allPosts,
|
||||||
|
columns: Responsive.columnsFor(
|
||||||
|
context,
|
||||||
|
mobileColumns: settings.mobileColumns,
|
||||||
|
desktopColumns: settings.desktopColumns,
|
||||||
|
),
|
||||||
|
blurExplicit: settings.blurExplicitContent,
|
||||||
|
showBadges: settings.showPostBadges,
|
||||||
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode:
|
||||||
|
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||||
|
favoriteKeys: favoriteKeys,
|
||||||
|
onOpen: (post) => context.push(
|
||||||
|
'/post/${post.providerId}/${post.id}',
|
||||||
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: allPosts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onFavorite: (post) => _toggleFavorite(
|
||||||
|
ref,
|
||||||
|
post,
|
||||||
|
settings,
|
||||||
|
favoriteKeys,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
onRefresh: () =>
|
onRefresh: () =>
|
||||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
children: [
|
children: [
|
||||||
for (final group in groups) ...[
|
for (final group in groups) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
padding: const EdgeInsets.fromLTRB(4, 16, 4, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_localizedGroupLabel(group.label, isRu),
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
group.label,
|
'${group.items.length}',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final item in group.items)
|
for (final item in group.items)
|
||||||
@@ -52,11 +210,8 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
post: item.post,
|
post: item.post,
|
||||||
viewedAt: item.viewedAt,
|
viewedAt: item.viewedAt,
|
||||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||||
|
isRu: isRu,
|
||||||
onOpen: () {
|
onOpen: () {
|
||||||
final allPosts = [
|
|
||||||
for (final g in groups)
|
|
||||||
for (final i in g.items) i.post
|
|
||||||
];
|
|
||||||
context.push(
|
context.push(
|
||||||
'/post/${item.post.providerId}/${item.post.id}',
|
'/post/${item.post.providerId}/${item.post.id}',
|
||||||
extra: PostNavigationContext(
|
extra: PostNavigationContext(
|
||||||
@@ -71,13 +226,24 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
settings,
|
settings,
|
||||||
favoriteKeys,
|
favoriteKeys,
|
||||||
),
|
),
|
||||||
|
onDelete: () => ref
|
||||||
|
.read(viewedControllerProvider.notifier)
|
||||||
|
.deleteItem(item.post.providerId, item.post.id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localizedGroupLabel(String label, bool isRu) {
|
||||||
|
if (!isRu) return label;
|
||||||
|
if (label == 'Today') return 'Сегодня';
|
||||||
|
if (label == 'Yesterday') return 'Вчера';
|
||||||
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleFavorite(
|
Future<void> _toggleFavorite(
|
||||||
@@ -98,6 +264,7 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
|
ref.invalidate(favoritesControllerProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,43 +273,211 @@ class _ViewedPostTile extends StatelessWidget {
|
|||||||
required this.post,
|
required this.post,
|
||||||
required this.viewedAt,
|
required this.viewedAt,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
|
required this.isRu,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onFavorite,
|
required this.onFavorite,
|
||||||
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Post post;
|
final Post post;
|
||||||
final DateTime viewedAt;
|
final DateTime viewedAt;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
|
final bool isRu;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
final VoidCallback onFavorite;
|
final VoidCallback onFavorite;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
bool get _isVideo {
|
||||||
|
final type = post.fileType.toLowerCase();
|
||||||
|
final url = post.fileUrl.toLowerCase();
|
||||||
|
return type == 'mp4' ||
|
||||||
|
type == 'webm' ||
|
||||||
|
url.endsWith('.mp4') ||
|
||||||
|
url.endsWith('.webm');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final image = MediaUrlSelector.preview(post).firstOrNull;
|
final image = MediaUrlSelector.preview(post).firstOrNull;
|
||||||
|
final timeStr =
|
||||||
|
'${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: scheme.surfaceContainerLow,
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
onTap: onOpen,
|
onTap: onOpen,
|
||||||
leading: ClipRRect(
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(6),
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Squircle Thumbnail
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 56,
|
width: 68,
|
||||||
height: 56,
|
height: 68,
|
||||||
child: image == null
|
child: Stack(
|
||||||
? const Icon(Icons.image_not_supported_rounded)
|
fit: StackFit.expand,
|
||||||
: Image.network(image, fit: BoxFit.cover),
|
children: [
|
||||||
|
image == null
|
||||||
|
? Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.image_not_supported_rounded),
|
||||||
|
)
|
||||||
|
: CachedNetworkImage(
|
||||||
|
imageUrl: image,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.broken_image_rounded,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(post.tags.take(4).join(' '), maxLines: 1),
|
|
||||||
subtitle: Text(
|
|
||||||
'${post.providerName} • ${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
if (_isVideo)
|
||||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
Positioned(
|
||||||
|
right: 4,
|
||||||
|
bottom: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Post details
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
scheme.primaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
post.providerName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: scheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
timeStr,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
post.tags.isEmpty ? '#${post.id}' : post.tags.take(4).join(' '),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.score != 0) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.thumb_up_rounded,
|
||||||
|
size: 12,
|
||||||
|
color: scheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${post.score}',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: scheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Action buttons
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
tooltip: isFavorite
|
||||||
|
? (isRu ? 'В избранном' : 'In favorites')
|
||||||
|
: (isRu ? 'В избранное' : 'Add to favorites'),
|
||||||
onPressed: onFavorite,
|
onPressed: onFavorite,
|
||||||
icon: Icon(isFavorite
|
icon: Icon(
|
||||||
|
isFavorite
|
||||||
? Icons.favorite_rounded
|
? Icons.favorite_rounded
|
||||||
: Icons.favorite_border_rounded),
|
: Icons.favorite_border_rounded,
|
||||||
|
color: isFavorite ? Colors.redAccent : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
tooltip: isRu ? 'Удалить из истории' : 'Delete from history',
|
||||||
|
onPressed: onDelete,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -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.0.13+35
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/backend/backend.dart';
|
||||||
|
import 'package:gel_rule_app/features/settings/presentation/settings_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/settings/presentation/settings_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('SettingsScreen renders category quick nav and sections without overflow', (tester) async {
|
||||||
|
// Test on typical mobile viewport: 390x844
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final settings = AppSettings.defaults.copyWith(
|
||||||
|
languageCode: 'ru',
|
||||||
|
themeMode: 'dark',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
settingsControllerProvider.overrideWith(
|
||||||
|
() => _FakeSettingsController(settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: SettingsScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify pinned Category Quick Nav renders
|
||||||
|
expect(find.text('Основное'), findsWidgets);
|
||||||
|
expect(find.text('Внешний вид'), findsWidgets);
|
||||||
|
|
||||||
|
// Verify Theme mode segment buttons
|
||||||
|
expect(find.text('Темная'), findsOneWidget);
|
||||||
|
expect(find.text('Светлая'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap a quick nav chip to test scrolling
|
||||||
|
await tester.tap(find.text('Внешний вид').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify language segment is visible and tappable
|
||||||
|
expect(find.text('🇷🇺 Русский'), findsOneWidget);
|
||||||
|
expect(find.text('🇬🇧 English'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap English to test selection
|
||||||
|
await tester.tap(find.text('🇬🇧 English'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeSettingsController extends SettingsController {
|
||||||
|
_FakeSettingsController(this._initial);
|
||||||
|
|
||||||
|
final AppSettings _initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppSettings> build() async => _initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSettings(AppSettings settings) async {
|
||||||
|
state = AsyncData(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import 'package:flutter/material.dart' hide SearchController;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/app/app.dart';
|
||||||
|
import 'package:gel_rule_app/backend/backend.dart';
|
||||||
|
import 'package:gel_rule_app/features/collections/presentation/collections_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/collections/presentation/collections_screen.dart';
|
||||||
|
import 'package:gel_rule_app/features/favorites/presentation/favorites_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/favorites/presentation/favorites_screen.dart';
|
||||||
|
import 'package:gel_rule_app/features/favorites/presentation/favorites_state.dart';
|
||||||
|
import 'package:gel_rule_app/features/search/presentation/search_controller.dart'
|
||||||
|
as search_feature;
|
||||||
|
import 'package:gel_rule_app/features/search/presentation/search_screen.dart';
|
||||||
|
import 'package:gel_rule_app/features/search/presentation/search_state.dart';
|
||||||
|
import 'package:gel_rule_app/features/viewed/presentation/viewed_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/viewed/presentation/viewed_screen.dart';
|
||||||
|
|
||||||
|
Post _samplePost({
|
||||||
|
required String id,
|
||||||
|
String providerId = 'danbooru',
|
||||||
|
String providerName = 'Danbooru',
|
||||||
|
List<String> tags = const ['tag1', 'artist:test_artist'],
|
||||||
|
String fileType = 'jpg',
|
||||||
|
}) {
|
||||||
|
return Post(
|
||||||
|
id: id,
|
||||||
|
providerId: providerId,
|
||||||
|
providerName: providerName,
|
||||||
|
previewUrl: 'https://example.com/$id.jpg',
|
||||||
|
sampleUrl: 'https://example.com/$id.jpg',
|
||||||
|
fileUrl: 'https://example.com/$id.jpg',
|
||||||
|
tags: tags,
|
||||||
|
rating: 'safe',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
fileType: fileType,
|
||||||
|
score: 42,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('FavoritesScreen renders segments, counters and filter chips',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final post = _samplePost(id: '1');
|
||||||
|
final post2 = _samplePost(
|
||||||
|
id: '2', fileType: 'mp4', tags: ['artist:animator', 'video']);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
favoritesControllerProvider.overrideWith(
|
||||||
|
() => _FakeFavoritesController([post, post2]),
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
downloadedMediaByKeysProvider([post.cacheKey, post2.cacheKey])
|
||||||
|
.overrideWith((ref) => <String, DownloadedMedia>{}),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: FavoritesScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Verify segmented button labels with live counts
|
||||||
|
expect(find.text('Все (2)'), findsOneWidget);
|
||||||
|
expect(find.text('Офлайн (0)'), findsOneWidget);
|
||||||
|
expect(find.text('Авторы (2)'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify media filter chips
|
||||||
|
expect(find.text('Все'), findsWidgets);
|
||||||
|
expect(find.text('Видео'), findsOneWidget);
|
||||||
|
expect(find.text('Фото'), findsOneWidget);
|
||||||
|
|
||||||
|
// Switch to Artists tab
|
||||||
|
await tester.tap(find.text('Авторы (2)'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(find.text('test_artist'), findsOneWidget);
|
||||||
|
expect(find.text('animator'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ViewedScreen renders timeline and switches view mode',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final post = _samplePost(id: '10', tags: ['landscape', 'sunset']);
|
||||||
|
final group = ViewedTimelineGroup(
|
||||||
|
label: 'Today',
|
||||||
|
items: [
|
||||||
|
ViewedPostEntry(post: post, viewedAt: DateTime.now()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
viewedControllerProvider.overrideWith(
|
||||||
|
() => _FakeViewedController([group]),
|
||||||
|
),
|
||||||
|
favoriteKeysProvider.overrideWith(
|
||||||
|
(ref) => <String>{},
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: ViewedScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Verify timeline group
|
||||||
|
expect(find.text('Сегодня'), findsOneWidget);
|
||||||
|
expect(find.text('landscape sunset'), findsOneWidget);
|
||||||
|
|
||||||
|
// Switch to Grid mode
|
||||||
|
final gridBtn = find.byIcon(Icons.grid_view_rounded);
|
||||||
|
expect(gridBtn, findsOneWidget);
|
||||||
|
await tester.tap(gridBtn);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Now icon should switch to list mode
|
||||||
|
expect(find.byIcon(Icons.view_agenda_rounded), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CollectionsScreen renders collection cards and new collection button',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final collection = Collection(
|
||||||
|
id: 'c1',
|
||||||
|
name: 'Cyberpunk Art',
|
||||||
|
description: 'Futuristic neon cities',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
collectionsControllerProvider.overrideWith(
|
||||||
|
() => _FakeCollectionsController([collection]),
|
||||||
|
),
|
||||||
|
collectionPostsProvider('c1').overrideWith(
|
||||||
|
(ref) => Future.value([_samplePost(id: '1')]),
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: CollectionsScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(find.text('Cyberpunk Art'), findsOneWidget);
|
||||||
|
expect(find.text('Futuristic neon cities'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'SearchScreen renders hero search bar, quick chips, and categories',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
search_feature.searchControllerProvider.overrideWith(
|
||||||
|
() => _FakeSearchController([
|
||||||
|
SearchHistory(
|
||||||
|
id: 'h1',
|
||||||
|
query: 'genshin_impact',
|
||||||
|
tags: const ['genshin_impact'],
|
||||||
|
searchedAt: DateTime.now(),
|
||||||
|
resultCount: 1500,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: SearchScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Verify Search Bar and Quick Operator Chips
|
||||||
|
expect(find.text('type:video'), findsOneWidget);
|
||||||
|
expect(find.text('rating:safe'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify Recent Searches chip
|
||||||
|
expect(find.text('genshin_impact'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify Popular Categories
|
||||||
|
expect(find.text('Тематика'), findsOneWidget);
|
||||||
|
expect(find.text('Персонажи и детали'), findsOneWidget);
|
||||||
|
expect(find.text('Провайдеры'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeFavoritesController extends FavoritesController {
|
||||||
|
_FakeFavoritesController(this._posts);
|
||||||
|
|
||||||
|
final List<Post> _posts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FavoritesState> build() async {
|
||||||
|
return FavoritesState(posts: _posts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeViewedController extends ViewedController {
|
||||||
|
_FakeViewedController(this._groups);
|
||||||
|
|
||||||
|
final List<ViewedTimelineGroup> _groups;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ViewedTimelineGroup>> build() async => _groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeCollectionsController extends CollectionsController {
|
||||||
|
_FakeCollectionsController(this._collections);
|
||||||
|
|
||||||
|
final List<Collection> _collections;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Collection>> build() async => _collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeSearchController extends search_feature.SearchController {
|
||||||
|
_FakeSearchController(this._history);
|
||||||
|
|
||||||
|
final List<SearchHistory> _history;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchState> build() async {
|
||||||
|
return SearchState(
|
||||||
|
recent: _history,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user