From 8ff2efd84cd6197c3da64cc4cd7f0fb886783803 Mon Sep 17 00:00:00 2001
From: RarDog
Date: Sat, 5 Sep 2026 11:02:09 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?=
=?UTF-8?q?=D1=80=D0=B5=D0=B1=D1=80=D0=B5=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=20?=
=?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0,?=
=?UTF-8?q?=20User-Agent=20=D0=B8=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?=
=?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=BB=D1=83=D0=B6=D0=B1=20=D0=B2=20?=
=?UTF-8?q?Prisma=20(v3.4.1)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 2 +-
installer/{Lunaris.iss => Prisma.iss} | 4 +-
lib/app/app.dart | 2 +-
lib/app/app_version.dart | 4 +-
lib/app/changelog.dart | 130 +-
lib/backend/di/backend_providers.dart | 2 +-
lib/backend/providers/gelbooru_provider.dart | 2 +-
lib/backend/providers/kemono_provider.dart | 4 +-
lib/backend/providers/provider_manager.dart | 2 +-
lib/backend/providers/rule34_provider.dart | 2 +-
.../repositories/provider_repository.dart | 4 +-
lib/backend/services/backup_service.dart | 4 +-
lib/backend/services/download_service.dart | 2 +-
.../services/pawchive_sync_service.dart | 6 +-
lib/backend/services/settings_service.dart | 2 +-
lib/backend/services/tag_cache_service.dart | 9 +-
lib/backend/services/update_service.dart | 10 +-
lib/core/http/dio_client.dart | 2 +-
.../feed/presentation/feed_screen.dart | 2 +-
.../presentation/post_details_screen.dart | 3270 ++++++++---------
.../widgets/post_media_viewer.dart | 2 +-
.../presentation/provider_form_screen.dart | 2 +-
.../presentation/settings_screen.dart | 16 +-
lib/shared/widgets/post_card.dart | 2 +-
pubspec.yaml | 2 +-
test/backend/tag_cache_service_test.dart | 2 +-
test/backend/update_service_test.dart | 16 +-
27 files changed, 1763 insertions(+), 1744 deletions(-)
rename installer/{Lunaris.iss => Prisma.iss} (96%)
diff --git a/README.md b/README.md
index 4f93eaf..f1f5c74 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/installer/Lunaris.iss b/installer/Prisma.iss
similarity index 96%
rename from installer/Lunaris.iss
rename to installer/Prisma.iss
index a5764a1..1c7ade5 100644
--- a/installer/Lunaris.iss
+++ b/installer/Prisma.iss
@@ -1,4 +1,4 @@
-#define MyAppName "Lunaris"
+#define MyAppName "Prisma"
#define MyAppVersion "2.0.1"
#define MyAppPublisher "RarDog"
#define MyAppExeName "gel_rule_app.exe"
@@ -13,7 +13,7 @@ DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=..\dist
-OutputBaseFilename=LunarisSetup
+OutputBaseFilename=PrismaSetup
SetupIconFile=..\windows\runner\resources\app_icon.ico
Compression=lzma2
SolidCompression=yes
diff --git a/lib/app/app.dart b/lib/app/app.dart
index 2ff0f6c..e166b03 100644
--- a/lib/app/app.dart
+++ b/lib/app/app.dart
@@ -97,7 +97,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
await showDialog(
context: context,
builder: (context) => AlertDialog(
- title: Text('${strings.appUpdateAvailable}: Lunaris ${info.version}'),
+ title: Text('${strings.appUpdateAvailable}: Prisma ${info.version}'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: SingleChildScrollView(
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index 6ba44de..238f516 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '3.4.0';
-const appBuildNumber = 74;
+const appDisplayVersion = '3.4.1';
+const appBuildNumber = 75;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index 4058d91..18e1530 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,5 +1,14 @@
-const lunarisChangelog = [
- LunarisChange(
+const prismaChangelog = [
+ PrismaChange(
+ version: '3.4.1',
+ title: 'Полный ребрендинг интерфейса: заголовок ленты, окно «О приложении» и системные службы',
+ bullets: [
+ 'Полное обновление названия в интерфейсе: заголовок в шапке ленты заменен на Prisma, обновлен логотип и карточка в диалоге «О приложении».',
+ 'Окна обновлений и чейнджлогов: все диалоги проверки обновлений, чейнджлога и бета-версий теперь используют имя Prisma.',
+ 'Системные службы и User-Agent: сетевой стек, экспорт резервных копий и кэш тегов переведены на Prisma с автоматической миграцией сохраненных данных.',
+ ],
+ ),
+ PrismaChange(
version: '3.4.0',
title: 'Ребрендинг в Prisma: новое имя, комфортная прокрутка анонсов и обновленные репозитории',
bullets: [
@@ -8,7 +17,7 @@ const lunarisChangelog = [
'Синхронизация репозиториев: репозитории на GitHub и Gitea переведены на Prisma, ссылки внутри приложения и служба автообновления переключены на новые адреса.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.3.1',
title: 'Улучшенное разделение текстовых постов и облачных зеркал',
bullets: [
@@ -17,7 +26,7 @@ const lunarisChangelog = [
'Чистый и нативный вид карточек: если у медиапоста есть только ссылки или только описание, отображается аккуратная монолитная секция с соответствующим заголовком и иконкой без лишних переключателей.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.3.0',
title: 'Редизайн личных сообщений и рассылок артиста, нативная поддержка текстовых публикаций',
bullets: [
@@ -28,7 +37,7 @@ const lunarisChangelog = [
'Поддержка анонсов в Kemono и Coomer: API анонсов и персональных сообщений теперь полноценно работает для всех поддерживаемых провайдеров.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.2.0',
title: 'Стабильный полноэкранный режим: сохранение воспроизведения, устранение вылетов плеера и чистый интерфейс',
bullets: [
@@ -38,7 +47,7 @@ const lunarisChangelog = [
'Быстрое скрытие и показ контролов: касание свободной области видео плавно переключает видимость панели управления, а при нажатии на кнопки таймер автоскрытия автоматически обновляется.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.9',
title: 'Полноэкранный плеер 2.0: мгновенный переход без задержек, жесты яркости и скраббинг с превью',
bullets: [
@@ -48,7 +57,7 @@ const lunarisChangelog = [
'Сброс яркости: при выходе из полноэкранного режима системный уровень яркости экрана аккуратно восстанавливается.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.8',
title: 'Безупречный полноэкранный плеер: устранение остановки при развертывании и отзывчивые кнопки управления',
bullets: [
@@ -58,7 +67,7 @@ const lunarisChangelog = [
'Синхронизация состояния при сворачивании: плеер точно запоминает состояние паузы/воспроизведения при выходе из полноэкранного режима обратно в пост.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.7',
title: 'Устранение критического сбоя при открытии видеопоста (SIGSEGV в libmpv)',
bullets: [
@@ -67,7 +76,7 @@ const lunarisChangelog = [
'Надежный фоллбэк при редких ошибках кодека: при несовместимости аппаратного декодера плеер бесшовно переключается на программный FFmpeg-декодер без риска краша.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.6',
title: 'Надежный полноэкранный плеер, сохранение провайдеров в поиске, громкость и устранение ошибки кодека',
bullets: [
@@ -77,7 +86,7 @@ const lunarisChangelog = [
'Автоматический fallback для ошибки Could not open codec: видеоплеер получил безопасные профили декодирования и автопереключение на программный лавк-декодер (FFmpeg software fallback) при редких сбоях аппаратного ускорения.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.5',
title: 'Сохранение громкости видео, исправление полноэкранного плеера, фильтрации и удобства комментариев',
bullets: [
@@ -88,7 +97,7 @@ const lunarisChangelog = [
'Удобный доступ к комментариям в посте: увеличен отступ снизу в деталях поста и расширена зона клика блока комментариев, позволяя комфортно прокрутить их выше системных жестов навигации.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.4',
title: 'Оптимизация видеоплеера — адаптивная высота и идеальное прилегание полосы воспроизведения',
bullets: [
@@ -97,7 +106,7 @@ const lunarisChangelog = [
'Компактные элементы управления: выровнены буферный трек и слайдер прокрутки, оптимизирован градиент затенения для максимальной видимости и удобства управления.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.3',
title: 'iPhone Liquid Glass — Источники, Проверка сети и Навигация назад по вкладкам',
bullets: [
@@ -107,7 +116,7 @@ const lunarisChangelog = [
'Liquid Glass редизайн проверки состояния источников (Provider Check): карточка общих метрик сети (онлайн/офлайн, средний пинг), раскрывающиеся аккордеоны детальной диагностики, живые светодиодные LED-индикаторы статуса и кнопка одновременной проверки всех провайдеров.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.2',
title: 'Оптимизация производительности: устранение лагов при скролле к тегам',
bullets: [
@@ -117,7 +126,7 @@ const lunarisChangelog = [
'Интеллектуальный лимит начального отображения: оптимизировано первоначальное количество отображаемых тегов в огромных категориях с удобной кнопкой быстрого раскрытия.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.1',
title: 'Деление тегов по стилю e621/Gelbooru/Rule34 и исправление поиска тегов со скобками',
bullets: [
@@ -129,7 +138,7 @@ const lunarisChangelog = [
'Liquid Glass оформление блока тегов в детальном просмотре поста на мобильных и десктоп устройствах.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.1.0',
title: 'iPhone Liquid Glass — Поиск, Избранное, История и исправление доступности',
bullets: [
@@ -139,7 +148,7 @@ const lunarisChangelog = [
'Тактильный отклик и стеклянные пилюли: быстрые операторы, категории, недавние запросы, фильтры медиа и переключатели разделов получили матовое оформление и вибрационный отклик Haptic Feedback.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.0.9',
title: 'Коррекция позиции кнопки настроек и отображения кнопок JSON и бэкапа',
bullets: [
@@ -148,7 +157,7 @@ const lunarisChangelog = [
'Улучшенный дизайн и сетка кнопок экспорта/импорта: кнопки бэкапа и JSON организованы в симметричные аккуратные строки с яркими акцентными бейджами.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.0.8',
title: 'iPhone Liquid Glass — раздел «Настройки» и парящая стеклянная кнопка',
bullets: [
@@ -158,7 +167,7 @@ const lunarisChangelog = [
'Стеклянная быстрая навигация по категориям: зафиксированная панель категорий с размытием фона и тактильным откликом при переключении разделов.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.0.7',
title: 'Liquid Glass Navigation Bar — парящая стеклянная панель и анимированная пилюля',
bullets: [
@@ -168,7 +177,7 @@ const lunarisChangelog = [
'Глубина контента: контент ленты, поиска и галереи плавно просвечивает и размывается под стеклянной панелью при прокрутке.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.0.6',
title: 'Улучшение недавних запросов и отображения тегов',
bullets: [
@@ -178,7 +187,7 @@ const lunarisChangelog = [
'Удобное удаление: увеличенная область нажатия для кнопки удаления (крестика) предотвращает случайные нажатия на сам запрос.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.0.5',
title: 'Интерактивные ссылки на соцсети и авторов в объявлениях и описаниях',
bullets: [
@@ -187,7 +196,7 @@ const lunarisChangelog = [
'Корректная очистка пунктуации: знаки препинания в конце URL (!, ?, :, ., ,, ;, >) больше не ломают ссылки.',
],
),
- LunarisChange(
+ PrismaChange(
version: '3.0.0',
title: 'Lunaris 3.0 — Глобальное обновление: Дизайн 2.0, Интеграция Pawchive и умная синхронизация',
bullets: [
@@ -200,7 +209,7 @@ const lunarisChangelog = [
'Исправление вёрстки: устранены переносы текста в заголовке окна аккаунтов и карточках пользователей на экранах смартфонов.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.10',
title: 'Выгрузка локального избранного в Pawchive и двусторонняя синхронизация',
bullets: [
@@ -210,7 +219,7 @@ const lunarisChangelog = [
'Синхронизация в реальном времени с любых провайдеров: при добавлении автора в избранное в лентах Kemono, Coomer или Pawchive изменение мгновенно передаётся на сервер Pawchive.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.9',
title: 'Исправление входа и синхронизации аккаунтов Pawchive',
bullets: [
@@ -219,7 +228,7 @@ const lunarisChangelog = [
'Информативные ошибки: понятные сообщения на русском при неверном пароле или сбое сети вместо сырых исключений Dio.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.8',
title: 'ABI-Aware Auto-Update — правильный APK для вашего процессора',
bullets: [
@@ -229,7 +238,7 @@ const lunarisChangelog = [
'Кеш ABI: архитектура определяется один раз за сеанс.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.7',
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
bullets: [
@@ -238,7 +247,7 @@ const lunarisChangelog = [
'Session Cache: resolved image dimensions are cached in memory for the lifetime of the app so repeated visits to the same post do not trigger re-downloads.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.6',
title: 'Pawchive Accounts Integration & Favorites Synchronization',
bullets: [
@@ -249,7 +258,7 @@ const lunarisChangelog = [
'Dedicated UI & Quick Access: manage connected accounts, view sync stats, and trigger synchronization from the Artists screen, Favorites modal, and Settings screen.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.5',
title: 'Fullscreen Video Auto-Rotation & Orientation Controls',
bullets: [
@@ -258,7 +267,7 @@ const lunarisChangelog = [
'Clean Orientation Recovery: exiting fullscreen seamlessly restores system navigation bars (Edge-to-Edge) and restores unrestricted device sensor orientations.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.4',
title: 'OverlayPortal Architecture & Search UI Unblocking',
bullets: [
@@ -269,7 +278,7 @@ const lunarisChangelog = [
'Non-Blocking Outside Tap: tapping outside the suggestion dropdown smoothly dismisses suggestions while allowing the touch event to pass through directly to the underlying button or post.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.3',
title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment',
bullets: [
@@ -279,7 +288,7 @@ const lunarisChangelog = [
'Rule34 Authentication & Outage Handling: added detection for "missing authentication", rate limits, and server overload/search down ("success": false) with informative guidance to enter API Key & User ID.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.2',
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
bullets: [
@@ -289,7 +298,7 @@ const lunarisChangelog = [
'Throttling & Auth Guidance: enhanced API credentials helper text and informative error messages with direct instructions to configure API Key & User ID from Gelbooru account options.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.1',
title: 'Cloud Mirrors & Author Announcements Fixes',
bullets: [
@@ -298,7 +307,7 @@ const lunarisChangelog = [
'Author Announcements HTML Cleaning: stripped all raw , , and formatting artifacts from artist announcements, rendering clean readable text and clickable service link chips.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.3.0',
title: 'Artist Cloud Drives & External Video Mirrors',
bullets: [
@@ -310,7 +319,7 @@ const lunarisChangelog = [
'Artist Commentary Card: formatted display of author notes, instructions, and text below post details.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.2.0',
title: 'Offline Mode Overhaul & Post Details Screen Redesign',
bullets: [
@@ -321,7 +330,7 @@ const lunarisChangelog = [
'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(
+ PrismaChange(
version: '2.1.0',
title: '4-Tab Major Redesign: Search, Favorites, History & Collections',
bullets: [
@@ -331,7 +340,7 @@ const lunarisChangelog = [
'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(
+ PrismaChange(
version: '2.0.15',
title: 'Settings 2.0 Layout Fixes & Pinned Quick Navigation',
bullets: [
@@ -342,7 +351,7 @@ const lunarisChangelog = [
'Header text overflow protections: wrapped title texts in Expanded across all settings cards.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.14',
title: 'Settings 2.0: Modern Grouped Design & Quick Ergonomics',
bullets: [
@@ -354,7 +363,7 @@ const lunarisChangelog = [
'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and GitHub repository link.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.13',
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
bullets: [
@@ -364,7 +373,7 @@ const lunarisChangelog = [
'Expanded Bottom Gradient: increased bottom cinematic gradient height to 64dp for clean text and badge contrast.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.12',
title: 'Feed 2.0: Pinterest Modern Redesign & Sleek Cards',
bullets: [
@@ -377,7 +386,7 @@ const lunarisChangelog = [
'Polished Search Presets: stylish bookmark capsules for quick access to saved search queries.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.11',
title: 'Mobile Video Player Redesign & Format Recognition Fix',
bullets: [
@@ -391,7 +400,7 @@ const lunarisChangelog = [
'Sleek Neon Seekbar with live network buffer progress indicator.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.10',
title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling',
bullets: [
@@ -401,7 +410,7 @@ const lunarisChangelog = [
'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.9',
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
bullets: [
@@ -415,7 +424,7 @@ const lunarisChangelog = [
'Dedicated Clear Tag Cache actions in Settings and Cache Manager.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.8',
title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering',
bullets: [
@@ -424,7 +433,7 @@ const lunarisChangelog = [
'Resolved "No posts yet" bug by adapting client post validation to multi-tag stream groups and stripping operator keywords from booru queries.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.7',
title: 'Independent Multi-Tag Search Streams, Local "and" Connector, Pawchive in Main Feed & Reverts',
bullets: [
@@ -435,7 +444,7 @@ const lunarisChangelog = [
'Removed external linked accounts bar from artist posts screen.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.6',
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
bullets: [
@@ -446,7 +455,7 @@ const lunarisChangelog = [
'Server-side media filtering (Photos, Videos, GIFs) querying the entire author archive through the API.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.5',
title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
bullets: [
@@ -461,7 +470,7 @@ const lunarisChangelog = [
'Full JSON backup export and import for seamless configuration sync.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.4',
title: 'Platform service selection & media type filtering in artist posts',
bullets: [
@@ -470,7 +479,7 @@ const lunarisChangelog = [
'Full-screen post pager respects active media type filters during swiping.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.3',
title: 'Infinite artist pagination, favorite artists modal, and card layout polish',
bullets: [
@@ -480,7 +489,7 @@ const lunarisChangelog = [
'Removed mapping truncation in Pawchive provider to deliver all post attachments cleanly.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.2',
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
bullets: [
@@ -492,7 +501,7 @@ const lunarisChangelog = [
'In-app update checks and downloads connected directly to RarDog GitHub releases.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.1',
title: 'Localization, zoom and favorite downloads',
bullets: [
@@ -503,7 +512,7 @@ const lunarisChangelog = [
'Favorites can optionally auto-download media in the background, with local files tracked separately from favorites.',
],
),
- LunarisChange(
+ PrismaChange(
version: '2.0.0',
title: 'Customization and provider polish',
bullets: [
@@ -514,7 +523,7 @@ const lunarisChangelog = [
'Kemono and Coomer creator search uses the current mbahArip keyword endpoints with public API fallbacks.',
],
),
- LunarisChange(
+ PrismaChange(
version: '1.1.2',
title: 'Experimental tag and Artists update',
bullets: [
@@ -524,7 +533,7 @@ const lunarisChangelog = [
'This is an experimental release for checking the new tag and Artists behavior.',
],
),
- LunarisChange(
+ PrismaChange(
version: '1.1.1',
title: 'Artists and in-app updates',
bullets: [
@@ -534,7 +543,7 @@ const lunarisChangelog = [
'Tag input keeps its scroll position steadier while typing and committing chips.',
],
),
- LunarisChange(
+ PrismaChange(
version: '1.1.0',
title: 'Lunaris rebrand and providers',
bullets: [
@@ -544,7 +553,7 @@ const lunarisChangelog = [
'Video player UI preferences are saved across fullscreen, details and restarts.',
],
),
- LunarisChange(
+ PrismaChange(
version: '1.0.2',
title: 'Provider safety fix',
bullets: [
@@ -553,7 +562,7 @@ const lunarisChangelog = [
'Existing enabled legacy configs are disabled so the main feed is not polluted by known-offline APIs.',
],
),
- LunarisChange(
+ PrismaChange(
version: '1.0.1',
title: 'Mobile polish',
bullets: [
@@ -562,7 +571,7 @@ const lunarisChangelog = [
'Tag chip input no longer jumps while typing.',
],
),
- LunarisChange(
+ PrismaChange(
version: '1.0.0',
title: 'Stable 1.0',
bullets: [
@@ -572,7 +581,7 @@ const lunarisChangelog = [
'Windows portable package is published alongside APK and installer.',
],
),
- LunarisChange(
+ PrismaChange(
version: '0.4.3',
title: 'Bugfixes',
bullets: [
@@ -584,8 +593,8 @@ const lunarisChangelog = [
),
];
-class LunarisChange {
- const LunarisChange({
+class PrismaChange {
+ const PrismaChange({
required this.version,
required this.title,
required this.bullets,
@@ -595,3 +604,6 @@ class LunarisChange {
final String title;
final List bullets;
}
+
+const lunarisChangelog = prismaChangelog;
+typedef LunarisChange = PrismaChange;
diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart
index cb0a2f0..dc847a2 100644
--- a/lib/backend/di/backend_providers.dart
+++ b/lib/backend/di/backend_providers.dart
@@ -224,7 +224,7 @@ final updateServiceProvider = Provider((ref) {
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 12),
headers: const {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': 'application/json',
},
),
diff --git a/lib/backend/providers/gelbooru_provider.dart b/lib/backend/providers/gelbooru_provider.dart
index 1ba7ee3..45d9e20 100644
--- a/lib/backend/providers/gelbooru_provider.dart
+++ b/lib/backend/providers/gelbooru_provider.dart
@@ -46,7 +46,7 @@ class GelbooruProvider
Map mediaHeaders(Post post) {
final host = Uri.tryParse(baseUrl)?.host;
return {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/',
};
diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart
index daba43d..796f4c4 100644
--- a/lib/backend/providers/kemono_provider.dart
+++ b/lib/backend/providers/kemono_provider.dart
@@ -30,7 +30,7 @@ class KemonoProvider
receiveTimeout: const Duration(seconds: 20),
headers: const {
'Accept': 'application/json',
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
},
),
);
@@ -582,7 +582,7 @@ class KemonoProvider
receiveTimeout: const Duration(seconds: 25),
headers: const {
'Accept': 'application/json',
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
},
),
);
diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart
index 875f895..2705e66 100644
--- a/lib/backend/providers/provider_manager.dart
+++ b/lib/backend/providers/provider_manager.dart
@@ -569,7 +569,7 @@ class ProviderManager {
);
}
return const Success({
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': '*/*',
});
}
diff --git a/lib/backend/providers/rule34_provider.dart b/lib/backend/providers/rule34_provider.dart
index f36ed03..bc1fae1 100644
--- a/lib/backend/providers/rule34_provider.dart
+++ b/lib/backend/providers/rule34_provider.dart
@@ -21,7 +21,7 @@ class Rule34Provider extends GelbooruProvider {
@override
Map mediaHeaders(Post post) => const {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': '*/*',
'Referer': 'https://rule34.xxx/',
};
diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart
index c50cb87..799a7e1 100644
--- a/lib/backend/repositories/provider_repository.dart
+++ b/lib/backend/repositories/provider_repository.dart
@@ -60,7 +60,7 @@ class ProviderRepository {
priority: 3,
timeoutSeconds: 20,
customHeaders: const {
- 'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.2 Flutter local booru browser',
},
createdAt: now,
updatedAt: now,
@@ -74,7 +74,7 @@ class ProviderRepository {
priority: 4,
timeoutSeconds: 20,
customHeaders: const {
- 'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.2 Flutter local booru browser',
},
createdAt: now,
updatedAt: now,
diff --git a/lib/backend/services/backup_service.dart b/lib/backend/services/backup_service.dart
index 81a1f9e..711b9dc 100644
--- a/lib/backend/services/backup_service.dart
+++ b/lib/backend/services/backup_service.dart
@@ -52,13 +52,13 @@ class BackupService {
Future exportBackup() async {
final json = await createBackupJson();
final fileName =
- 'lunaris_backup_${DateTime.now().millisecondsSinceEpoch}.json';
+ 'prisma_backup_${DateTime.now().millisecondsSinceEpoch}.json';
if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/$fileName');
await file.writeAsString(json);
- await Share.shareXFiles([XFile(file.path)], text: 'Lunaris Backup');
+ await Share.shareXFiles([XFile(file.path)], text: 'Prisma Backup');
return true;
}
diff --git a/lib/backend/services/download_service.dart b/lib/backend/services/download_service.dart
index 5b19ac7..258280e 100644
--- a/lib/backend/services/download_service.dart
+++ b/lib/backend/services/download_service.dart
@@ -224,7 +224,7 @@ class DownloadService {
return {
'User-Agent': lower.contains('realbooru') || lower.contains('paheal')
? 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36'
- : 'Lunaris/2.0.1 Flutter local booru browser',
+ : 'Prisma/2.0.1 Flutter local booru browser',
'Accept': lower.contains('realbooru') || lower.contains('paheal')
? 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8'
: '*/*',
diff --git a/lib/backend/services/pawchive_sync_service.dart b/lib/backend/services/pawchive_sync_service.dart
index cc7c3dd..540ea4b 100644
--- a/lib/backend/services/pawchive_sync_service.dart
+++ b/lib/backend/services/pawchive_sync_service.dart
@@ -94,7 +94,7 @@ class PawchiveSyncService {
followRedirects: false,
validateStatus: (status) => status != null && status < 500,
headers: const {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
),
@@ -231,7 +231,7 @@ class PawchiveSyncService {
headers: {
'Cookie': 'session=$cleanCookie',
'Accept': 'application/json',
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
},
validateStatus: (status) => status != null && status < 500,
),
@@ -509,7 +509,7 @@ class PawchiveSyncService {
final options = Options(
headers: {
'Cookie': 'session=$cleanCookie',
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
},
validateStatus: (status) =>
status != null && (status == 200 || status == 201 || status == 204),
diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart
index ad8ed69..75ab1c6 100644
--- a/lib/backend/services/settings_service.dart
+++ b/lib/backend/services/settings_service.dart
@@ -674,7 +674,7 @@ class SettingsService {
return Success(
const JsonEncoder.withIndent(' ').convert({
'app': {
- 'name': 'Lunaris',
+ 'name': 'Prisma',
'version': appVersion,
'build': buildNumber,
'generatedAt': DateTime.now().toIso8601String(),
diff --git a/lib/backend/services/tag_cache_service.dart b/lib/backend/services/tag_cache_service.dart
index 6022639..dcccc55 100644
--- a/lib/backend/services/tag_cache_service.dart
+++ b/lib/backend/services/tag_cache_service.dart
@@ -25,7 +25,14 @@ class TagCacheService {
return _resolvedPath!;
}
final dir = await getApplicationDocumentsDirectory();
- _resolvedPath = '${dir.path}/lunaris_tag_cache.json';
+ final newFile = File('${dir.path}/prisma_tag_cache.json');
+ final oldFile = File('${dir.path}/lunaris_tag_cache.json');
+ if (!newFile.existsSync() && oldFile.existsSync()) {
+ try {
+ oldFile.copySync(newFile.path);
+ } catch (_) {}
+ }
+ _resolvedPath = newFile.path;
return _resolvedPath!;
}
diff --git a/lib/backend/services/update_service.dart b/lib/backend/services/update_service.dart
index cbbcddc..48ea878 100644
--- a/lib/backend/services/update_service.dart
+++ b/lib/backend/services/update_service.dart
@@ -111,7 +111,7 @@ class UpdateService {
'Authorization': 'token 1c744d6044d756759d7b1f693c94c80cf70d75fa',
}
: {
- 'User-Agent': 'Lunaris-App',
+ 'User-Agent': 'Prisma-App',
'Accept': 'application/vnd.github+json',
},
);
@@ -239,10 +239,10 @@ class UpdateService {
final fromUrl =
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
if (fromUrl.contains('.')) return fromUrl;
- if (Platform.isAndroid) return 'Lunaris-v${info.version}.apk';
- if (Platform.isLinux) return 'Lunaris-v${info.version}-linux-x64.tar.gz';
- if (Platform.isWindows) return 'LunarisSetup-v${info.version}.exe';
- return 'LunarisPortable-v${info.version}.zip';
+ if (Platform.isAndroid) return 'Prisma-v${info.version}.apk';
+ if (Platform.isLinux) return 'Prisma-v${info.version}-linux-x64.tar.gz';
+ if (Platform.isWindows) return 'PrismaSetup-v${info.version}.exe';
+ return 'PrismaPortable-v${info.version}.zip';
}
static String _versionFromTag(String tag) {
diff --git a/lib/core/http/dio_client.dart b/lib/core/http/dio_client.dart
index 07b0e2f..85aa286 100644
--- a/lib/core/http/dio_client.dart
+++ b/lib/core/http/dio_client.dart
@@ -40,7 +40,7 @@ class DioClient {
receiveTimeout: timeout,
sendTimeout: timeout,
headers: {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8',
...?headers,
},
diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart
index c9363d3..d1b23ca 100644
--- a/lib/features/feed/presentation/feed_screen.dart
+++ b/lib/features/feed/presentation/feed_screen.dart
@@ -653,7 +653,7 @@ class _FeedTitle extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
- 'Lunaris',
+ 'Prisma',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart
index 62d93df..ee26919 100644
--- a/lib/features/post/presentation/post_details_screen.dart
+++ b/lib/features/post/presentation/post_details_screen.dart
@@ -1,1635 +1,1635 @@
-import 'dart:ui';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:go_router/go_router.dart';
-import 'package:cached_network_image/cached_network_image.dart';
-import 'package:share_plus/share_plus.dart';
-import 'package:url_launcher/url_launcher.dart';
-
-import '../../../app/app.dart';
-import '../../../app/app_strings.dart';
-import '../../../app/motion.dart';
-import '../../../app/responsive.dart';
-import '../../../backend/backend.dart';
-import '../../../core/utils/result.dart';
-import '../../../shared/widgets/adaptive_scaffold.dart';
-import '../../../shared/widgets/empty_view.dart';
-import '../../../shared/widgets/error_view.dart';
-import '../../../shared/widgets/post_card.dart';
-import '../../../shared/widgets/rating_badge.dart';
-import '../../collections/presentation/collection_form_dialog.dart';
-import '../../favorites/presentation/favorites_controller.dart';
-import '../../feed/presentation/feed_controller.dart';
-import '../../settings/presentation/settings_controller.dart';
-import '../../viewed/presentation/viewed_controller.dart';
-import 'post_details_controller.dart';
-import 'widgets/cloud_mirrors_card.dart';
-import 'widgets/post_action_bar.dart';
-import 'widgets/post_media_viewer.dart';
-import 'widgets/post_tags_panel.dart';
-
-final postCommentsProvider =
- FutureProvider.family, PostDetailsArgs>(
- (ref, args) async {
- final result = await ref
- .watch(providerManagerProvider)
- .getComments(args.providerId, args.postId);
- return result is Success> ? result.data : const [];
-});
-
-class PostDetailsScreen extends ConsumerWidget {
- const PostDetailsScreen({
- required this.providerId,
- required this.postId,
- this.initialPost,
- this.postsList,
- super.key,
- });
-
- final String providerId;
- final String postId;
- final Post? initialPost;
- final List? postsList;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final args = PostDetailsArgs(
- providerId: providerId,
- postId: postId,
- initialPost: initialPost,
- );
- final post = ref.watch(postDetailsControllerProvider(args));
- final settings =
- ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
- final strings = ref.watch(appStringsProvider);
- final feedPosts = postsList ??
- ref.watch(feedControllerProvider).value?.posts ??
- const [];
- final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {};
- return AdaptiveScaffold(
- title: strings.post,
- actions: [
- IconButton(
- tooltip: strings.close,
- onPressed: () => _close(context),
- icon: const Icon(Icons.close_rounded),
- ),
- ],
- body: post.when(
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => ErrorView(message: error.toString()),
- data: (post) {
- if (post == null) return const EmptyView(title: 'Post not found');
- WidgetsBinding.instance.addPostFrameCallback((_) {
- ref.read(viewedHistoryServiceProvider).markViewed(post);
- ref.invalidate(viewedKeysProvider);
- ref.invalidate(viewedControllerProvider);
- });
- final currentIndex = feedPosts.indexWhere(
- (item) => item.providerId == post.providerId && item.id == post.id,
- );
- final previous =
- currentIndex > 0 ? feedPosts[currentIndex - 1] : null;
- final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
- ? feedPosts[currentIndex + 1]
- : null;
- final qualityMode =
- MediaQualityMode.fromName(settings.mediaQualityMode);
- final localMedia = ref
- .watch(downloadedMediaByKeyProvider(post.cacheKey))
- .value;
- final fileSizeBytes = localMedia != null
- ? DownloadedMediaService.getFileSizeSync(localMedia)
- : null;
- final isDownloading = ref.watch(downloadTasksProvider).value?.any(
- (t) =>
- (t.post?.cacheKey == post.cacheKey ||
- t.id == post.cacheKey) &&
- (t.status == DownloadTaskStatus.running ||
- t.status == DownloadTaskStatus.queued),
- ) ??
- false;
- final isTextOnly = post.fileType == 'text' ||
- (post.previewUrl.isEmpty &&
- post.sampleUrl.isEmpty &&
- post.fileUrl.isEmpty &&
- !MediaUrlSelector.isVideo(post));
- final shouldShowCloudCard = isTextOnly
- ? post.cloudLinks.isNotEmpty
- : (post.cloudLinks.isNotEmpty ||
- (post.commentary != null &&
- post.commentary!.trim().isNotEmpty));
-
- if (Responsive.isMobile(context)) {
- if (currentIndex >= 0 && feedPosts.length > 1) {
- return _MobilePostPager(
- posts: feedPosts,
- initialIndex: currentIndex,
- buildDetails: (context, post, mediaGestureLocked,
- onMediaGestureLockChanged) =>
- _buildMobileDetails(
- context,
- ref,
- post,
- settings,
- favoriteKeys,
- qualityMode,
- strings,
- mediaGestureLocked,
- onMediaGestureLockChanged,
- feedPosts,
- ),
- );
- }
- return _buildMobileDetails(
- context,
- ref,
- post,
- settings,
- favoriteKeys,
- qualityMode,
- strings,
- false,
- null,
- feedPosts,
- );
- }
- return Shortcuts(
- shortcuts: {
- LogicalKeySet(LogicalKeyboardKey.arrowLeft):
- const _PreviousPostIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyA):
- const _PreviousPostIntent(),
- LogicalKeySet(LogicalKeyboardKey.arrowRight):
- const _NextPostIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyD):
- const _NextPostIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyF):
- const _ToggleFavoriteIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyC):
- const _AddCollectionIntent(),
- LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
- LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
- },
- child: Actions(
- actions: {
- _PreviousPostIntent: CallbackAction<_PreviousPostIntent>(
- onInvoke: (_) {
- if (previous != null) _openPost(context, previous);
- return null;
- },
- ),
- _NextPostIntent: CallbackAction<_NextPostIntent>(
- onInvoke: (_) {
- if (next != null) _openPost(context, next);
- return null;
- },
- ),
- _ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>(
- onInvoke: (_) {
- _toggleFavorite(ref, post, favoriteKeys);
- return null;
- },
- ),
- _AddCollectionIntent: CallbackAction<_AddCollectionIntent>(
- onInvoke: (_) {
- _addToCollection(context, ref, post);
- return null;
- },
- ),
- _CloseIntent: CallbackAction<_CloseIntent>(
- onInvoke: (_) {
- _close(context);
- return null;
- },
- ),
- _DownloadIntent: CallbackAction<_DownloadIntent>(
- onInvoke: (_) {
- if (settings.allowDownloads) {
- _download(context, ref, post);
- }
- return null;
- },
- ),
- },
- child: Focus(
- autofocus: true,
- child: ListView(
- padding: const EdgeInsets.all(16),
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- IconButton.filledTonal(
- tooltip: strings.ru ? 'Предыдущий' : 'Previous',
- onPressed: previous == null
- ? null
- : () => _openPost(context, previous),
- icon: const Icon(Icons.chevron_left_rounded),
- ),
- Expanded(
- child: Center(
- child: MediaUrlSelector.isVideo(post)
- ? ConstrainedBox(
- constraints: const BoxConstraints(
- maxHeight: 760,
- ),
- child: AspectRatio(
- aspectRatio:
- (post.width > 0 && post.height > 0)
- ? (post.width / post.height)
- .clamp(0.45, 2.4)
- : (16 / 9),
- child: PostMediaViewer(
- key: ValueKey(post.cacheKey),
- post: post,
- localFilePath: localMedia?.savedPath,
- qualityMode: qualityMode,
- mediaHeaders: ref
- .watch(postMediaHeadersProvider(post))
- .value ??
- const {},
- initialPosition: Duration(
- milliseconds: settings.videoPlaybackPositions[
- post.cacheKey] ??
- 0,
- ),
- initialLoop: settings.videoPlayerLoop,
- initialMuted: settings.videoPlayerMuted,
- initialCoverVideo: settings.videoPlayerCover,
- initialHalfVolume:
- settings.videoPlayerHalfVolume,
- initialVolume: settings.videoPlayerVolume,
- onVolumeChanged: (vol) => ref
- .read(settingsControllerProvider.notifier)
- .setVideoPlayerVolume(vol),
- onPlaybackSnapshot: (snapshot) =>
- _saveVideoSnapshot(ref, post, snapshot),
- onPlaybackPreferencesChanged: (snapshot) =>
- _saveVideoPreferences(ref, snapshot),
- ),
- ),
- )
- : SizedBox(
- width: double.infinity,
- height: 760,
- child: PostMediaViewer(
- key: ValueKey(post.cacheKey),
- post: post,
- localFilePath: localMedia?.savedPath,
- qualityMode: qualityMode,
- mediaHeaders: ref
- .watch(postMediaHeadersProvider(post))
- .value ??
- const {},
- initialPosition: Duration(
- milliseconds: settings.videoPlaybackPositions[
- post.cacheKey] ??
- 0,
- ),
- initialLoop: settings.videoPlayerLoop,
- initialMuted: settings.videoPlayerMuted,
- initialCoverVideo: settings.videoPlayerCover,
- initialHalfVolume:
- settings.videoPlayerHalfVolume,
- initialVolume: settings.videoPlayerVolume,
- onVolumeChanged: (vol) => ref
- .read(settingsControllerProvider.notifier)
- .setVideoPlayerVolume(vol),
- onPlaybackSnapshot: (snapshot) =>
- _saveVideoSnapshot(ref, post, snapshot),
- onPlaybackPreferencesChanged: (snapshot) =>
- _saveVideoPreferences(ref, snapshot),
- ),
- ),
- ),
- ),
- IconButton.filledTonal(
- tooltip: strings.ru ? 'Следующий' : 'Next',
- onPressed: next == null
- ? null
- : () => _openPost(context, next),
- icon: const Icon(Icons.chevron_right_rounded),
- ),
- ],
- ),
- if (currentIndex >= 0) ...[
- const SizedBox(height: 10),
- _NeighborStrip(
- posts: feedPosts,
- currentIndex: currentIndex,
- onOpen: (post) => _openPost(context, post),
- ),
- ],
- const SizedBox(height: 16),
- PostActionBar(
- isFavorite: favoriteKeys.contains(post.cacheKey),
- labels: _postActionLabels(strings),
- downloaded: localMedia != null,
- isDownloading: isDownloading,
- onFavorite: () =>
- _toggleFavorite(ref, post, favoriteKeys),
- onCollection: () => _addToCollection(context, ref, post),
- onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
- onOpenSource: () => _openSourcePage(ref, post),
- onCopy: () =>
- Clipboard.setData(ClipboardData(text: post.fileUrl)),
- onSimilar: () => _openSimilar(context, ref, post),
- onHide: () => _hidePost(context, ref, post),
- onDownload: settings.allowDownloads
- ? () => _download(context, ref, post)
- : null,
- onDeleteLocalFile: () =>
- _deleteLocalFile(context, ref, post),
- onShare: () => _sharePost(context, ref, post),
- ),
- const SizedBox(height: 16),
- _PostInfoCard(
- post: post,
- strings: strings,
- localMedia: localMedia,
- fileSizeBytes: fileSizeBytes,
- ),
- if (shouldShowCloudCard) ...[
- const SizedBox(height: 16),
- CloudMirrorsCard(
- links: post.cloudLinks,
- strings: strings,
- commentary: isTextOnly ? null : post.commentary,
- onPlayStream: (streamUrl) => launchUrl(
- Uri.parse(streamUrl),
- mode: LaunchMode.externalApplication,
- ),
- onDownloadStream: settings.allowDownloads
- ? (streamUrl) =>
- _downloadUrl(context, ref, post, streamUrl)
- : null,
- ),
- ],
- const SizedBox(height: 16),
- Container(
- decoration: BoxDecoration(
- color: Theme.of(context).brightness == Brightness.dark
- ? Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
- : Theme.of(context).colorScheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(20),
- border: Border.all(
- color: Theme.of(context).brightness == Brightness.dark
- ? Colors.white.withValues(alpha: 0.12)
- : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.40),
- width: 1.1,
- ),
- ),
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Icon(
- Icons.tag_rounded,
- size: 20,
- color: Theme.of(context).colorScheme.primary,
- ),
- const SizedBox(width: 8),
- Text(
- '${strings.tags} (${post.cleanTags.length})',
- style: Theme.of(context)
- .textTheme
- .titleMedium
- ?.copyWith(fontWeight: FontWeight.bold),
- ),
- ],
- ),
- const SizedBox(height: 12),
- PostTagsPanel(post: post),
- ],
- ),
- ),
- const SizedBox(height: 16),
- _CommentsSection(post: post),
- const SizedBox(height: 80),
- ],
- ),
- ),
- ),
- );
- },
- ),
- );
- }
-
- Widget _buildMobileDetails(
- BuildContext context,
- WidgetRef ref,
- Post post,
- AppSettings settings,
- Set favoriteKeys,
- MediaQualityMode qualityMode,
- AppStrings strings,
- bool mediaGestureLocked,
- ValueChanged? onMediaGestureLockChanged, [
- List? feedPosts,
- ]) {
- final isVideo = MediaUrlSelector.isVideo(post);
- final localMedia =
- ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value;
- final fileSizeBytes = localMedia != null
- ? DownloadedMediaService.getFileSizeSync(localMedia)
- : null;
- final isDownloading = ref.watch(downloadTasksProvider).value?.any(
- (t) =>
- (t.post?.cacheKey == post.cacheKey ||
- t.id == post.cacheKey) &&
- (t.status == DownloadTaskStatus.running ||
- t.status == DownloadTaskStatus.queued),
- ) ??
- false;
- final isTextOnly = post.fileType == 'text' ||
- (post.previewUrl.isEmpty &&
- post.sampleUrl.isEmpty &&
- post.fileUrl.isEmpty &&
- !MediaUrlSelector.isVideo(post));
- final shouldShowCloudCard = isTextOnly
- ? post.cloudLinks.isNotEmpty
- : (post.cloudLinks.isNotEmpty ||
- (post.commentary != null &&
- post.commentary!.trim().isNotEmpty));
-
- return GestureDetector(
- behavior: HitTestBehavior.translucent,
- onVerticalDragEnd: (details) {
- if (mediaGestureLocked) return;
- if ((details.primaryVelocity ?? 0) > 900) _close(context);
- },
- child: ListView(
- padding: EdgeInsets.fromLTRB(
- 8,
- 8,
- 8,
- 120 + MediaQuery.paddingOf(context).bottom,
- ),
- children: [
- GestureDetector(
- behavior: HitTestBehavior.deferToChild,
- onDoubleTap:
- isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys),
- onLongPress: isVideo
- ? null
- : () =>
- _showMobileQuickActions(context, ref, post, favoriteKeys),
- child: isVideo
- ? Center(
- child: ConstrainedBox(
- constraints: BoxConstraints(
- maxHeight: MediaQuery.sizeOf(context).height * 0.70,
- ),
- child: AspectRatio(
- aspectRatio: (post.width > 0 && post.height > 0)
- ? (post.width / post.height).clamp(0.45, 2.4)
- : (16 / 9),
- child: PostMediaViewer(
- key: ValueKey(post.cacheKey),
- post: post,
- localFilePath: localMedia?.savedPath,
- qualityMode: qualityMode,
- mediaHeaders:
- ref.watch(postMediaHeadersProvider(post)).value ??
- const {},
- initialPosition: Duration(
- milliseconds:
- settings.videoPlaybackPositions[post.cacheKey] ??
- 0,
- ),
- initialLoop: settings.videoPlayerLoop,
- initialMuted: settings.videoPlayerMuted,
- initialCoverVideo: settings.videoPlayerCover,
- initialHalfVolume: settings.videoPlayerHalfVolume,
- initialVolume: settings.videoPlayerVolume,
- onVolumeChanged: (vol) => ref
- .read(settingsControllerProvider.notifier)
- .setVideoPlayerVolume(vol),
- onPlaybackSnapshot: (snapshot) =>
- _saveVideoSnapshot(ref, post, snapshot),
- onPlaybackPreferencesChanged: (snapshot) =>
- _saveVideoPreferences(ref, snapshot),
- onMediaGestureLockChanged: (locked) {
- onMediaGestureLockChanged?.call(locked);
- },
- ),
- ),
- ),
- )
- : SizedBox(
- width: double.infinity,
- height: MediaQuery.sizeOf(context).height * 0.62,
- child: PostMediaViewer(
- key: ValueKey(post.cacheKey),
- post: post,
- localFilePath: localMedia?.savedPath,
- qualityMode: qualityMode,
- mediaHeaders:
- ref.watch(postMediaHeadersProvider(post)).value ??
- const {},
- initialPosition: Duration(
- milliseconds:
- settings.videoPlaybackPositions[post.cacheKey] ?? 0,
- ),
- initialLoop: settings.videoPlayerLoop,
- initialMuted: settings.videoPlayerMuted,
- initialCoverVideo: settings.videoPlayerCover,
- initialHalfVolume: settings.videoPlayerHalfVolume,
- initialVolume: settings.videoPlayerVolume,
- onVolumeChanged: (vol) => ref
- .read(settingsControllerProvider.notifier)
- .setVideoPlayerVolume(vol),
- onPlaybackSnapshot: (snapshot) =>
- _saveVideoSnapshot(ref, post, snapshot),
- onPlaybackPreferencesChanged: (snapshot) =>
- _saveVideoPreferences(ref, snapshot),
- onMediaGestureLockChanged: (locked) {
- onMediaGestureLockChanged?.call(locked);
- },
- ),
- ),
- ),
- const SizedBox(height: 12),
- PostActionBar(
- isFavorite: favoriteKeys.contains(post.cacheKey),
- labels: _postActionLabels(strings),
- downloaded: localMedia != null,
- isDownloading: isDownloading,
- onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
- onCollection: () => _addToCollection(context, ref, post),
- onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
- onOpenSource: () => _openSourcePage(ref, post),
- onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
- onSimilar: () => _openSimilar(context, ref, post),
- onHide: () => _hidePost(context, ref, post),
- onDownload: settings.allowDownloads
- ? () => _download(context, ref, post)
- : null,
- onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
- onShare: () => _sharePost(context, ref, post),
- ),
- const SizedBox(height: 12),
- _PostInfoCard(
- post: post,
- strings: strings,
- localMedia: localMedia,
- fileSizeBytes: fileSizeBytes,
- ),
- if (shouldShowCloudCard) ...[
- const SizedBox(height: 12),
- CloudMirrorsCard(
- links: post.cloudLinks,
- strings: strings,
- commentary: isTextOnly ? null : post.commentary,
- onPlayStream: (streamUrl) => launchUrl(
- Uri.parse(streamUrl),
- mode: LaunchMode.externalApplication,
- ),
- onDownloadStream: settings.allowDownloads
- ? (streamUrl) => _downloadUrl(context, ref, post, streamUrl)
- : null,
- ),
- ],
- if (feedPosts != null && feedPosts.length > 1) ...[
- const SizedBox(height: 12),
- _NeighborStrip(
- posts: feedPosts,
- currentIndex: feedPosts.indexWhere(
- (item) =>
- item.providerId == post.providerId && item.id == post.id,
- ),
- onOpen: (p) => _openPost(context, p),
- ),
- ],
- const SizedBox(height: 12),
- Container(
- decoration: BoxDecoration(
- color: Theme.of(context).brightness == Brightness.dark
- ? Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
- : Theme.of(context).colorScheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(20),
- border: Border.all(
- color: Theme.of(context).brightness == Brightness.dark
- ? Colors.white.withValues(alpha: 0.12)
- : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.40),
- width: 1.1,
- ),
- ),
- clipBehavior: Clip.antiAlias,
- child: ExpansionTile(
- tilePadding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
- childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
- initiallyExpanded: false,
- shape: const Border(),
- collapsedShape: const Border(),
- leading: Icon(
- Icons.tag_rounded,
- color: Theme.of(context).colorScheme.primary,
- ),
- title: Text(
- '${strings.tags} (${post.cleanTags.length})',
- style: Theme.of(context).textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- children: [
- Align(
- alignment: Alignment.centerLeft,
- child: PostTagsPanel(post: post),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- _CommentsSection(post: post),
- const SizedBox(height: 24),
- ],
- ),
- );
- }
-
- Future _addToCollection(
- BuildContext context,
- WidgetRef ref,
- Post post,
- ) async {
- final result = await ref.read(collectionServiceProvider).getCollections();
- final collections =
- result is Success> ? result.data : [];
- if (!context.mounted) return;
- await showAddToCollectionPicker(
- context,
- collections: collections,
- onSelected: (collection) {
- ref
- .read(collectionServiceProvider)
- .addPostToCollection(collection.id, post);
- },
- onCreate: () => showCollectionFormDialog(context, ref),
- );
- }
-
- Future _download(BuildContext context, WidgetRef ref, Post post) async {
- if (!context.mounted) return;
- await ref.read(downloadManagerServiceProvider).start(post);
- if (!context.mounted) return;
- final strings = ref.read(appStringsProvider);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(strings.ru ? 'Скачивание началось' : 'Download started'),
- ),
- );
- Future.delayed(const Duration(seconds: 2), () {
- ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
- });
- }
-
- Future _downloadUrl(
- BuildContext context,
- WidgetRef ref,
- Post post,
- String targetUrl,
- ) async {
- final customPost = post.copyWith(fileUrl: targetUrl, sampleUrl: targetUrl);
- await _download(context, ref, customPost);
- }
-
- Future _sharePost(BuildContext context, WidgetRef ref, Post post) async {
- final strings = ref.read(appStringsProvider);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- strings.ru
- ? 'Подготовка файла к отправке...'
- : 'Preparing file for sharing...',
- ),
- duration: const Duration(seconds: 1),
- ),
- );
- try {
- final path =
- await ref.read(downloadServiceProvider).prepareFileForShare(post);
- await Share.shareXFiles([XFile(path)], text: post.source ?? post.fileUrl);
- } catch (e) {
- if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Ошибка отправки: $e')),
- );
- }
- }
-
- Future _showMobileQuickActions(
- BuildContext context,
- WidgetRef ref,
- Post post,
- Set favoriteKeys,
- ) async {
- await showModalBottomSheet(
- context: context,
- showDragHandle: true,
- builder: (sheetContext) => SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- ListTile(
- leading: Icon(
- favoriteKeys.contains(post.cacheKey)
- ? Icons.favorite_rounded
- : Icons.favorite_border_rounded,
- ),
- title: Text(
- favoriteKeys.contains(post.cacheKey)
- ? 'Remove favorite'
- : 'Favorite',
- ),
- onTap: () async {
- Navigator.pop(sheetContext);
- await _toggleFavorite(ref, post, favoriteKeys);
- },
- ),
- ListTile(
- leading: const Icon(Icons.collections_bookmark_rounded),
- title: const Text('Add to collection'),
- onTap: () {
- Navigator.pop(sheetContext);
- _addToCollection(context, ref, post);
- },
- ),
- ListTile(
- leading: const Icon(Icons.copy_rounded),
- title: const Text('Copy link'),
- onTap: () {
- Clipboard.setData(ClipboardData(text: post.fileUrl));
- Navigator.pop(sheetContext);
- },
- ),
- ListTile(
- leading: const Icon(Icons.download_rounded),
- title: const Text('Download'),
- onTap: () {
- Navigator.pop(sheetContext);
- _download(context, ref, post);
- },
- ),
- ],
- ),
- ),
- );
- }
-
- Future _toggleFavorite(
- WidgetRef ref,
- Post post,
- Set favoriteKeys,
- ) async {
- if (favoriteKeys.contains(post.cacheKey)) {
- await ref
- .read(favoriteServiceProvider)
- .removeFavorite(post.id, post.providerId);
- } else {
- await ref.read(favoriteServiceProvider).addFavorite(post);
- await _maybeAutoDownloadFavorite(ref, post);
- }
- ref.invalidate(favoriteKeysProvider);
- ref.invalidate(favoritesControllerProvider);
- }
-
- Future _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
- final settings =
- ref.read(appSettingsProvider).value ?? AppSettings.defaults;
- if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
- await ref.read(downloadManagerServiceProvider).start(post);
- }
-
- Future _deleteLocalFile(
- BuildContext context,
- WidgetRef ref,
- Post post,
- ) async {
- final strings = ref.read(appStringsProvider);
- await ref.read(downloadedMediaServiceProvider).deleteLocalFile(
- post.cacheKey,
- );
- ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
- if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(strings.deleteLocalFile)),
- );
- }
-
- PostActionLabels _postActionLabels(AppStrings strings) {
- return PostActionLabels(
- favorite: strings.favorite,
- unfavorite: strings.removeFavorite,
- collection: strings.collection,
- similar: strings.similar,
- openOriginal: strings.open,
- copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
- download: strings.download,
- deleteLocalFile: strings.deleteLocalFile,
- hidePost: strings.hidePost,
- share: strings.ru ? 'Поделиться' : 'Share',
- );
- }
-
- Future _hidePost(
- BuildContext context,
- WidgetRef ref,
- Post post,
- ) async {
- await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey);
- ref.invalidate(appSettingsProvider);
- ref.read(feedControllerProvider.notifier).refresh();
- if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Post hidden locally')),
- );
- _close(context);
- }
-
- void _openPost(BuildContext context, Post post) {
- _replacePost(context, post);
- }
-
- void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
- context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
- }
-
- Future _openSourcePage(WidgetRef ref, Post post) async {
- final source = post.source?.trim();
- String? url = source != null && source.isNotEmpty ? source : null;
- if (url == null) {
- final result =
- await ref.read(providerManagerProvider).getPostPageUrl(post);
- if (result is Success) url = result.data;
- }
- if (url == null || url.isEmpty) return;
- await launchUrl(Uri.parse(url));
- }
-
- void _replacePost(BuildContext context, Post post) {
- context.replace('/post/${post.providerId}/${post.id}', extra: post);
- }
-
- void _close(BuildContext context) {
- if (Navigator.of(context).canPop()) {
- context.pop();
- return;
- }
- context.go('/');
- }
-
- Future _saveVideoPreferences(
- WidgetRef ref,
- VideoPlaybackSnapshot snapshot,
- ) async {
- final result = await ref.read(settingsServiceProvider).getSettings();
- if (result is! Success) return;
- final settings = result.data;
- if (settings.videoPlayerMuted == snapshot.muted &&
- settings.videoPlayerHalfVolume == snapshot.halfVolume &&
- settings.videoPlayerLoop == snapshot.loopVideo &&
- settings.videoPlayerCover == snapshot.coverVideo &&
- (settings.videoPlayerVolume - snapshot.volume).abs() < 0.01) {
- return;
- }
- await ref.read(settingsServiceProvider).updateSettings(
- settings.copyWith(
- videoPlayerMuted: snapshot.muted,
- videoPlayerHalfVolume: snapshot.halfVolume,
- videoPlayerLoop: snapshot.loopVideo,
- videoPlayerCover: snapshot.coverVideo,
- videoPlayerVolume: snapshot.volume,
- ),
- );
- ref.invalidate(appSettingsProvider);
- }
-
- Future _saveVideoSnapshot(
- WidgetRef ref,
- Post post,
- VideoPlaybackSnapshot snapshot,
- ) async {
- await ref.read(settingsServiceProvider).saveVideoPlaybackPosition(
- post.cacheKey,
- snapshot.position.inMilliseconds,
- );
- ref.invalidate(appSettingsProvider);
- }
-}
-
-class _MobilePostPager extends StatefulWidget {
- const _MobilePostPager({
- required this.posts,
- required this.initialIndex,
- required this.buildDetails,
- });
-
- final List posts;
- final int initialIndex;
- final Widget Function(
- BuildContext context,
- Post post,
- bool mediaGestureLocked,
- ValueChanged onMediaGestureLockChanged,
- ) buildDetails;
-
- @override
- State<_MobilePostPager> createState() => _MobilePostPagerState();
-}
-
-class _MobilePostPagerState extends State<_MobilePostPager> {
- late final PageController _controller;
- bool _mediaGestureLocked = false;
-
- @override
- void initState() {
- super.initState();
- _controller = PageController(initialPage: widget.initialIndex);
- WidgetsBinding.instance.addPostFrameCallback((_) {
- _prefetchAround(widget.initialIndex);
- });
- }
-
- @override
- void didUpdateWidget(covariant _MobilePostPager oldWidget) {
- super.didUpdateWidget(oldWidget);
- // Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
- if (oldWidget.initialIndex != widget.initialIndex &&
- widget.initialIndex >= 0 &&
- widget.initialIndex < widget.posts.length) {
- if (_controller.hasClients) {
- _controller.jumpToPage(widget.initialIndex);
- }
- WidgetsBinding.instance.addPostFrameCallback((_) {
- _prefetchAround(widget.initialIndex);
- });
- }
- }
-
- @override
- void dispose() {
- _controller.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- return ScrollConfiguration(
- behavior: ScrollConfiguration.of(context).copyWith(
- dragDevices: {
- PointerDeviceKind.touch,
- PointerDeviceKind.mouse,
- PointerDeviceKind.trackpad,
- PointerDeviceKind.stylus,
- },
- ),
- child: PageView.builder(
- controller: _controller,
- physics: _mediaGestureLocked
- ? const NeverScrollableScrollPhysics()
- : const PageScrollPhysics(),
- itemCount: widget.posts.length,
- onPageChanged: (index) {
- _prefetchAround(index);
- },
- itemBuilder: (context, index) {
- final initialPost = widget.posts[index];
- return Consumer(
- builder: (context, ref, _) {
- final args = PostDetailsArgs(
- providerId: initialPost.providerId,
- postId: initialPost.id,
- initialPost: initialPost,
- );
- final post = ref.watch(postDetailsControllerProvider(args));
- return post.when(
- loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, _) => ErrorView(message: error.toString()),
- data: (resolvedPost) => _KeepAlivePostPage(
- child: KeyedSubtree(
- key: ValueKey((resolvedPost ?? initialPost).cacheKey),
- child: widget.buildDetails(
- context,
- resolvedPost ?? initialPost,
- _mediaGestureLocked,
- _setMediaGestureLocked,
- ),
- ),
- ),
- );
- },
- );
- },
- ),
- );
-}
-
- void _setMediaGestureLocked(bool locked) {
- if (_mediaGestureLocked == locked) return;
- setState(() => _mediaGestureLocked = locked);
- }
-
- void _prefetchAround(int index) {
- for (final offset in [-2, -1, 0, 1, 2]) {
- final target = index + offset;
- if (target < 0 || target >= widget.posts.length) continue;
- final post = widget.posts[target];
- final urls = [
- post.previewUrl,
- post.sampleUrl,
- if (post.fileType.toLowerCase().contains('gif')) post.fileUrl,
- ].where((url) => url.trim().isNotEmpty).toSet();
- for (final url in urls) {
- precacheImage(
- CachedNetworkImageProvider(url, headers: _headersFor(post)),
- context,
- );
- }
- }
- }
-
- Map _headersFor(Post post) {
- return const {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
- 'Accept': '*/*',
- };
- }
-}
-
-class _KeepAlivePostPage extends StatefulWidget {
- const _KeepAlivePostPage({required this.child});
-
- final Widget child;
-
- @override
- State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState();
-}
-
-class _KeepAlivePostPageState extends State<_KeepAlivePostPage>
- with AutomaticKeepAliveClientMixin {
- @override
- bool get wantKeepAlive => true;
-
- @override
- Widget build(BuildContext context) {
- super.build(context);
- return widget.child;
- }
-}
-
-class _NeighborStrip extends StatelessWidget {
- const _NeighborStrip({
- required this.posts,
- required this.currentIndex,
- required this.onOpen,
- });
-
- final List posts;
- final int currentIndex;
- final ValueChanged onOpen;
-
- @override
- Widget build(BuildContext context) {
- final start = (currentIndex - 3).clamp(0, posts.length);
- final end = (currentIndex + 4).clamp(0, posts.length);
- final visible = posts.sublist(start, end);
- return SizedBox(
- height: 72,
- child: ListView.separated(
- scrollDirection: Axis.horizontal,
- itemCount: visible.length,
- separatorBuilder: (_, __) => const SizedBox(width: 8),
- itemBuilder: (context, index) {
- final post = visible[index];
- final selected = post.cacheKey == posts[currentIndex].cacheKey;
- return InkWell(
- onTap: () => onOpen(post),
- borderRadius: BorderRadius.circular(8),
- child: AnimatedContainer(
- duration: AppMotion.duration(context, 140),
- width: 72,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: selected
- ? Theme.of(context).colorScheme.primary
- : Colors.transparent,
- width: 2,
- ),
- ),
- clipBehavior: Clip.antiAlias,
- child: CachedNetworkImage(
- imageUrl: post.previewUrl.isNotEmpty
- ? post.previewUrl
- : post.sampleUrl,
- fit: BoxFit.cover,
- errorWidget: (_, __, ___) =>
- const Center(child: Icon(Icons.broken_image_rounded)),
- ),
- ),
- );
- },
- ),
- );
- }
-}
-
-class _PreviousPostIntent extends Intent {
- const _PreviousPostIntent();
-}
-
-class _NextPostIntent extends Intent {
- const _NextPostIntent();
-}
-
-class _ToggleFavoriteIntent extends Intent {
- const _ToggleFavoriteIntent();
-}
-
-class _AddCollectionIntent extends Intent {
- const _AddCollectionIntent();
-}
-
-class _CloseIntent extends Intent {
- const _CloseIntent();
-}
-
-class _DownloadIntent extends Intent {
- const _DownloadIntent();
-}
-
-class _CommentsSection extends ConsumerStatefulWidget {
- const _CommentsSection({required this.post});
-
- final Post post;
-
- @override
- ConsumerState<_CommentsSection> createState() => _CommentsSectionState();
-}
-
-class _CommentsSectionState extends ConsumerState<_CommentsSection> {
- bool _expanded = false;
-
- @override
- Widget build(BuildContext context) {
- final strings = ref.watch(appStringsProvider);
- final comments = _expanded
- ? ref.watch(
- postCommentsProvider(
- PostDetailsArgs(
- providerId: widget.post.providerId,
- postId: widget.post.id,
- ),
- ),
- )
- : null;
- final scheme = Theme.of(context).colorScheme;
-
- return Container(
- decoration: BoxDecoration(
- color: scheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(18),
- border: Border.all(
- color: scheme.outlineVariant.withValues(alpha: 0.35),
- ),
- ),
- clipBehavior: Clip.antiAlias,
- child: ExpansionTile(
- tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
- childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
- initiallyExpanded: false,
- shape: const Border(),
- collapsedShape: const Border(),
- leading: Icon(
- Icons.chat_bubble_outline_rounded,
- color: scheme.primary,
- ),
- onExpansionChanged: (value) => setState(() => _expanded = value),
- title: Text(
- strings.comments,
- style: Theme.of(context).textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- children: [
- (comments ?? const AsyncValue>.data([])).when(
- loading: () => const Padding(
- padding: EdgeInsets.all(16),
- child: Center(child: CircularProgressIndicator()),
- ),
- error: (_, __) => Padding(
- padding: const EdgeInsets.all(12),
- child: Text(
- strings.commentsUnavailable,
- style: TextStyle(color: scheme.outline),
- ),
- ),
- data: (items) {
- if (items.isEmpty) {
- return Padding(
- padding: const EdgeInsets.all(12),
- child: Text(
- strings.noComments,
- style: TextStyle(color: scheme.onSurfaceVariant),
- ),
- );
- }
- return Column(
- children: [
- for (final comment in items)
- Container(
- margin: const EdgeInsets.only(bottom: 8),
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: scheme.surfaceContainerHigh,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- CircleAvatar(
- radius: 14,
- backgroundColor: scheme.primaryContainer,
- child: Text(
- (comment.authorName.isNotEmpty
- ? comment.authorName[0]
- : '?')
- .toUpperCase(),
- style: TextStyle(
- fontSize: 12,
- fontWeight: FontWeight.bold,
- color: scheme.onPrimaryContainer,
- ),
- ),
- ),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- comment.authorName.isEmpty
- ? strings.anonymous
- : comment.authorName,
- style: Theme.of(context)
- .textTheme
- .labelMedium
- ?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- comment.body,
- style: Theme.of(context).textTheme.bodySmall,
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ],
- );
- },
- ),
- ],
- ),
- );
- }
-}
-
-class _PostInfoCard extends StatefulWidget {
- 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
- State<_PostInfoCard> createState() => _PostInfoCardState();
-}
-
-class _PostInfoCardState extends State<_PostInfoCard> {
- // Lazily resolved dimensions (used when post.width/height == 0).
- int? _resolvedWidth;
- int? _resolvedHeight;
- bool _resolvingDimensions = false;
-
- static final _dimensionCache = {};
-
- static bool _isImageType(String fileType) {
- const imageTypes = {'image', 'jpeg', 'jpg', 'png', 'gif', 'webp', 'avif'};
- return imageTypes.contains(fileType.toLowerCase());
- }
-
- @override
- void initState() {
- super.initState();
- _maybeResolveDimensions();
- }
-
- @override
- void didUpdateWidget(_PostInfoCard old) {
- super.didUpdateWidget(old);
- if (old.post.fileUrl != widget.post.fileUrl) {
- _resolvedWidth = null;
- _resolvedHeight = null;
- _resolvingDimensions = false;
- _maybeResolveDimensions();
- }
- }
-
- void _maybeResolveDimensions() {
- final post = widget.post;
- // Only attempt lazy resolution when dimensions are unknown (0) and it's
- // an image (not video/link which we can't resolve this way).
- if (post.width != 0 || post.height != 0) return;
- if (!_isImageType(post.fileType)) return;
- final url = post.fileUrl.isNotEmpty ? post.fileUrl : post.sampleUrl;
- if (url.isEmpty) return;
-
- // Check cache first.
- if (_dimensionCache.containsKey(url)) {
- final cached = _dimensionCache[url]!;
- _resolvedWidth = cached.$1;
- _resolvedHeight = cached.$2;
- return;
- }
-
- if (_resolvingDimensions) return;
- _resolvingDimensions = true;
-
- final imageProvider = NetworkImage(url);
- final completer = imageProvider.resolve(ImageConfiguration.empty);
- completer.addListener(
- ImageStreamListener(
- (info, _) {
- final w = info.image.width;
- final h = info.image.height;
- _dimensionCache[url] = (w, h);
- if (mounted) {
- setState(() {
- _resolvedWidth = w;
- _resolvedHeight = h;
- _resolvingDimensions = false;
- });
- }
- },
- onError: (_, __) {
- if (mounted) setState(() => _resolvingDimensions = false);
- },
- ),
- );
- }
-
- @override
- Widget build(BuildContext context) {
- final post = widget.post;
- final strings = widget.strings;
- final localMedia = widget.localMedia;
- final fileSizeBytes = widget.fileSizeBytes;
-
- 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();
-
- // Determine which dimensions to display.
- final displayWidth = post.width != 0 ? post.width : _resolvedWidth;
- final displayHeight = post.height != 0 ? post.height : _resolvedHeight;
- final hasDimensions = displayWidth != null && displayHeight != null;
- final dimensionLabel = hasDimensions
- ? '$displayWidth × $displayHeight'
- : (_resolvingDimensions ? '… × …' : null);
-
- 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(
- _formatPostDate(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: [
- if (dimensionLabel != null)
- _SpecBadge(
- icon: Icons.aspect_ratio_rounded,
- label: dimensionLabel,
- ),
- 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,
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
-}
-
-String _formatPostDate(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,
- ),
- ),
- ],
- ),
- );
- }
-}
+import 'dart:ui';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:share_plus/share_plus.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../../app/app.dart';
+import '../../../app/app_strings.dart';
+import '../../../app/motion.dart';
+import '../../../app/responsive.dart';
+import '../../../backend/backend.dart';
+import '../../../core/utils/result.dart';
+import '../../../shared/widgets/adaptive_scaffold.dart';
+import '../../../shared/widgets/empty_view.dart';
+import '../../../shared/widgets/error_view.dart';
+import '../../../shared/widgets/post_card.dart';
+import '../../../shared/widgets/rating_badge.dart';
+import '../../collections/presentation/collection_form_dialog.dart';
+import '../../favorites/presentation/favorites_controller.dart';
+import '../../feed/presentation/feed_controller.dart';
+import '../../settings/presentation/settings_controller.dart';
+import '../../viewed/presentation/viewed_controller.dart';
+import 'post_details_controller.dart';
+import 'widgets/cloud_mirrors_card.dart';
+import 'widgets/post_action_bar.dart';
+import 'widgets/post_media_viewer.dart';
+import 'widgets/post_tags_panel.dart';
+
+final postCommentsProvider =
+ FutureProvider.family, PostDetailsArgs>(
+ (ref, args) async {
+ final result = await ref
+ .watch(providerManagerProvider)
+ .getComments(args.providerId, args.postId);
+ return result is Success> ? result.data : const [];
+});
+
+class PostDetailsScreen extends ConsumerWidget {
+ const PostDetailsScreen({
+ required this.providerId,
+ required this.postId,
+ this.initialPost,
+ this.postsList,
+ super.key,
+ });
+
+ final String providerId;
+ final String postId;
+ final Post? initialPost;
+ final List? postsList;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final args = PostDetailsArgs(
+ providerId: providerId,
+ postId: postId,
+ initialPost: initialPost,
+ );
+ final post = ref.watch(postDetailsControllerProvider(args));
+ final settings =
+ ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
+ final strings = ref.watch(appStringsProvider);
+ final feedPosts = postsList ??
+ ref.watch(feedControllerProvider).value?.posts ??
+ const [];
+ final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {};
+ return AdaptiveScaffold(
+ title: strings.post,
+ actions: [
+ IconButton(
+ tooltip: strings.close,
+ onPressed: () => _close(context),
+ icon: const Icon(Icons.close_rounded),
+ ),
+ ],
+ body: post.when(
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) => ErrorView(message: error.toString()),
+ data: (post) {
+ if (post == null) return const EmptyView(title: 'Post not found');
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref.read(viewedHistoryServiceProvider).markViewed(post);
+ ref.invalidate(viewedKeysProvider);
+ ref.invalidate(viewedControllerProvider);
+ });
+ final currentIndex = feedPosts.indexWhere(
+ (item) => item.providerId == post.providerId && item.id == post.id,
+ );
+ final previous =
+ currentIndex > 0 ? feedPosts[currentIndex - 1] : null;
+ final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
+ ? feedPosts[currentIndex + 1]
+ : null;
+ final qualityMode =
+ MediaQualityMode.fromName(settings.mediaQualityMode);
+ final localMedia = ref
+ .watch(downloadedMediaByKeyProvider(post.cacheKey))
+ .value;
+ final fileSizeBytes = localMedia != null
+ ? DownloadedMediaService.getFileSizeSync(localMedia)
+ : null;
+ final isDownloading = ref.watch(downloadTasksProvider).value?.any(
+ (t) =>
+ (t.post?.cacheKey == post.cacheKey ||
+ t.id == post.cacheKey) &&
+ (t.status == DownloadTaskStatus.running ||
+ t.status == DownloadTaskStatus.queued),
+ ) ??
+ false;
+ final isTextOnly = post.fileType == 'text' ||
+ (post.previewUrl.isEmpty &&
+ post.sampleUrl.isEmpty &&
+ post.fileUrl.isEmpty &&
+ !MediaUrlSelector.isVideo(post));
+ final shouldShowCloudCard = isTextOnly
+ ? post.cloudLinks.isNotEmpty
+ : (post.cloudLinks.isNotEmpty ||
+ (post.commentary != null &&
+ post.commentary!.trim().isNotEmpty));
+
+ if (Responsive.isMobile(context)) {
+ if (currentIndex >= 0 && feedPosts.length > 1) {
+ return _MobilePostPager(
+ posts: feedPosts,
+ initialIndex: currentIndex,
+ buildDetails: (context, post, mediaGestureLocked,
+ onMediaGestureLockChanged) =>
+ _buildMobileDetails(
+ context,
+ ref,
+ post,
+ settings,
+ favoriteKeys,
+ qualityMode,
+ strings,
+ mediaGestureLocked,
+ onMediaGestureLockChanged,
+ feedPosts,
+ ),
+ );
+ }
+ return _buildMobileDetails(
+ context,
+ ref,
+ post,
+ settings,
+ favoriteKeys,
+ qualityMode,
+ strings,
+ false,
+ null,
+ feedPosts,
+ );
+ }
+ return Shortcuts(
+ shortcuts: {
+ LogicalKeySet(LogicalKeyboardKey.arrowLeft):
+ const _PreviousPostIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyA):
+ const _PreviousPostIntent(),
+ LogicalKeySet(LogicalKeyboardKey.arrowRight):
+ const _NextPostIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyD):
+ const _NextPostIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyF):
+ const _ToggleFavoriteIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyC):
+ const _AddCollectionIntent(),
+ LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
+ LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
+ },
+ child: Actions(
+ actions: {
+ _PreviousPostIntent: CallbackAction<_PreviousPostIntent>(
+ onInvoke: (_) {
+ if (previous != null) _openPost(context, previous);
+ return null;
+ },
+ ),
+ _NextPostIntent: CallbackAction<_NextPostIntent>(
+ onInvoke: (_) {
+ if (next != null) _openPost(context, next);
+ return null;
+ },
+ ),
+ _ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>(
+ onInvoke: (_) {
+ _toggleFavorite(ref, post, favoriteKeys);
+ return null;
+ },
+ ),
+ _AddCollectionIntent: CallbackAction<_AddCollectionIntent>(
+ onInvoke: (_) {
+ _addToCollection(context, ref, post);
+ return null;
+ },
+ ),
+ _CloseIntent: CallbackAction<_CloseIntent>(
+ onInvoke: (_) {
+ _close(context);
+ return null;
+ },
+ ),
+ _DownloadIntent: CallbackAction<_DownloadIntent>(
+ onInvoke: (_) {
+ if (settings.allowDownloads) {
+ _download(context, ref, post);
+ }
+ return null;
+ },
+ ),
+ },
+ child: Focus(
+ autofocus: true,
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ IconButton.filledTonal(
+ tooltip: strings.ru ? 'Предыдущий' : 'Previous',
+ onPressed: previous == null
+ ? null
+ : () => _openPost(context, previous),
+ icon: const Icon(Icons.chevron_left_rounded),
+ ),
+ Expanded(
+ child: Center(
+ child: MediaUrlSelector.isVideo(post)
+ ? ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxHeight: 760,
+ ),
+ child: AspectRatio(
+ aspectRatio:
+ (post.width > 0 && post.height > 0)
+ ? (post.width / post.height)
+ .clamp(0.45, 2.4)
+ : (16 / 9),
+ child: PostMediaViewer(
+ key: ValueKey(post.cacheKey),
+ post: post,
+ localFilePath: localMedia?.savedPath,
+ qualityMode: qualityMode,
+ mediaHeaders: ref
+ .watch(postMediaHeadersProvider(post))
+ .value ??
+ const {},
+ initialPosition: Duration(
+ milliseconds: settings.videoPlaybackPositions[
+ post.cacheKey] ??
+ 0,
+ ),
+ initialLoop: settings.videoPlayerLoop,
+ initialMuted: settings.videoPlayerMuted,
+ initialCoverVideo: settings.videoPlayerCover,
+ initialHalfVolume:
+ settings.videoPlayerHalfVolume,
+ initialVolume: settings.videoPlayerVolume,
+ onVolumeChanged: (vol) => ref
+ .read(settingsControllerProvider.notifier)
+ .setVideoPlayerVolume(vol),
+ onPlaybackSnapshot: (snapshot) =>
+ _saveVideoSnapshot(ref, post, snapshot),
+ onPlaybackPreferencesChanged: (snapshot) =>
+ _saveVideoPreferences(ref, snapshot),
+ ),
+ ),
+ )
+ : SizedBox(
+ width: double.infinity,
+ height: 760,
+ child: PostMediaViewer(
+ key: ValueKey(post.cacheKey),
+ post: post,
+ localFilePath: localMedia?.savedPath,
+ qualityMode: qualityMode,
+ mediaHeaders: ref
+ .watch(postMediaHeadersProvider(post))
+ .value ??
+ const {},
+ initialPosition: Duration(
+ milliseconds: settings.videoPlaybackPositions[
+ post.cacheKey] ??
+ 0,
+ ),
+ initialLoop: settings.videoPlayerLoop,
+ initialMuted: settings.videoPlayerMuted,
+ initialCoverVideo: settings.videoPlayerCover,
+ initialHalfVolume:
+ settings.videoPlayerHalfVolume,
+ initialVolume: settings.videoPlayerVolume,
+ onVolumeChanged: (vol) => ref
+ .read(settingsControllerProvider.notifier)
+ .setVideoPlayerVolume(vol),
+ onPlaybackSnapshot: (snapshot) =>
+ _saveVideoSnapshot(ref, post, snapshot),
+ onPlaybackPreferencesChanged: (snapshot) =>
+ _saveVideoPreferences(ref, snapshot),
+ ),
+ ),
+ ),
+ ),
+ IconButton.filledTonal(
+ tooltip: strings.ru ? 'Следующий' : 'Next',
+ onPressed: next == null
+ ? null
+ : () => _openPost(context, next),
+ icon: const Icon(Icons.chevron_right_rounded),
+ ),
+ ],
+ ),
+ if (currentIndex >= 0) ...[
+ const SizedBox(height: 10),
+ _NeighborStrip(
+ posts: feedPosts,
+ currentIndex: currentIndex,
+ onOpen: (post) => _openPost(context, post),
+ ),
+ ],
+ const SizedBox(height: 16),
+ PostActionBar(
+ isFavorite: favoriteKeys.contains(post.cacheKey),
+ labels: _postActionLabels(strings),
+ downloaded: localMedia != null,
+ isDownloading: isDownloading,
+ onFavorite: () =>
+ _toggleFavorite(ref, post, favoriteKeys),
+ onCollection: () => _addToCollection(context, ref, post),
+ onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
+ onOpenSource: () => _openSourcePage(ref, post),
+ onCopy: () =>
+ Clipboard.setData(ClipboardData(text: post.fileUrl)),
+ onSimilar: () => _openSimilar(context, ref, post),
+ onHide: () => _hidePost(context, ref, post),
+ onDownload: settings.allowDownloads
+ ? () => _download(context, ref, post)
+ : null,
+ onDeleteLocalFile: () =>
+ _deleteLocalFile(context, ref, post),
+ onShare: () => _sharePost(context, ref, post),
+ ),
+ const SizedBox(height: 16),
+ _PostInfoCard(
+ post: post,
+ strings: strings,
+ localMedia: localMedia,
+ fileSizeBytes: fileSizeBytes,
+ ),
+ if (shouldShowCloudCard) ...[
+ const SizedBox(height: 16),
+ CloudMirrorsCard(
+ links: post.cloudLinks,
+ strings: strings,
+ commentary: isTextOnly ? null : post.commentary,
+ onPlayStream: (streamUrl) => launchUrl(
+ Uri.parse(streamUrl),
+ mode: LaunchMode.externalApplication,
+ ),
+ onDownloadStream: settings.allowDownloads
+ ? (streamUrl) =>
+ _downloadUrl(context, ref, post, streamUrl)
+ : null,
+ ),
+ ],
+ const SizedBox(height: 16),
+ Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
+ : Theme.of(context).colorScheme.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.12)
+ : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.40),
+ width: 1.1,
+ ),
+ ),
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ Icons.tag_rounded,
+ size: 20,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ '${strings.tags} (${post.cleanTags.length})',
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium
+ ?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ PostTagsPanel(post: post),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ _CommentsSection(post: post),
+ const SizedBox(height: 80),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildMobileDetails(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ AppSettings settings,
+ Set favoriteKeys,
+ MediaQualityMode qualityMode,
+ AppStrings strings,
+ bool mediaGestureLocked,
+ ValueChanged? onMediaGestureLockChanged, [
+ List? feedPosts,
+ ]) {
+ final isVideo = MediaUrlSelector.isVideo(post);
+ final localMedia =
+ ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value;
+ final fileSizeBytes = localMedia != null
+ ? DownloadedMediaService.getFileSizeSync(localMedia)
+ : null;
+ final isDownloading = ref.watch(downloadTasksProvider).value?.any(
+ (t) =>
+ (t.post?.cacheKey == post.cacheKey ||
+ t.id == post.cacheKey) &&
+ (t.status == DownloadTaskStatus.running ||
+ t.status == DownloadTaskStatus.queued),
+ ) ??
+ false;
+ final isTextOnly = post.fileType == 'text' ||
+ (post.previewUrl.isEmpty &&
+ post.sampleUrl.isEmpty &&
+ post.fileUrl.isEmpty &&
+ !MediaUrlSelector.isVideo(post));
+ final shouldShowCloudCard = isTextOnly
+ ? post.cloudLinks.isNotEmpty
+ : (post.cloudLinks.isNotEmpty ||
+ (post.commentary != null &&
+ post.commentary!.trim().isNotEmpty));
+
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onVerticalDragEnd: (details) {
+ if (mediaGestureLocked) return;
+ if ((details.primaryVelocity ?? 0) > 900) _close(context);
+ },
+ child: ListView(
+ padding: EdgeInsets.fromLTRB(
+ 8,
+ 8,
+ 8,
+ 120 + MediaQuery.paddingOf(context).bottom,
+ ),
+ children: [
+ GestureDetector(
+ behavior: HitTestBehavior.deferToChild,
+ onDoubleTap:
+ isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys),
+ onLongPress: isVideo
+ ? null
+ : () =>
+ _showMobileQuickActions(context, ref, post, favoriteKeys),
+ child: isVideo
+ ? Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: MediaQuery.sizeOf(context).height * 0.70,
+ ),
+ child: AspectRatio(
+ aspectRatio: (post.width > 0 && post.height > 0)
+ ? (post.width / post.height).clamp(0.45, 2.4)
+ : (16 / 9),
+ child: PostMediaViewer(
+ key: ValueKey(post.cacheKey),
+ post: post,
+ localFilePath: localMedia?.savedPath,
+ qualityMode: qualityMode,
+ mediaHeaders:
+ ref.watch(postMediaHeadersProvider(post)).value ??
+ const {},
+ initialPosition: Duration(
+ milliseconds:
+ settings.videoPlaybackPositions[post.cacheKey] ??
+ 0,
+ ),
+ initialLoop: settings.videoPlayerLoop,
+ initialMuted: settings.videoPlayerMuted,
+ initialCoverVideo: settings.videoPlayerCover,
+ initialHalfVolume: settings.videoPlayerHalfVolume,
+ initialVolume: settings.videoPlayerVolume,
+ onVolumeChanged: (vol) => ref
+ .read(settingsControllerProvider.notifier)
+ .setVideoPlayerVolume(vol),
+ onPlaybackSnapshot: (snapshot) =>
+ _saveVideoSnapshot(ref, post, snapshot),
+ onPlaybackPreferencesChanged: (snapshot) =>
+ _saveVideoPreferences(ref, snapshot),
+ onMediaGestureLockChanged: (locked) {
+ onMediaGestureLockChanged?.call(locked);
+ },
+ ),
+ ),
+ ),
+ )
+ : SizedBox(
+ width: double.infinity,
+ height: MediaQuery.sizeOf(context).height * 0.62,
+ child: PostMediaViewer(
+ key: ValueKey(post.cacheKey),
+ post: post,
+ localFilePath: localMedia?.savedPath,
+ qualityMode: qualityMode,
+ mediaHeaders:
+ ref.watch(postMediaHeadersProvider(post)).value ??
+ const {},
+ initialPosition: Duration(
+ milliseconds:
+ settings.videoPlaybackPositions[post.cacheKey] ?? 0,
+ ),
+ initialLoop: settings.videoPlayerLoop,
+ initialMuted: settings.videoPlayerMuted,
+ initialCoverVideo: settings.videoPlayerCover,
+ initialHalfVolume: settings.videoPlayerHalfVolume,
+ initialVolume: settings.videoPlayerVolume,
+ onVolumeChanged: (vol) => ref
+ .read(settingsControllerProvider.notifier)
+ .setVideoPlayerVolume(vol),
+ onPlaybackSnapshot: (snapshot) =>
+ _saveVideoSnapshot(ref, post, snapshot),
+ onPlaybackPreferencesChanged: (snapshot) =>
+ _saveVideoPreferences(ref, snapshot),
+ onMediaGestureLockChanged: (locked) {
+ onMediaGestureLockChanged?.call(locked);
+ },
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ PostActionBar(
+ isFavorite: favoriteKeys.contains(post.cacheKey),
+ labels: _postActionLabels(strings),
+ downloaded: localMedia != null,
+ isDownloading: isDownloading,
+ onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
+ onCollection: () => _addToCollection(context, ref, post),
+ onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
+ onOpenSource: () => _openSourcePage(ref, post),
+ onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
+ onSimilar: () => _openSimilar(context, ref, post),
+ onHide: () => _hidePost(context, ref, post),
+ onDownload: settings.allowDownloads
+ ? () => _download(context, ref, post)
+ : null,
+ onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
+ onShare: () => _sharePost(context, ref, post),
+ ),
+ const SizedBox(height: 12),
+ _PostInfoCard(
+ post: post,
+ strings: strings,
+ localMedia: localMedia,
+ fileSizeBytes: fileSizeBytes,
+ ),
+ if (shouldShowCloudCard) ...[
+ const SizedBox(height: 12),
+ CloudMirrorsCard(
+ links: post.cloudLinks,
+ strings: strings,
+ commentary: isTextOnly ? null : post.commentary,
+ onPlayStream: (streamUrl) => launchUrl(
+ Uri.parse(streamUrl),
+ mode: LaunchMode.externalApplication,
+ ),
+ onDownloadStream: settings.allowDownloads
+ ? (streamUrl) => _downloadUrl(context, ref, post, streamUrl)
+ : null,
+ ),
+ ],
+ if (feedPosts != null && feedPosts.length > 1) ...[
+ const SizedBox(height: 12),
+ _NeighborStrip(
+ posts: feedPosts,
+ currentIndex: feedPosts.indexWhere(
+ (item) =>
+ item.providerId == post.providerId && item.id == post.id,
+ ),
+ onOpen: (p) => _openPost(context, p),
+ ),
+ ],
+ const SizedBox(height: 12),
+ Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
+ : Theme.of(context).colorScheme.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(
+ color: Theme.of(context).brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.12)
+ : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.40),
+ width: 1.1,
+ ),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: ExpansionTile(
+ tilePadding:
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
+ childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
+ initiallyExpanded: false,
+ shape: const Border(),
+ collapsedShape: const Border(),
+ leading: Icon(
+ Icons.tag_rounded,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ title: Text(
+ '${strings.tags} (${post.cleanTags.length})',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ children: [
+ Align(
+ alignment: Alignment.centerLeft,
+ child: PostTagsPanel(post: post),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ _CommentsSection(post: post),
+ const SizedBox(height: 24),
+ ],
+ ),
+ );
+ }
+
+ Future _addToCollection(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ ) async {
+ final result = await ref.read(collectionServiceProvider).getCollections();
+ final collections =
+ result is Success> ? result.data : [];
+ if (!context.mounted) return;
+ await showAddToCollectionPicker(
+ context,
+ collections: collections,
+ onSelected: (collection) {
+ ref
+ .read(collectionServiceProvider)
+ .addPostToCollection(collection.id, post);
+ },
+ onCreate: () => showCollectionFormDialog(context, ref),
+ );
+ }
+
+ Future _download(BuildContext context, WidgetRef ref, Post post) async {
+ if (!context.mounted) return;
+ await ref.read(downloadManagerServiceProvider).start(post);
+ if (!context.mounted) return;
+ final strings = ref.read(appStringsProvider);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(strings.ru ? 'Скачивание началось' : 'Download started'),
+ ),
+ );
+ Future.delayed(const Duration(seconds: 2), () {
+ ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
+ });
+ }
+
+ Future _downloadUrl(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ String targetUrl,
+ ) async {
+ final customPost = post.copyWith(fileUrl: targetUrl, sampleUrl: targetUrl);
+ await _download(context, ref, customPost);
+ }
+
+ Future _sharePost(BuildContext context, WidgetRef ref, Post post) async {
+ final strings = ref.read(appStringsProvider);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ strings.ru
+ ? 'Подготовка файла к отправке...'
+ : 'Preparing file for sharing...',
+ ),
+ duration: const Duration(seconds: 1),
+ ),
+ );
+ try {
+ final path =
+ await ref.read(downloadServiceProvider).prepareFileForShare(post);
+ await Share.shareXFiles([XFile(path)], text: post.source ?? post.fileUrl);
+ } catch (e) {
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Ошибка отправки: $e')),
+ );
+ }
+ }
+
+ Future _showMobileQuickActions(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ Set favoriteKeys,
+ ) async {
+ await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (sheetContext) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: Icon(
+ favoriteKeys.contains(post.cacheKey)
+ ? Icons.favorite_rounded
+ : Icons.favorite_border_rounded,
+ ),
+ title: Text(
+ favoriteKeys.contains(post.cacheKey)
+ ? 'Remove favorite'
+ : 'Favorite',
+ ),
+ onTap: () async {
+ Navigator.pop(sheetContext);
+ await _toggleFavorite(ref, post, favoriteKeys);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.collections_bookmark_rounded),
+ title: const Text('Add to collection'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _addToCollection(context, ref, post);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.copy_rounded),
+ title: const Text('Copy link'),
+ onTap: () {
+ Clipboard.setData(ClipboardData(text: post.fileUrl));
+ Navigator.pop(sheetContext);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.download_rounded),
+ title: const Text('Download'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _download(context, ref, post);
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Future _toggleFavorite(
+ WidgetRef ref,
+ Post post,
+ Set favoriteKeys,
+ ) async {
+ if (favoriteKeys.contains(post.cacheKey)) {
+ await ref
+ .read(favoriteServiceProvider)
+ .removeFavorite(post.id, post.providerId);
+ } else {
+ await ref.read(favoriteServiceProvider).addFavorite(post);
+ await _maybeAutoDownloadFavorite(ref, post);
+ }
+ ref.invalidate(favoriteKeysProvider);
+ ref.invalidate(favoritesControllerProvider);
+ }
+
+ Future _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
+ final settings =
+ ref.read(appSettingsProvider).value ?? AppSettings.defaults;
+ if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
+ await ref.read(downloadManagerServiceProvider).start(post);
+ }
+
+ Future _deleteLocalFile(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ ) async {
+ final strings = ref.read(appStringsProvider);
+ await ref.read(downloadedMediaServiceProvider).deleteLocalFile(
+ post.cacheKey,
+ );
+ ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(strings.deleteLocalFile)),
+ );
+ }
+
+ PostActionLabels _postActionLabels(AppStrings strings) {
+ return PostActionLabels(
+ favorite: strings.favorite,
+ unfavorite: strings.removeFavorite,
+ collection: strings.collection,
+ similar: strings.similar,
+ openOriginal: strings.open,
+ copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
+ download: strings.download,
+ deleteLocalFile: strings.deleteLocalFile,
+ hidePost: strings.hidePost,
+ share: strings.ru ? 'Поделиться' : 'Share',
+ );
+ }
+
+ Future _hidePost(
+ BuildContext context,
+ WidgetRef ref,
+ Post post,
+ ) async {
+ await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey);
+ ref.invalidate(appSettingsProvider);
+ ref.read(feedControllerProvider.notifier).refresh();
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Post hidden locally')),
+ );
+ _close(context);
+ }
+
+ void _openPost(BuildContext context, Post post) {
+ _replacePost(context, post);
+ }
+
+ void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
+ context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
+ }
+
+ Future _openSourcePage(WidgetRef ref, Post post) async {
+ final source = post.source?.trim();
+ String? url = source != null && source.isNotEmpty ? source : null;
+ if (url == null) {
+ final result =
+ await ref.read(providerManagerProvider).getPostPageUrl(post);
+ if (result is Success) url = result.data;
+ }
+ if (url == null || url.isEmpty) return;
+ await launchUrl(Uri.parse(url));
+ }
+
+ void _replacePost(BuildContext context, Post post) {
+ context.replace('/post/${post.providerId}/${post.id}', extra: post);
+ }
+
+ void _close(BuildContext context) {
+ if (Navigator.of(context).canPop()) {
+ context.pop();
+ return;
+ }
+ context.go('/');
+ }
+
+ Future _saveVideoPreferences(
+ WidgetRef ref,
+ VideoPlaybackSnapshot snapshot,
+ ) async {
+ final result = await ref.read(settingsServiceProvider).getSettings();
+ if (result is! Success) return;
+ final settings = result.data;
+ if (settings.videoPlayerMuted == snapshot.muted &&
+ settings.videoPlayerHalfVolume == snapshot.halfVolume &&
+ settings.videoPlayerLoop == snapshot.loopVideo &&
+ settings.videoPlayerCover == snapshot.coverVideo &&
+ (settings.videoPlayerVolume - snapshot.volume).abs() < 0.01) {
+ return;
+ }
+ await ref.read(settingsServiceProvider).updateSettings(
+ settings.copyWith(
+ videoPlayerMuted: snapshot.muted,
+ videoPlayerHalfVolume: snapshot.halfVolume,
+ videoPlayerLoop: snapshot.loopVideo,
+ videoPlayerCover: snapshot.coverVideo,
+ videoPlayerVolume: snapshot.volume,
+ ),
+ );
+ ref.invalidate(appSettingsProvider);
+ }
+
+ Future _saveVideoSnapshot(
+ WidgetRef ref,
+ Post post,
+ VideoPlaybackSnapshot snapshot,
+ ) async {
+ await ref.read(settingsServiceProvider).saveVideoPlaybackPosition(
+ post.cacheKey,
+ snapshot.position.inMilliseconds,
+ );
+ ref.invalidate(appSettingsProvider);
+ }
+}
+
+class _MobilePostPager extends StatefulWidget {
+ const _MobilePostPager({
+ required this.posts,
+ required this.initialIndex,
+ required this.buildDetails,
+ });
+
+ final List posts;
+ final int initialIndex;
+ final Widget Function(
+ BuildContext context,
+ Post post,
+ bool mediaGestureLocked,
+ ValueChanged onMediaGestureLockChanged,
+ ) buildDetails;
+
+ @override
+ State<_MobilePostPager> createState() => _MobilePostPagerState();
+}
+
+class _MobilePostPagerState extends State<_MobilePostPager> {
+ late final PageController _controller;
+ bool _mediaGestureLocked = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = PageController(initialPage: widget.initialIndex);
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _prefetchAround(widget.initialIndex);
+ });
+ }
+
+ @override
+ void didUpdateWidget(covariant _MobilePostPager oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ // Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
+ if (oldWidget.initialIndex != widget.initialIndex &&
+ widget.initialIndex >= 0 &&
+ widget.initialIndex < widget.posts.length) {
+ if (_controller.hasClients) {
+ _controller.jumpToPage(widget.initialIndex);
+ }
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _prefetchAround(widget.initialIndex);
+ });
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ScrollConfiguration(
+ behavior: ScrollConfiguration.of(context).copyWith(
+ dragDevices: {
+ PointerDeviceKind.touch,
+ PointerDeviceKind.mouse,
+ PointerDeviceKind.trackpad,
+ PointerDeviceKind.stylus,
+ },
+ ),
+ child: PageView.builder(
+ controller: _controller,
+ physics: _mediaGestureLocked
+ ? const NeverScrollableScrollPhysics()
+ : const PageScrollPhysics(),
+ itemCount: widget.posts.length,
+ onPageChanged: (index) {
+ _prefetchAround(index);
+ },
+ itemBuilder: (context, index) {
+ final initialPost = widget.posts[index];
+ return Consumer(
+ builder: (context, ref, _) {
+ final args = PostDetailsArgs(
+ providerId: initialPost.providerId,
+ postId: initialPost.id,
+ initialPost: initialPost,
+ );
+ final post = ref.watch(postDetailsControllerProvider(args));
+ return post.when(
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) => ErrorView(message: error.toString()),
+ data: (resolvedPost) => _KeepAlivePostPage(
+ child: KeyedSubtree(
+ key: ValueKey((resolvedPost ?? initialPost).cacheKey),
+ child: widget.buildDetails(
+ context,
+ resolvedPost ?? initialPost,
+ _mediaGestureLocked,
+ _setMediaGestureLocked,
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ },
+ ),
+ );
+}
+
+ void _setMediaGestureLocked(bool locked) {
+ if (_mediaGestureLocked == locked) return;
+ setState(() => _mediaGestureLocked = locked);
+ }
+
+ void _prefetchAround(int index) {
+ for (final offset in [-2, -1, 0, 1, 2]) {
+ final target = index + offset;
+ if (target < 0 || target >= widget.posts.length) continue;
+ final post = widget.posts[target];
+ final urls = [
+ post.previewUrl,
+ post.sampleUrl,
+ if (post.fileType.toLowerCase().contains('gif')) post.fileUrl,
+ ].where((url) => url.trim().isNotEmpty).toSet();
+ for (final url in urls) {
+ precacheImage(
+ CachedNetworkImageProvider(url, headers: _headersFor(post)),
+ context,
+ );
+ }
+ }
+ }
+
+ Map _headersFor(Post post) {
+ return const {
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
+ 'Accept': '*/*',
+ };
+ }
+}
+
+class _KeepAlivePostPage extends StatefulWidget {
+ const _KeepAlivePostPage({required this.child});
+
+ final Widget child;
+
+ @override
+ State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState();
+}
+
+class _KeepAlivePostPageState extends State<_KeepAlivePostPage>
+ with AutomaticKeepAliveClientMixin {
+ @override
+ bool get wantKeepAlive => true;
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ return widget.child;
+ }
+}
+
+class _NeighborStrip extends StatelessWidget {
+ const _NeighborStrip({
+ required this.posts,
+ required this.currentIndex,
+ required this.onOpen,
+ });
+
+ final List posts;
+ final int currentIndex;
+ final ValueChanged onOpen;
+
+ @override
+ Widget build(BuildContext context) {
+ final start = (currentIndex - 3).clamp(0, posts.length);
+ final end = (currentIndex + 4).clamp(0, posts.length);
+ final visible = posts.sublist(start, end);
+ return SizedBox(
+ height: 72,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: visible.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
+ itemBuilder: (context, index) {
+ final post = visible[index];
+ final selected = post.cacheKey == posts[currentIndex].cacheKey;
+ return InkWell(
+ onTap: () => onOpen(post),
+ borderRadius: BorderRadius.circular(8),
+ child: AnimatedContainer(
+ duration: AppMotion.duration(context, 140),
+ width: 72,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: selected
+ ? Theme.of(context).colorScheme.primary
+ : Colors.transparent,
+ width: 2,
+ ),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: CachedNetworkImage(
+ imageUrl: post.previewUrl.isNotEmpty
+ ? post.previewUrl
+ : post.sampleUrl,
+ fit: BoxFit.cover,
+ errorWidget: (_, __, ___) =>
+ const Center(child: Icon(Icons.broken_image_rounded)),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _PreviousPostIntent extends Intent {
+ const _PreviousPostIntent();
+}
+
+class _NextPostIntent extends Intent {
+ const _NextPostIntent();
+}
+
+class _ToggleFavoriteIntent extends Intent {
+ const _ToggleFavoriteIntent();
+}
+
+class _AddCollectionIntent extends Intent {
+ const _AddCollectionIntent();
+}
+
+class _CloseIntent extends Intent {
+ const _CloseIntent();
+}
+
+class _DownloadIntent extends Intent {
+ const _DownloadIntent();
+}
+
+class _CommentsSection extends ConsumerStatefulWidget {
+ const _CommentsSection({required this.post});
+
+ final Post post;
+
+ @override
+ ConsumerState<_CommentsSection> createState() => _CommentsSectionState();
+}
+
+class _CommentsSectionState extends ConsumerState<_CommentsSection> {
+ bool _expanded = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final strings = ref.watch(appStringsProvider);
+ final comments = _expanded
+ ? ref.watch(
+ postCommentsProvider(
+ PostDetailsArgs(
+ providerId: widget.post.providerId,
+ postId: widget.post.id,
+ ),
+ ),
+ )
+ : null;
+ final scheme = Theme.of(context).colorScheme;
+
+ return Container(
+ decoration: BoxDecoration(
+ color: scheme.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(
+ color: scheme.outlineVariant.withValues(alpha: 0.35),
+ ),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: ExpansionTile(
+ tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
+ childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
+ initiallyExpanded: false,
+ shape: const Border(),
+ collapsedShape: const Border(),
+ leading: Icon(
+ Icons.chat_bubble_outline_rounded,
+ color: scheme.primary,
+ ),
+ onExpansionChanged: (value) => setState(() => _expanded = value),
+ title: Text(
+ strings.comments,
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ children: [
+ (comments ?? const AsyncValue>.data([])).when(
+ loading: () => const Padding(
+ padding: EdgeInsets.all(16),
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ error: (_, __) => Padding(
+ padding: const EdgeInsets.all(12),
+ child: Text(
+ strings.commentsUnavailable,
+ style: TextStyle(color: scheme.outline),
+ ),
+ ),
+ data: (items) {
+ if (items.isEmpty) {
+ return Padding(
+ padding: const EdgeInsets.all(12),
+ child: Text(
+ strings.noComments,
+ style: TextStyle(color: scheme.onSurfaceVariant),
+ ),
+ );
+ }
+ return Column(
+ children: [
+ for (final comment in items)
+ Container(
+ margin: const EdgeInsets.only(bottom: 8),
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: scheme.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ CircleAvatar(
+ radius: 14,
+ backgroundColor: scheme.primaryContainer,
+ child: Text(
+ (comment.authorName.isNotEmpty
+ ? comment.authorName[0]
+ : '?')
+ .toUpperCase(),
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ color: scheme.onPrimaryContainer,
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ comment.authorName.isEmpty
+ ? strings.anonymous
+ : comment.authorName,
+ style: Theme.of(context)
+ .textTheme
+ .labelMedium
+ ?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ comment.body,
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _PostInfoCard extends StatefulWidget {
+ 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
+ State<_PostInfoCard> createState() => _PostInfoCardState();
+}
+
+class _PostInfoCardState extends State<_PostInfoCard> {
+ // Lazily resolved dimensions (used when post.width/height == 0).
+ int? _resolvedWidth;
+ int? _resolvedHeight;
+ bool _resolvingDimensions = false;
+
+ static final _dimensionCache = {};
+
+ static bool _isImageType(String fileType) {
+ const imageTypes = {'image', 'jpeg', 'jpg', 'png', 'gif', 'webp', 'avif'};
+ return imageTypes.contains(fileType.toLowerCase());
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _maybeResolveDimensions();
+ }
+
+ @override
+ void didUpdateWidget(_PostInfoCard old) {
+ super.didUpdateWidget(old);
+ if (old.post.fileUrl != widget.post.fileUrl) {
+ _resolvedWidth = null;
+ _resolvedHeight = null;
+ _resolvingDimensions = false;
+ _maybeResolveDimensions();
+ }
+ }
+
+ void _maybeResolveDimensions() {
+ final post = widget.post;
+ // Only attempt lazy resolution when dimensions are unknown (0) and it's
+ // an image (not video/link which we can't resolve this way).
+ if (post.width != 0 || post.height != 0) return;
+ if (!_isImageType(post.fileType)) return;
+ final url = post.fileUrl.isNotEmpty ? post.fileUrl : post.sampleUrl;
+ if (url.isEmpty) return;
+
+ // Check cache first.
+ if (_dimensionCache.containsKey(url)) {
+ final cached = _dimensionCache[url]!;
+ _resolvedWidth = cached.$1;
+ _resolvedHeight = cached.$2;
+ return;
+ }
+
+ if (_resolvingDimensions) return;
+ _resolvingDimensions = true;
+
+ final imageProvider = NetworkImage(url);
+ final completer = imageProvider.resolve(ImageConfiguration.empty);
+ completer.addListener(
+ ImageStreamListener(
+ (info, _) {
+ final w = info.image.width;
+ final h = info.image.height;
+ _dimensionCache[url] = (w, h);
+ if (mounted) {
+ setState(() {
+ _resolvedWidth = w;
+ _resolvedHeight = h;
+ _resolvingDimensions = false;
+ });
+ }
+ },
+ onError: (_, __) {
+ if (mounted) setState(() => _resolvingDimensions = false);
+ },
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final post = widget.post;
+ final strings = widget.strings;
+ final localMedia = widget.localMedia;
+ final fileSizeBytes = widget.fileSizeBytes;
+
+ 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();
+
+ // Determine which dimensions to display.
+ final displayWidth = post.width != 0 ? post.width : _resolvedWidth;
+ final displayHeight = post.height != 0 ? post.height : _resolvedHeight;
+ final hasDimensions = displayWidth != null && displayHeight != null;
+ final dimensionLabel = hasDimensions
+ ? '$displayWidth × $displayHeight'
+ : (_resolvingDimensions ? '… × …' : null);
+
+ 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(
+ _formatPostDate(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: [
+ if (dimensionLabel != null)
+ _SpecBadge(
+ icon: Icons.aspect_ratio_rounded,
+ label: dimensionLabel,
+ ),
+ 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,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+String _formatPostDate(DateTime date) {
+ return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
+}
+
+class _SpecBadge extends StatelessWidget {
+ const _SpecBadge({
+ required this.icon,
+ required this.label,
+ this.iconColor,
+ this.color,
+ });
+
+ final IconData icon;
+ final String label;
+ final Color? iconColor;
+ final Color? color;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
+ decoration: BoxDecoration(
+ color: color ?? scheme.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: scheme.outlineVariant.withValues(alpha: 0.25),
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 14, color: iconColor ?? scheme.onSurfaceVariant),
+ const SizedBox(width: 5),
+ Text(
+ label,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: scheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart
index d819664..d0a1d77 100644
--- a/lib/features/post/presentation/widgets/post_media_viewer.dart
+++ b/lib/features/post/presentation/widgets/post_media_viewer.dart
@@ -546,7 +546,7 @@ class _PostMediaViewerState extends State
}
return {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (defaultReferer != null) 'Referer': defaultReferer,
...widget.mediaHeaders,
diff --git a/lib/features/providers/presentation/provider_form_screen.dart b/lib/features/providers/presentation/provider_form_screen.dart
index 1e44889..028943a 100644
--- a/lib/features/providers/presentation/provider_form_screen.dart
+++ b/lib/features/providers/presentation/provider_form_screen.dart
@@ -494,7 +494,7 @@ class _ProviderFormScreenState extends ConsumerState {
child: _GlassTextField(
controller: _headers,
label: isRu ? 'Пользовательские заголовки' : 'Headers JSON',
- hint: '{"User-Agent": "Lunaris/3.0"}',
+ hint: '{"User-Agent": "Prisma/3.0"}',
icon: Icons.code_rounded,
minLines: 2,
maxLines: 5,
diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart
index 9b88500..f8429d4 100644
--- a/lib/features/settings/presentation/settings_screen.dart
+++ b/lib/features/settings/presentation/settings_screen.dart
@@ -403,7 +403,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
? 'Beta и экспериментальные обновления'
: 'Receive beta & experimental updates',
subtitle: isRu
- ? 'Проверка и уведомление о prerelease сборках Lunaris'
+ ? 'Проверка и уведомление о prerelease сборках Prisma'
: 'Include prerelease builds when checking for updates',
value: settings.allowExperimentalUpdates,
onChanged: (val) => _update(
@@ -1278,7 +1278,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
messenger.showSnackBar(
SnackBar(
content: Text(
- isRu ? 'У вас установлена последняя версия Lunaris' : 'Lunaris is up to date',
+ isRu ? 'У вас установлена последняя версия Prisma' : 'Prisma is up to date',
),
),
);
@@ -1295,7 +1295,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
await showDialog(
context: context,
builder: (context) => AlertDialog(
- title: Text('Lunaris ${info.version} is available'),
+ title: Text('Prisma ${info.version} is available'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: SingleChildScrollView(
@@ -1356,7 +1356,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
return showDialog(
context: context,
builder: (context) => AlertDialog(
- title: const Text('Lunaris changelog'),
+ title: const Text('Prisma changelog'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SingleChildScrollView(
@@ -1364,7 +1364,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
- for (final change in lunarisChangelog) ...[
+ for (final change in prismaChangelog) ...[
Text(
'${change.version} - ${change.title}',
style: Theme.of(context).textTheme.titleMedium,
@@ -2341,7 +2341,7 @@ class _ColorSwatches extends StatelessWidget {
final ValueChanged onChanged;
static const colors = <(int, String)>[
- (0xFFE84D8A, 'Lunaris pink'),
+ (0xFFE84D8A, 'Prisma pink'),
(0xFF8B5CF6, 'Violet'),
(0xFF3B82F6, 'Azure'),
(0xFF0EA5E9, 'Sky'),
@@ -2565,7 +2565,7 @@ class _HeroBrandBanner extends StatelessWidget {
),
const SizedBox(height: 14),
Text(
- 'Lunaris',
+ 'Prisma',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
@@ -2619,7 +2619,7 @@ class _HeroBrandBanner extends StatelessWidget {
OutlinedButton.icon(
onPressed: () async {
final url = Uri.parse(
- 'https://github.com/RarDog/Lunaris',
+ 'https://github.com/RarDog/Prisma',
);
await launchUrl(url, mode: LaunchMode.externalApplication);
},
diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart
index bb95076..016ebb2 100644
--- a/lib/shared/widgets/post_card.dart
+++ b/lib/shared/widgets/post_card.dart
@@ -478,7 +478,7 @@ class _PostCardState extends ConsumerState
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
.toLowerCase();
return {
- 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
+ 'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
if (lower.contains('realbooru'))
diff --git a/pubspec.yaml b/pubspec.yaml
index a1099c9..e86c1c1 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 3.4.0+74
+version: 3.4.1+75
environment:
sdk: ">=3.3.0 <4.0.0"
diff --git a/test/backend/tag_cache_service_test.dart b/test/backend/tag_cache_service_test.dart
index 28c4358..e785909 100644
--- a/test/backend/tag_cache_service_test.dart
+++ b/test/backend/tag_cache_service_test.dart
@@ -10,7 +10,7 @@ void main() {
late TagCacheService cacheService;
setUp(() async {
- tempDir = await Directory.systemTemp.createTemp('lunaris_test_tag_cache');
+ tempDir = await Directory.systemTemp.createTemp('prisma_test_tag_cache');
cacheFilePath = '${tempDir.path}/test_tags.json';
cacheService = TagCacheService(customPath: cacheFilePath);
await cacheService.init();
diff --git a/test/backend/update_service_test.dart b/test/backend/update_service_test.dart
index 164e0a9..c5cdcd7 100644
--- a/test/backend/update_service_test.dart
+++ b/test/backend/update_service_test.dart
@@ -13,24 +13,24 @@ void main() {
final info = AppUpdateInfo(
version: '2.0.2',
tagName: 'v2.0.2',
- name: 'Lunaris 2.0.2',
+ name: 'Prisma 2.0.2',
body: 'Release notes',
- htmlUrl: 'https://github.com/RarDog/Lunaris/releases/tag/v2.0.2',
+ htmlUrl: 'https://github.com/RarDog/Prisma/releases/tag/v2.0.2',
publishedAt: DateTime.parse('2026-09-03T18:00:00Z'),
- apkUrl: 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/Lunaris.apk',
- linuxTarGzUrl: 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/linux.tar.gz',
+ apkUrl: 'https://github.com/RarDog/Prisma/releases/download/v2.0.2/Prisma.apk',
+ linuxTarGzUrl: 'https://github.com/RarDog/Prisma/releases/download/v2.0.2/linux.tar.gz',
);
expect(info.hasAssets, isTrue);
expect(info.apkUrl, contains('apk'));
expect(info.linuxTarGzUrl, contains('linux'));
expect(
- service.assetFileName(info, 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/Lunaris-v2.0.2.apk'),
- 'Lunaris-v2.0.2.apk',
+ service.assetFileName(info, 'https://github.com/RarDog/Prisma/releases/download/v2.0.2/Prisma-v2.0.2.apk'),
+ 'Prisma-v2.0.2.apk',
);
expect(
- service.assetFileName(info, 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/unknown-linux'),
- 'Lunaris-v2.0.2-linux-x64.tar.gz',
+ service.assetFileName(info, 'https://github.com/RarDog/Prisma/releases/download/v2.0.2/unknown-linux'),
+ 'Prisma-v2.0.2-linux-x64.tar.gz',
);
});
}