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 @@

- Version 3.4.0 + Version 3.4.1 Flutter Platforms Wiki 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', ); }); }