Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402a5436bc | ||
|
|
80ca59d19f | ||
|
|
848fee6ff1 | ||
|
|
d4e52ff429 | ||
|
|
10b02b2d6f | ||
|
|
fe4777c4b3 | ||
|
|
ce78a1f47f | ||
|
|
576bd53487 | ||
|
|
87e126ec4d | ||
|
|
639bfc50af | ||
|
|
f603c37c91 | ||
|
|
c6a9dcb4d5 | ||
|
|
b33248ef9d | ||
|
|
1b08194666 | ||
|
|
37f2dbf47d | ||
|
|
4bbe04dfd8 | ||
|
|
e8448eb748 | ||
|
|
7e384b1be7 |
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.9-blueviolet?style=for-the-badge" alt="Version 2.0.9" />
|
||||
<img src="https://img.shields.io/badge/Version-2.3.1-blueviolet?style=for-the-badge" alt="Version 2.3.1" />
|
||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||
@@ -20,6 +20,14 @@
|
||||
|
||||
## ✨ Основные возможности
|
||||
|
||||
- **💾 Офлайн 2.0 и Sync Hub**: 100% честное воспроизведение видео и просмотр фото прямо с диска без единого запроса в сеть, карточка аналитики хранилища (`💾 X.X ГБ на устройстве`), кнопка пакетного скачивания недостающих в 1 тап и удобная сортировка по дате, размеру и авторам.
|
||||
- **🖼️ Экран поста 2.0**: Action Dock 2.0 (чистый док из 5 главных действий + модальный лист вторичных функций), современная карточка инфо с автором, провайдером, разрешением, форматом и оценкой, лента соседних постов на мобильных и ПК, стильные блоки тегов и комментарии в виде чат-бабблов.
|
||||
- **🔍 Поиск 2.0**: Hero-панель поиска, быстрые чипы операторов (`and`, `type:video`, `rating:safe`), облако недавних поисков с быстрым удалением крестиком `✕`, карточки популярных категорий и шпаргалка по синтаксису запросов.
|
||||
- **❤️ Избранное 2.0**: Живые счетчики в сегментах (Все, Офлайн, Авторы), быстрый фильтр по типу медиа (Все / Видео / Фото), мгновенный поиск среди сохраненных работ и карточки авторов в стиле Spotify с мини-коллажем из 3 превью.
|
||||
- **🕒 История 2.0**: Группировка по дням (Сегодня, Вчера, даты), переключение между подробным списком (с 68x68 миниатюрами, бэйджами провайдеров и быстрым удалением из истории) и галерейной Masonry-сеткой, а также диалог подтверждения очистки.
|
||||
- **📁 Коллекции 2.0**: Мозаичные обложки 2x2 с превью постов, счетчики работ со стеклянным бейджем, современный экран деталей коллекции и обновленный диалог создания.
|
||||
- **⚙️ Settings 2.0**: Полный редизайн настроек с современными squircle-карточками, горизонтальной быстрой навигацией по разделам, мгновенными Segmented-кнопками выбора темы/языка/качества и стильной карточкой о приложении.
|
||||
|
||||
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
|
||||
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
|
||||
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
|
||||
@@ -69,9 +77,9 @@
|
||||
|
||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||
|
||||
- **Android**: `Lunaris-v2.0.9.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip`
|
||||
- **Android**: `Lunaris-v2.0.10.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.zip`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ class MainActivity : FlutterActivity() {
|
||||
setPreferredRefreshRate(hz.toFloat())
|
||||
result.success(null)
|
||||
}
|
||||
"getSupportedAbis" -> result.success(Build.SUPPORTED_ABIS.toList())
|
||||
"getPrimaryAbi" -> result.success(Build.SUPPORTED_ABIS.firstOrNull() ?: "armeabi-v7a")
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
||||
|
||||
Future<void> _downloadUpdate(AppUpdateInfo info) async {
|
||||
final updateService = ref.read(updateServiceProvider);
|
||||
final assetUrl = updateService.assetUrlForCurrentPlatform(info);
|
||||
final assetUrl = await updateService.assetUrlForCurrentPlatform(info);
|
||||
if (assetUrl == null) {
|
||||
final url = Uri.tryParse(info.htmlUrl);
|
||||
if (url != null) {
|
||||
|
||||
@@ -36,6 +36,7 @@ class AppStrings {
|
||||
String get downloading => ru ? 'Скачивается' : 'Downloading';
|
||||
String get failed => ru ? 'Ошибка' : 'Failed';
|
||||
String get open => ru ? 'Открыть' : 'Open';
|
||||
String get copyLink => ru ? 'Копировать ссылку' : 'Copy link';
|
||||
String get hidePost => ru ? 'Скрыть пост' : 'Hide post';
|
||||
String get hiddenLocally =>
|
||||
ru ? 'Пост скрыт локально' : 'Post hidden locally';
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.9';
|
||||
const appBuildNumber = 31;
|
||||
const appDisplayVersion = '2.3.1';
|
||||
const appBuildNumber = 41;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,195 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.3.9',
|
||||
title: 'Исправление входа и синхронизации аккаунтов Pawchive',
|
||||
bullets: [
|
||||
'Исправлены API эндпоинты Pawchive: пути запросов избранных авторов и управления подписками приведены в соответствие со спецификацией API v1 (/api/v1/account/favorites, /api/v1/favorites/creator/...).',
|
||||
'Устранена ошибка 404: авторизация по логину и паролю, а также по session cookie теперь успешно завершается и сразу синхронизирует всех авторов.',
|
||||
'Информативные ошибки: понятные сообщения на русском при неверном пароле или сбое сети вместо сырых исключений Dio.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.3.8',
|
||||
title: 'ABI-Aware Auto-Update — правильный APK для вашего процессора',
|
||||
bullets: [
|
||||
'Определение архитектуры Android: приложение читает Build.SUPPORTED_ABIS (arm64-v8a, armeabi-v7a, x86_64) через нативный канал.',
|
||||
'Точный APK из Gitea: при обновлении скачивается per-ABI APK (app-arm64-v8a-release.apk и т.д.), а не один общий файл.',
|
||||
'Fallback: нет per-ABI APK → универсальный APK → страница релиза в браузере.',
|
||||
'Кеш ABI: архитектура определяется один раз за сеанс.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.3.7',
|
||||
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
|
||||
bullets: [
|
||||
'Auto-Resolve Image Dimensions: when a provider (such as Pawchive) does not supply width/height metadata, the app now lazily resolves the real pixel dimensions by decoding the image in the background and displays them as soon as they are ready.',
|
||||
'No More "0 × 0": the resolution badge now shows "… × …" while dimensions are being fetched, and the correct resolution (e.g. 1920 × 1080) once decoded. The badge is hidden entirely for video/link posts where dimensions cannot be resolved.',
|
||||
'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(
|
||||
version: '2.3.6',
|
||||
title: 'Pawchive Accounts Integration & Favorites Synchronization',
|
||||
bullets: [
|
||||
'Pawchive Account Support: users can now log into Pawchive with their username and password or paste a direct session cookie.',
|
||||
'Multi-Account Synchronization: add and manage multiple Pawchive accounts; synchronize favorite artists from all connected accounts into a consolidated local favorites list with 1 tap.',
|
||||
'Real-Time Remote Favorites Sync: favoriting or unfavoriting any Pawchive artist in the app automatically pushes the change to the active Pawchive account in real time.',
|
||||
'Artist Posts Direct Favorite: added a star toggle directly in the artist posts screen top bar to add or remove creators from favorites without leaving their gallery.',
|
||||
'Dedicated UI & Quick Access: manage connected accounts, view sync stats, and trigger synchronization from the Artists screen, Favorites modal, and Settings screen.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.3.5',
|
||||
title: 'Fullscreen Video Auto-Rotation & Orientation Controls',
|
||||
bullets: [
|
||||
'Smart Fullscreen Auto-Rotation: entering fullscreen mode on horizontal/landscape videos automatically snaps the device into landscape orientation for maximum viewing area, while vertical/portrait videos stay in portrait mode.',
|
||||
'Manual Orientation Toggle: added a quick-access rotation button (Icons.screen_lock_portrait / landscape) directly in the fullscreen top bar next to the close button, allowing 1-tap switching between landscape and portrait.',
|
||||
'Clean Orientation Recovery: exiting fullscreen seamlessly restores system navigation bars (Edge-to-Edge) and restores unrestricted device sensor orientations.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.3.4',
|
||||
title: 'OverlayPortal Architecture & Search UI Unblocking',
|
||||
bullets: [
|
||||
'OverlayPortal Migration: replaced manual root OverlayEntry and translucent backdrop with Flutter native OverlayPortal and TapRegion. Completely eliminates ghost overlay leaks and touch interception.',
|
||||
'Unblocked Toolbar & Search Bar GUI: buttons (Refresh / Обновить, Clear Filters / Сбросить, random post, filter chips) and search chip crosses now respond immediately to taps without being swallowed by overlay backdrops.',
|
||||
'Instant Chip Removal: deleting a tag chip (via its X cross icon) now updates the search immediately in real time, synchronizing the feed without restoring deleted tags on rebuild.',
|
||||
'Search Clear Synchronization: tapping the search bar clear button (X) resets the query and clears active filters immediately.',
|
||||
'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(
|
||||
version: '2.3.3',
|
||||
title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment',
|
||||
bullets: [
|
||||
'Tag Suggestions Hanging Fix: eliminated the bug where the tag suggestion card remained stuck on screen after clearing text or tapping outside. Added immediate dismissal on empty input, outside-tap backdrop dismissal, controller listener, and unfocus on submission.',
|
||||
'Rule34 Dedicated Autocomplete: connected Rule34 directly to its official fast /autocomplete.php?q= endpoint, displaying real-time tag suggestions with accurate post counts (e.g. braid (216,984)).',
|
||||
'Rule34 Tag Types (fields=tag_info): added tag_info field to post query for accurate tag categories per post.',
|
||||
'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(
|
||||
version: '2.3.2',
|
||||
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
|
||||
bullets: [
|
||||
'Official Gelbooru DAPI Alignment: fixed tag suggestions pattern to use SQL LIKE wildcard (%) instead of literal asterisk (*), restoring accurate tag autocompletion.',
|
||||
'Popularity Tag Sorting: added orderby=count and order=DESC to Gelbooru tag search so the most relevant and widely used tags appear first.',
|
||||
'Batch Tag Categorization: migrated post tag metadata queries to the official "names" parameter, resolving categories for up to 80 tags in a single batch request instead of dozens of sequential requests.',
|
||||
'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(
|
||||
version: '2.3.1',
|
||||
title: 'Cloud Mirrors & Author Announcements Fixes',
|
||||
bullets: [
|
||||
'Collapsible Cloud Mirrors: the cloud drives section is now folded by default into a sleek header card matching the Tags and Comments cards, expandable on tap.',
|
||||
'Tag Panel Sanitation: filtered out cloud drive links, JSON metadata, and URLs from appearing in the post tags panel and tag counters.',
|
||||
'Author Announcements HTML Cleaning: stripped all raw <p style="">, <strong>, and formatting artifacts from artist announcements, rendering clean readable text and clickable service link chips.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.3.0',
|
||||
title: 'Artist Cloud Drives & External Video Mirrors',
|
||||
bullets: [
|
||||
'Cloud Mirrors & Drives: automatic detection and parsing of external links to Google Drive, MEGA, Dropbox, Pixeldrain, Catbox, MediaFire, GoFile, Bunkr, and direct video files from post descriptions, HTML, and embed data.',
|
||||
'Direct Video Playback & Download: resolves direct streaming URLs for Dropbox (?raw=1), Google Drive, Pixeldrain API, and Catbox with 1-tap playback and direct downloads.',
|
||||
'Cloud Mirrors Card: styled cards with service branding colors (MEGA Red, Google Drive Blue, Dropbox Blue, Pixeldrain Cyan) showing link type, direct stream button, external open, and copy link.',
|
||||
'Link-Only Posts Support: artist posts that only contain cloud drive links without direct server files are now displayed with an elegant CloudMediaHero banner instead of an empty view.',
|
||||
'Archive Password Detection: automatically extracts and displays archive passwords (Pass: ..., Password: ...) with 1-tap copy button.',
|
||||
'Artist Commentary Card: formatted display of author notes, instructions, and text below post details.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.2.0',
|
||||
title: 'Offline Mode Overhaul & Post Details Screen Redesign',
|
||||
bullets: [
|
||||
'100% Genuine Offline Playback: local images and videos play directly from disk storage via media_kit and cached files without initiating network requests.',
|
||||
'Offline Storage & Sync Hub: live storage size indicator (💾 X.X MB/GB on device), 1-tap "Download all missing" batch sync button, real-time downloading task progress bar, and sorting (by date, size, artist).',
|
||||
'Action Dock 2.0: redesigned post action bar with primary dock buttons (Like, Download with status/progress, Collection, Share) and clean "More" modal bottom sheet for secondary actions.',
|
||||
'Modern Post Info Card: author row with palette icon and clickable search, booru provider badge with relative timestamp, spec capsules (resolution, file format, safety rating badge, score, offline size).',
|
||||
'Unified Neighbor Strip & Tags Card: post neighbor thumbnail strip now available on both mobile and desktop, alongside modern rounded tag blocks and chat-bubble comments.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.1.0',
|
||||
title: '4-Tab Major Redesign: Search, Favorites, History & Collections',
|
||||
bullets: [
|
||||
'Search Tab Redesign: hero search container, quick search operator chips (and, type:video, rating:safe, etc.), interactive recent search cloud with 1-tap delete (x), popular exploratory category cards, and search tips cheat sheet.',
|
||||
'Favorites Tab Redesign: live counters on segment tabs (All, Offline, Artists), quick media filter chips (All, Videos, Images), in-favorites search bar, and Spotify/Apple Music-style artist album cards with 3-thumbnail preview collages.',
|
||||
'History Tab Redesign: timeline grouped by time (Today, Yesterday, Date), view mode switcher (rich list with 68x68 rounded thumbnails, provider chips, timestamps, and delete from history vs full visual Masonry gallery grid), and confirmation dialog before clearing.',
|
||||
'Collections Tab Redesign: Pinterest-style collection cards with 2x2 thumbnail mosaic covers and glassmorphism post counters, modernized collection details screen with description banner, and elegant squircle creation dialog.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.15',
|
||||
title: 'Settings 2.0 Layout Fixes & Pinned Quick Navigation',
|
||||
bullets: [
|
||||
'Fixed vertical text wrapping on mobile: separated title and full-width SegmentedButtons for Theme, Language, and Media Quality so text never collapses.',
|
||||
'Pinned Category Quick Navigation: bar now stays at the top of the screen and reliably scrolls to all 7 sections.',
|
||||
'SingleChildScrollView Migration: ensures all section keys remain mounted and scrollable on demand.',
|
||||
'Full-width Folder Structure Tile: added clean monospace path card with dedicated change button.',
|
||||
'Header text overflow protections: wrapped title texts in Expanded across all settings cards.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.14',
|
||||
title: 'Settings 2.0: Modern Grouped Design & Quick Ergonomics',
|
||||
bullets: [
|
||||
'Settings 2.0 Layout: reimagined settings experience with modern squircle card groups, smooth borders, and curated accent badges.',
|
||||
'Category Quick Navigation: horizontal capsule strip at the top to smoothly jump between General, Appearance, Feed, Filters, Storage, Diagnostics, and About.',
|
||||
'Instant Segmented Controls: replaced tedious dropdowns with fast 1-tap SegmentedButtons for Theme (Dark/Light/Auto), Language (RU/EN), and Media Quality (Auto/Data saver/HQ).',
|
||||
'Folder Structure Bottom Sheet: intuitive download directory template picker with preset chips and custom pattern editor.',
|
||||
'Interactive Stepper & Limit Controls: modernized desktop & mobile column steppers and disk cache limits with accent pill badges.',
|
||||
'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and Gitea repository link.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.13',
|
||||
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
|
||||
bullets: [
|
||||
'Toolbar Section Dividers: added subtle vertical dividers between Actions (Reset/Refresh), Rating, Providers, and Top Periods to prevent visual clutter.',
|
||||
'Post Card Badge Deconfliction: moved Rating badge (Explicit/Questionable/Safe) to the bottom-left beneath the Seen/Downloaded indicators.',
|
||||
'Separated Provider & Media Badges: booru source (e.g. Gelbooru) remains top-left and Video/GIF badge remains top-right, preventing them from overlapping on narrow screens.',
|
||||
'Expanded Bottom Gradient: increased bottom cinematic gradient height to 64dp for clean text and badge contrast.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.12',
|
||||
title: 'Feed 2.0: Pinterest Modern Redesign & Sleek Cards',
|
||||
bullets: [
|
||||
'Feed 2.0 Design: modern squircle post cards with 14dp border radius, subtle micro-borders, and depth shadows.',
|
||||
'Unified Media Badges: sleek glassmorphism capsules for booru providers, ratings, video/GIF indicators, and status badges.',
|
||||
'Cinematic Bottom Gradient: gentle shading on post cards for clean presentation without obscuring art.',
|
||||
'Tactile Floating Favorite Button: circular glassmorphism heart button with neon glow and instant feedback.',
|
||||
'Compact 2-Tier Feed Toolbar: 22dp search capsule, filter count badge, and single unified horizontal scroll for sources and time periods.',
|
||||
'Quick Filter & Actions: direct access to random post, selection mode, rating picker, and active filter resets.',
|
||||
'Polished Search Presets: stylish bookmark capsules for quick access to saved search queries.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.11',
|
||||
title: 'Mobile Video Player Redesign & Format Recognition Fix',
|
||||
bullets: [
|
||||
'Fixed "Failed to recognize file format" error on initial video load by providing synchronous Referer headers and automatic retry.',
|
||||
'Modern Mobile Video Player: cinematic top and bottom gradient glassmorphism overlays with immersive controls.',
|
||||
'Double-Tap Seek: double-tap left side to seek -10s, right side to seek +10s with glowing ripple animations.',
|
||||
'Hold for 2X Speed Boost: press and hold video to temporarily play at 2.0x with glowing badge, release to resume regular speed.',
|
||||
'Vertical Volume Swipe: swipe up/down on the right half of the screen to adjust player volume with a floating badge.',
|
||||
'Playback Speed Selector: quick chip button supporting 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x speeds.',
|
||||
'Screen Lock Mode: lock player controls with one tap to prevent accidental touches during video watching.',
|
||||
'Sleek Neon Seekbar with live network buffer progress indicator.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.10',
|
||||
title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling',
|
||||
bullets: [
|
||||
'Fixed "and" operator disappearing when submitting search or applying autocompletions.',
|
||||
'Preserved preceding draft tokens (e.g. typing "cat and d" then picking "dog" now retains "cat", "AND", and "dog").',
|
||||
'Supported multiple "and" separators without accidental deduplication.',
|
||||
'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.9',
|
||||
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
|
||||
|
||||
@@ -7,9 +7,12 @@ export 'models/artist_work_query.dart';
|
||||
export 'models/artist_tag.dart';
|
||||
export 'models/artist_link.dart';
|
||||
export 'models/artist_announcement.dart';
|
||||
export 'models/cloud_media_link.dart';
|
||||
export 'models/download_task.dart';
|
||||
export 'models/downloaded_media.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/favorite_artist_item.dart';
|
||||
export 'models/pawchive_account.dart';
|
||||
export 'models/post.dart';
|
||||
export 'models/post_comment.dart';
|
||||
export 'models/provider_diagnostics.dart';
|
||||
@@ -26,6 +29,7 @@ export 'services/download_manager_service.dart';
|
||||
export 'services/downloaded_media_service.dart';
|
||||
export 'services/favorite_service.dart';
|
||||
export 'services/feed_service.dart';
|
||||
export 'services/pawchive_sync_service.dart';
|
||||
export 'services/provider_check_service.dart';
|
||||
export 'services/search_service.dart';
|
||||
export 'services/tag_cache_service.dart';
|
||||
@@ -33,5 +37,6 @@ export 'services/settings_service.dart';
|
||||
export 'services/update_service.dart';
|
||||
export 'services/viewed_history_service.dart';
|
||||
export 'services/backup_service.dart';
|
||||
export 'services/cloud_link_extractor.dart';
|
||||
export 'utils/smart_blacklist.dart';
|
||||
export 'utils/media_quality.dart';
|
||||
|
||||
@@ -194,6 +194,11 @@ final downloadedMediaByKeysProvider =
|
||||
: const {};
|
||||
});
|
||||
|
||||
final offlineDiskSizeProvider =
|
||||
FutureProvider.family<int, Iterable<DownloadedMedia>>((ref, items) {
|
||||
return DownloadedMediaService.totalDiskSizeBytes(items);
|
||||
});
|
||||
|
||||
final downloadedMediaByKeyProvider =
|
||||
FutureProvider.family<DownloadedMedia?, String>((ref, cacheKey) async {
|
||||
final result =
|
||||
|
||||
@@ -7,6 +7,9 @@ class AppUpdateInfo {
|
||||
required this.htmlUrl,
|
||||
required this.publishedAt,
|
||||
this.apkUrl,
|
||||
this.apkArm64Url,
|
||||
this.apkArmv7Url,
|
||||
this.apkX86_64Url,
|
||||
this.windowsInstallerUrl,
|
||||
this.portableZipUrl,
|
||||
this.linuxTarGzUrl,
|
||||
@@ -18,13 +21,36 @@ class AppUpdateInfo {
|
||||
final String body;
|
||||
final String htmlUrl;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
/// Generic APK (fat / universal build). Used as a fallback.
|
||||
final String? apkUrl;
|
||||
|
||||
/// Per-ABI APKs produced by `flutter build apk --split-per-abi`.
|
||||
final String? apkArm64Url; // app-arm64-v8a-release.apk
|
||||
final String? apkArmv7Url; // app-armeabi-v7a-release.apk
|
||||
final String? apkX86_64Url; // app-x86_64-release.apk
|
||||
|
||||
final String? windowsInstallerUrl;
|
||||
final String? portableZipUrl;
|
||||
final String? linuxTarGzUrl;
|
||||
|
||||
/// Returns the best APK URL for [abi], falling back to the generic APK.
|
||||
String? apkUrlForAbi(String abi) {
|
||||
if (abi.contains('arm64') || abi == 'arm64-v8a') {
|
||||
return apkArm64Url ?? apkUrl;
|
||||
}
|
||||
if (abi.contains('x86_64')) {
|
||||
return apkX86_64Url ?? apkUrl;
|
||||
}
|
||||
// armeabi-v7a and everything else
|
||||
return apkArmv7Url ?? apkArm64Url ?? apkUrl;
|
||||
}
|
||||
|
||||
bool get hasAssets =>
|
||||
apkUrl != null ||
|
||||
apkArm64Url != null ||
|
||||
apkArmv7Url != null ||
|
||||
apkX86_64Url != null ||
|
||||
windowsInstallerUrl != null ||
|
||||
portableZipUrl != null ||
|
||||
linuxTarGzUrl != null;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum CloudServiceType {
|
||||
googleDrive,
|
||||
mega,
|
||||
dropbox,
|
||||
pixeldrain,
|
||||
catbox,
|
||||
mediafire,
|
||||
bunkr,
|
||||
gofile,
|
||||
terabox,
|
||||
genericVideo,
|
||||
other,
|
||||
}
|
||||
|
||||
class CloudMediaLink {
|
||||
const CloudMediaLink({
|
||||
required this.url,
|
||||
required this.service,
|
||||
required this.title,
|
||||
this.directStreamUrl,
|
||||
this.isFolder = false,
|
||||
this.isStreamable = false,
|
||||
this.detectedPassword,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final CloudServiceType service;
|
||||
final String title;
|
||||
final String? directStreamUrl;
|
||||
final bool isFolder;
|
||||
final bool isStreamable;
|
||||
final String? detectedPassword;
|
||||
|
||||
String get serviceName {
|
||||
switch (service) {
|
||||
case CloudServiceType.googleDrive:
|
||||
return 'Google Drive';
|
||||
case CloudServiceType.mega:
|
||||
return 'MEGA';
|
||||
case CloudServiceType.dropbox:
|
||||
return 'Dropbox';
|
||||
case CloudServiceType.pixeldrain:
|
||||
return 'Pixeldrain';
|
||||
case CloudServiceType.catbox:
|
||||
return 'Catbox';
|
||||
case CloudServiceType.mediafire:
|
||||
return 'MediaFire';
|
||||
case CloudServiceType.bunkr:
|
||||
return 'Bunkr';
|
||||
case CloudServiceType.gofile:
|
||||
return 'GoFile';
|
||||
case CloudServiceType.terabox:
|
||||
return 'TeraBox';
|
||||
case CloudServiceType.genericVideo:
|
||||
return 'Direct Video';
|
||||
case CloudServiceType.other:
|
||||
return 'External Mirror';
|
||||
}
|
||||
}
|
||||
|
||||
Color get brandColor {
|
||||
switch (service) {
|
||||
case CloudServiceType.googleDrive:
|
||||
return const Color(0xFF4285F4); // Google Blue
|
||||
case CloudServiceType.mega:
|
||||
return const Color(0xFFD9272E); // Mega Red
|
||||
case CloudServiceType.dropbox:
|
||||
return const Color(0xFF0061FF); // Dropbox Blue
|
||||
case CloudServiceType.pixeldrain:
|
||||
return const Color(0xFF00B4D8); // Cyan
|
||||
case CloudServiceType.catbox:
|
||||
return const Color(0xFFFF6F00); // Amber/Orange
|
||||
case CloudServiceType.mediafire:
|
||||
return const Color(0xFF1E88E5); // MediaFire Blue
|
||||
case CloudServiceType.bunkr:
|
||||
return const Color(0xFF9C27B0); // Purple
|
||||
case CloudServiceType.gofile:
|
||||
return const Color(0xFF26A69A); // Teal
|
||||
case CloudServiceType.terabox:
|
||||
return const Color(0xFF29B6F6); // Light Blue
|
||||
case CloudServiceType.genericVideo:
|
||||
return const Color(0xFFE91E63); // Pink
|
||||
case CloudServiceType.other:
|
||||
return const Color(0xFF78909C); // Slate
|
||||
}
|
||||
}
|
||||
|
||||
IconData get iconData {
|
||||
if (isFolder) return Icons.folder_zip_rounded;
|
||||
if (isStreamable || service == CloudServiceType.genericVideo) {
|
||||
return Icons.play_circle_filled_rounded;
|
||||
}
|
||||
switch (service) {
|
||||
case CloudServiceType.googleDrive:
|
||||
return Icons.add_to_drive_rounded;
|
||||
case CloudServiceType.mega:
|
||||
return Icons.cloud_circle_rounded;
|
||||
case CloudServiceType.dropbox:
|
||||
return Icons.cloud_done_rounded;
|
||||
case CloudServiceType.pixeldrain:
|
||||
case CloudServiceType.catbox:
|
||||
case CloudServiceType.mediafire:
|
||||
case CloudServiceType.bunkr:
|
||||
case CloudServiceType.gofile:
|
||||
case CloudServiceType.terabox:
|
||||
return Icons.cloud_download_rounded;
|
||||
case CloudServiceType.genericVideo:
|
||||
return Icons.videocam_rounded;
|
||||
case CloudServiceType.other:
|
||||
return Icons.link_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'service': service.name,
|
||||
'title': title,
|
||||
if (directStreamUrl != null) 'directStreamUrl': directStreamUrl,
|
||||
'isFolder': isFolder,
|
||||
'isStreamable': isStreamable,
|
||||
if (detectedPassword != null) 'detectedPassword': detectedPassword,
|
||||
};
|
||||
|
||||
factory CloudMediaLink.fromJson(Map<String, dynamic> json) {
|
||||
final serviceStr = (json['service'] ?? '').toString();
|
||||
final service = CloudServiceType.values.firstWhere(
|
||||
(e) => e.name == serviceStr,
|
||||
orElse: () => CloudServiceType.other,
|
||||
);
|
||||
return CloudMediaLink(
|
||||
url: (json['url'] ?? '').toString(),
|
||||
service: service,
|
||||
title: (json['title'] ?? '').toString(),
|
||||
directStreamUrl: json['directStreamUrl']?.toString(),
|
||||
isFolder: json['isFolder'] == true,
|
||||
isStreamable: json['isStreamable'] == true,
|
||||
detectedPassword: json['detectedPassword']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => jsonEncode(toJson());
|
||||
|
||||
static CloudMediaLink? tryDecode(String str) {
|
||||
try {
|
||||
final decoded = jsonDecode(str);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return CloudMediaLink.fromJson(decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
class FavoriteArtistItem {
|
||||
const FavoriteArtistItem({
|
||||
required this.id,
|
||||
required this.service,
|
||||
required this.providerId,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
});
|
||||
|
||||
const FavoriteArtistItem.empty()
|
||||
: id = '',
|
||||
service = '',
|
||||
providerId = '',
|
||||
name = '',
|
||||
avatarUrl = null;
|
||||
|
||||
final String id;
|
||||
final String service;
|
||||
final String providerId;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
|
||||
bool get isEmpty => id.isEmpty;
|
||||
String get key => '$providerId:$service:$id';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'service': service,
|
||||
'providerId': providerId,
|
||||
'name': name,
|
||||
'avatarUrl': avatarUrl,
|
||||
};
|
||||
|
||||
factory FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
|
||||
FavoriteArtistItem(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
service: (json['service'] ?? '').toString(),
|
||||
providerId: (json['providerId'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
avatarUrl: json['avatarUrl'] as String?,
|
||||
);
|
||||
|
||||
FavoriteArtistItem copyWith({
|
||||
String? id,
|
||||
String? service,
|
||||
String? providerId,
|
||||
String? name,
|
||||
String? avatarUrl,
|
||||
}) {
|
||||
return FavoriteArtistItem(
|
||||
id: id ?? this.id,
|
||||
service: service ?? this.service,
|
||||
providerId: providerId ?? this.providerId,
|
||||
name: name ?? this.name,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is FavoriteArtistItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
key == other.key;
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
class PawchiveAccount {
|
||||
const PawchiveAccount({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.sessionCookie,
|
||||
this.baseUrl = 'https://pawchive.pw',
|
||||
required this.createdAt,
|
||||
this.lastSyncedAt,
|
||||
this.isActive = true,
|
||||
this.syncedArtistsCount = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String username;
|
||||
final String sessionCookie;
|
||||
final String baseUrl;
|
||||
final DateTime createdAt;
|
||||
final DateTime? lastSyncedAt;
|
||||
final bool isActive;
|
||||
final int syncedArtistsCount;
|
||||
|
||||
PawchiveAccount copyWith({
|
||||
String? id,
|
||||
String? username,
|
||||
String? sessionCookie,
|
||||
String? baseUrl,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastSyncedAt,
|
||||
bool? isActive,
|
||||
int? syncedArtistsCount,
|
||||
}) {
|
||||
return PawchiveAccount(
|
||||
id: id ?? this.id,
|
||||
username: username ?? this.username,
|
||||
sessionCookie: sessionCookie ?? this.sessionCookie,
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
syncedArtistsCount: syncedArtistsCount ?? this.syncedArtistsCount,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'sessionCookie': sessionCookie,
|
||||
'baseUrl': baseUrl,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'lastSyncedAt': lastSyncedAt?.toIso8601String(),
|
||||
'isActive': isActive,
|
||||
'syncedArtistsCount': syncedArtistsCount,
|
||||
};
|
||||
|
||||
factory PawchiveAccount.fromJson(Map<String, dynamic> json) {
|
||||
return PawchiveAccount(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
username: (json['username'] ?? '').toString(),
|
||||
sessionCookie: (json['sessionCookie'] ?? '').toString(),
|
||||
baseUrl: (json['baseUrl'] ?? 'https://pawchive.pw').toString(),
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.tryParse(json['createdAt'].toString()) ?? DateTime.now()
|
||||
: DateTime.now(),
|
||||
lastSyncedAt: json['lastSyncedAt'] != null
|
||||
? DateTime.tryParse(json['lastSyncedAt'].toString())
|
||||
: null,
|
||||
isActive: json['isActive'] as bool? ?? true,
|
||||
syncedArtistsCount: (json['syncedArtistsCount'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PawchiveAccount &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'cloud_media_link.dart';
|
||||
import '../services/cloud_link_extractor.dart';
|
||||
|
||||
class Post {
|
||||
const Post({
|
||||
required this.id,
|
||||
@@ -35,6 +38,48 @@ class Post {
|
||||
|
||||
String get cacheKey => '$providerId:$id';
|
||||
|
||||
List<CloudMediaLink> get cloudLinks {
|
||||
final list = tagGroups['cloud_links'];
|
||||
if (list != null && list.isNotEmpty) {
|
||||
return list
|
||||
.map((e) => CloudMediaLink.tryDecode(e))
|
||||
.whereType<CloudMediaLink>()
|
||||
.toList(growable: false);
|
||||
}
|
||||
if (source != null && source!.isNotEmpty) {
|
||||
return CloudLinkExtractor.extractLinks(source: source);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
String? get commentary {
|
||||
final list = tagGroups['description'];
|
||||
if (list != null && list.isNotEmpty) {
|
||||
return list.first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> get cleanTags {
|
||||
return tags.where((tag) {
|
||||
final t = tag.trim();
|
||||
if (t.isEmpty) return false;
|
||||
if (t == 'cloud_mirror') return false;
|
||||
if (t.startsWith('{') && t.endsWith('}')) return false;
|
||||
if (t.contains('"url"') || t.contains('"service"')) return false;
|
||||
if (t.startsWith('http://') || t.startsWith('https://')) return false;
|
||||
if (t.contains('mega.nz') ||
|
||||
t.contains('drive.google.com') ||
|
||||
t.contains('dropbox.com') ||
|
||||
t.contains('pixeldrain.com') ||
|
||||
t.contains('catbox.moe') ||
|
||||
t.contains('mediafire.com')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
Post copyWith({
|
||||
String? id,
|
||||
String? providerId,
|
||||
|
||||
@@ -65,45 +65,55 @@ class GelbooruProvider
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
..._topTags(topPeriod),
|
||||
];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'tags': queryTags.join(' '),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'tags': queryTags.join(' '),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
_checkResponse(response);
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
_handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'id': id,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'id': id,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
_checkResponse(response);
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
} on DioException catch (e) {
|
||||
_handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -142,18 +152,22 @@ class GelbooruProvider
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'comment',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'post_id': postId,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return _commentsFromResponse(response.data, postId);
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'comment',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'post_id': postId,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return _commentsFromResponse(response.data, postId);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -161,33 +175,39 @@ class GelbooruProvider
|
||||
{int limit = 20}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'tag',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'name_pattern': '$trimmed*',
|
||||
'limit': limit.clamp(1, 100),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return _tagItems(response.data)
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return TagSuggestion(
|
||||
name: (json['name'] ?? '').toString(),
|
||||
category: tagCategoryFromString(
|
||||
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
|
||||
),
|
||||
postCount: _int(json['count'] ?? json['post_count']),
|
||||
providerId: id,
|
||||
);
|
||||
})
|
||||
.where((tag) => tag.name.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'tag',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'name_pattern': '$trimmed%',
|
||||
'orderby': 'count',
|
||||
'order': 'DESC',
|
||||
'limit': limit.clamp(1, 100),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return _tagItems(response.data)
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return TagSuggestion(
|
||||
name: (json['name'] ?? '').toString(),
|
||||
category: tagCategoryFromString(
|
||||
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
|
||||
),
|
||||
postCount: _int(json['count'] ?? json['post_count']),
|
||||
providerId: id,
|
||||
);
|
||||
})
|
||||
.where((tag) => tag.name.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -201,27 +221,81 @@ class GelbooruProvider
|
||||
if (cleaned.isEmpty) return const {};
|
||||
|
||||
final groups = <String, List<String>>{};
|
||||
for (final tag in cleaned) {
|
||||
try {
|
||||
final suggestions = await suggestTags(tag, limit: 3);
|
||||
final exact = suggestions.where((item) => item.name == tag).firstOrNull;
|
||||
final category = exact?.category ?? TagCategory.general;
|
||||
final key = switch (category) {
|
||||
TagCategory.artist => 'artist',
|
||||
TagCategory.copyright => 'copyright',
|
||||
TagCategory.character => 'character',
|
||||
TagCategory.meta => 'meta',
|
||||
TagCategory.species => 'species',
|
||||
TagCategory.general || TagCategory.unknown => 'general',
|
||||
};
|
||||
groups.putIfAbsent(key, () => <String>[]).add(tag);
|
||||
} catch (_) {
|
||||
groups.putIfAbsent('general', () => <String>[]).add(tag);
|
||||
final tagToCategory = <String, TagCategory>{};
|
||||
|
||||
// Batch query tag metadata using Gelbooru DAPI "names" parameter
|
||||
try {
|
||||
for (var i = 0; i < cleaned.length; i += 50) {
|
||||
final chunk = cleaned.sublist(
|
||||
i,
|
||||
(i + 50 > cleaned.length) ? cleaned.length : i + 50,
|
||||
);
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'tag',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'names': chunk.join(' '),
|
||||
'limit': chunk.length.clamp(1, 100),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
for (final item in _tagItems(response.data).whereType<Map>()) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
final name = (json['name'] ?? '').toString().trim();
|
||||
if (name.isNotEmpty) {
|
||||
tagToCategory[name] = tagCategoryFromString(
|
||||
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Gracefully continue to fallback categorization
|
||||
}
|
||||
|
||||
for (final tag in cleaned) {
|
||||
final category = tagToCategory[tag] ?? TagCategory.general;
|
||||
final key = switch (category) {
|
||||
TagCategory.artist => 'artist',
|
||||
TagCategory.copyright => 'copyright',
|
||||
TagCategory.character => 'character',
|
||||
TagCategory.meta => 'meta',
|
||||
TagCategory.species => 'species',
|
||||
TagCategory.general || TagCategory.unknown => 'general',
|
||||
};
|
||||
groups.putIfAbsent(key, () => <String>[]).add(tag);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
void _checkResponse(Response<dynamic> response) {
|
||||
if (response.data is String) {
|
||||
final text = (response.data as String).toLowerCase();
|
||||
if (text.contains('throttled') ||
|
||||
text.contains('authentication required') ||
|
||||
text.contains('requires authentication')) {
|
||||
unavailable(
|
||||
'$name requires authentication or is throttled. '
|
||||
'Please configure API Key & User ID in Settings -> Providers.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Never _handleDioError(DioException error) {
|
||||
final status = error.response?.statusCode;
|
||||
if (status == 401 || status == 403 || status == 429) {
|
||||
unavailable(
|
||||
'$name requires authentication or rate limit exceeded ($status). '
|
||||
'Please enter your API Key and User ID in Settings -> Providers.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
Never unavailable(String message) =>
|
||||
throw ProviderUnavailableException(message);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../services/cloud_link_extractor.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class KemonoProvider
|
||||
@@ -361,6 +362,18 @@ class KemonoProvider
|
||||
_dateFromAny(json['added']) ??
|
||||
_dateFromAny(json['edited']) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final postSource =
|
||||
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
|
||||
final rawContent = (json['content'] ?? '').toString();
|
||||
final embed = json['embed'] is Map ? Map<String, dynamic>.from(json['embed']) : null;
|
||||
final cloudLinks = CloudLinkExtractor.extractLinks(
|
||||
content: rawContent,
|
||||
source: postSource,
|
||||
embed: embed,
|
||||
);
|
||||
final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
|
||||
final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
|
||||
|
||||
final files = <Map<String, dynamic>>[];
|
||||
final file = json['file'];
|
||||
if (file is Map && file.isNotEmpty) {
|
||||
@@ -370,6 +383,47 @@ class KemonoProvider
|
||||
if (attachments is List) {
|
||||
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
|
||||
}
|
||||
|
||||
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
|
||||
if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) {
|
||||
final stableId = '${query.service}:${query.artistId}:$postId:0';
|
||||
final firstStreamable =
|
||||
cloudLinks.where((l) => l.isStreamable).firstOrNull;
|
||||
final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null;
|
||||
final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? '';
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
'cloud_mirror',
|
||||
];
|
||||
return [
|
||||
Post(
|
||||
id: stableId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: '',
|
||||
sampleUrl: '',
|
||||
fileUrl: fileUrl,
|
||||
tags: tags,
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: postSource,
|
||||
createdAt: published,
|
||||
fileType: firstStreamable != null ? 'video' : 'link',
|
||||
score: 0,
|
||||
tagGroups: {
|
||||
'artist': [query.artistName],
|
||||
'meta': [query.service],
|
||||
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
|
||||
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
|
||||
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
return files
|
||||
.map((file) {
|
||||
@@ -396,8 +450,7 @@ class KemonoProvider
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source:
|
||||
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
|
||||
source: postSource,
|
||||
createdAt: published,
|
||||
fileType: type,
|
||||
score: 0,
|
||||
@@ -405,6 +458,8 @@ class KemonoProvider
|
||||
'artist': [query.artistName],
|
||||
'meta': [query.service],
|
||||
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
|
||||
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
|
||||
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../services/cloud_link_extractor.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class PawchiveProvider
|
||||
@@ -394,6 +395,18 @@ class PawchiveProvider
|
||||
_dateFromAny(json['added']) ??
|
||||
_dateFromAny(json['edited']) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final postSource =
|
||||
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
|
||||
final rawContent = (json['content'] ?? '').toString();
|
||||
final embed = json['embed'] is Map ? Map<String, dynamic>.from(json['embed']) : null;
|
||||
final cloudLinks = CloudLinkExtractor.extractLinks(
|
||||
content: rawContent,
|
||||
source: postSource,
|
||||
embed: embed,
|
||||
);
|
||||
final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
|
||||
final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
|
||||
|
||||
final files = <Map<String, dynamic>>[];
|
||||
final file = json['file'];
|
||||
if (file is Map && file.isNotEmpty) {
|
||||
@@ -404,6 +417,46 @@ class PawchiveProvider
|
||||
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
|
||||
}
|
||||
|
||||
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
|
||||
if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) {
|
||||
final stableId = '${query.service}:${query.artistId}:$postId:0';
|
||||
final firstStreamable =
|
||||
cloudLinks.where((l) => l.isStreamable).firstOrNull;
|
||||
final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null;
|
||||
final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? '';
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
'cloud_mirror',
|
||||
];
|
||||
return [
|
||||
Post(
|
||||
id: stableId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: '',
|
||||
sampleUrl: '',
|
||||
fileUrl: fileUrl,
|
||||
tags: tags,
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: postSource,
|
||||
createdAt: published,
|
||||
fileType: firstStreamable != null ? 'video' : 'link',
|
||||
score: 0,
|
||||
tagGroups: {
|
||||
'artist': [query.artistName],
|
||||
'meta': [query.service],
|
||||
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
|
||||
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
|
||||
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
return files
|
||||
.map((fileMap) {
|
||||
@@ -436,8 +489,7 @@ class PawchiveProvider
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source:
|
||||
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
|
||||
source: postSource,
|
||||
createdAt: published,
|
||||
fileType: type,
|
||||
score: 0,
|
||||
@@ -445,6 +497,8 @@ class PawchiveProvider
|
||||
'artist': [query.artistName],
|
||||
'meta': [query.service],
|
||||
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
|
||||
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
|
||||
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../mappers/rule34_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
|
||||
@@ -36,27 +39,142 @@ class Rule34Provider extends GelbooruProvider {
|
||||
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
|
||||
TopPeriodFilter.allTime => const ['sort:score:desc'],
|
||||
};
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'tags': [
|
||||
...tags,
|
||||
if (rating != null) 'rating:$rating',
|
||||
...topTags,
|
||||
].join(' '),
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
return Rule34Mapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
try {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'pid': page,
|
||||
'limit': limit,
|
||||
'fields': 'tag_info',
|
||||
'tags': [
|
||||
...tags,
|
||||
if (rating != null) 'rating:$rating',
|
||||
...topTags,
|
||||
].join(' '),
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_checkRule34Response(response);
|
||||
return Rule34Mapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
_handleRule34DioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
try {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'id': id,
|
||||
'fields': 'tag_info',
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_checkRule34Response(response);
|
||||
final posts = Rule34Mapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
} on DioException catch (e) {
|
||||
_handleRule34DioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TagSuggestion>> suggestTags(String query,
|
||||
{int limit = 20}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
try {
|
||||
// Official Rule34 autocomplete endpoint (fast, public, with exact post count)
|
||||
final response = await dio.get<dynamic>(
|
||||
'/autocomplete.php',
|
||||
queryParameters: {
|
||||
'q': trimmed,
|
||||
},
|
||||
);
|
||||
if (response.data is List) {
|
||||
final list = response.data as List;
|
||||
final suggestions = list
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
final rawLabel = (json['label'] ?? '').toString();
|
||||
final rawValue = (json['value'] ?? '').toString();
|
||||
// Parse label "braid (216984)" -> name: "braid", postCount: 216984
|
||||
final match =
|
||||
RegExp(r'^(.*?)\s*\((\d+)\)$').firstMatch(rawLabel);
|
||||
final name = (match?.group(1) ?? rawValue).trim();
|
||||
final postCount = int.tryParse(match?.group(2) ?? '') ?? 0;
|
||||
return TagSuggestion(
|
||||
name: name.isNotEmpty ? name : rawValue,
|
||||
category: TagCategory.general,
|
||||
postCount: postCount,
|
||||
providerId: id,
|
||||
);
|
||||
})
|
||||
.where((tag) => tag.name.isNotEmpty)
|
||||
.take(limit)
|
||||
.toList(growable: false);
|
||||
if (suggestions.isNotEmpty) return suggestions;
|
||||
}
|
||||
} catch (_) {
|
||||
// Gracefully fall back to standard DAPI tag search
|
||||
}
|
||||
return super.suggestTags(query, limit: limit);
|
||||
}
|
||||
|
||||
void _checkRule34Response(Response<dynamic> response) {
|
||||
if (response.data is String) {
|
||||
final text = (response.data as String).toLowerCase();
|
||||
if (text.contains('missing authentication') ||
|
||||
text.contains('throttled') ||
|
||||
text.contains('authentication required')) {
|
||||
unavailable(
|
||||
'$name requires authentication. '
|
||||
'Please configure your API Key and User ID in Settings -> Providers '
|
||||
'(obtain from https://rule34.xxx/index.php?page=account&s=options).',
|
||||
);
|
||||
}
|
||||
if (text.contains('search down') ||
|
||||
text.contains('server is overloaded')) {
|
||||
unavailable(
|
||||
'$name searcher is temporarily down or overloaded. Please try again in a few minutes.',
|
||||
);
|
||||
}
|
||||
} else if (response.data is Map) {
|
||||
final map = response.data as Map;
|
||||
if (map['success'] == false || map['success'] == 'false') {
|
||||
final msg = map['message'] ?? 'search down';
|
||||
unavailable('$name error: $msg');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Never _handleRule34DioError(DioException error) {
|
||||
final status = error.response?.statusCode;
|
||||
if (status == 401 || status == 403 || status == 429) {
|
||||
unavailable(
|
||||
'$name requires authentication or rate limit reached ($status). '
|
||||
'Please enter your API Key and User ID in Settings -> Providers.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ class SearchRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<bool>> delete(String historyId) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
return await isar.searchHistoryEntitys.deleteByHistoryId(historyId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.searchHistoryEntitys.clear();
|
||||
|
||||
@@ -51,6 +51,14 @@ class ViewedPostRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.viewedPostEntitys.deleteByViewedKey(
|
||||
ViewedPost.keyFor(providerId, postId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.viewedPostEntitys.clear();
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import '../models/cloud_media_link.dart';
|
||||
|
||||
class CloudLinkExtractor {
|
||||
static final _urlRegex = RegExp(
|
||||
r'''https?://[^\s<>"{}|\\^`\[\]]+''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final _htmlLinkRegex = RegExp(
|
||||
r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
);
|
||||
|
||||
static final _passwordRegex = RegExp(
|
||||
r'(?:pass(?:word)?|pw|пароль|пас)(?:[\s:=_-]+)([^\s,;<>"\n\r]{2,50})',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Extracts any archive password mentioned in the raw text/HTML.
|
||||
static String? extractPassword(String? text) {
|
||||
if (text == null || text.trim().isEmpty) return null;
|
||||
final match = _passwordRegex.firstMatch(text);
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
final pass = match.group(1)?.trim();
|
||||
if (pass != null &&
|
||||
pass.isNotEmpty &&
|
||||
!pass.startsWith('http') &&
|
||||
pass.toLowerCase() != 'none') {
|
||||
return pass;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extracts all cloud media and video mirror links from text, HTML, and embeds.
|
||||
static List<CloudMediaLink> extractLinks({
|
||||
String? content,
|
||||
String? source,
|
||||
Map<String, dynamic>? embed,
|
||||
}) {
|
||||
final results = <CloudMediaLink>[];
|
||||
final seenUrls = <String>{};
|
||||
final detectedPass = extractPassword(content);
|
||||
|
||||
void addLink(String rawUrl, {String? customTitle}) {
|
||||
final cleanUrl = _cleanUrl(rawUrl);
|
||||
if (cleanUrl == null || seenUrls.contains(cleanUrl)) return;
|
||||
seenUrls.add(cleanUrl);
|
||||
|
||||
final link = _classifyLink(cleanUrl, customTitle: customTitle, password: detectedPass);
|
||||
if (link != null) {
|
||||
results.add(link);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Process HTML anchors first to preserve anchor text / custom title
|
||||
if (content != null && content.contains('<a ')) {
|
||||
for (final match in _htmlLinkRegex.allMatches(content)) {
|
||||
final href = match.group(1);
|
||||
final rawTitle = match.group(2);
|
||||
final cleanTitle = _stripHtml(rawTitle);
|
||||
if (href != null && href.isNotEmpty) {
|
||||
addLink(href, customTitle: cleanTitle.isNotEmpty ? cleanTitle : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Process all URLs in content text
|
||||
if (content != null) {
|
||||
for (final match in _urlRegex.allMatches(content)) {
|
||||
final url = match.group(0);
|
||||
if (url != null) addLink(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Process source
|
||||
if (source != null && source.trim().isNotEmpty) {
|
||||
for (final match in _urlRegex.allMatches(source)) {
|
||||
final url = match.group(0);
|
||||
if (url != null) addLink(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Process embed map
|
||||
if (embed != null) {
|
||||
final embedUrl = embed['url']?.toString();
|
||||
if (embedUrl != null && embedUrl.isNotEmpty) {
|
||||
final title = embed['title']?.toString();
|
||||
addLink(embedUrl, customTitle: title);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
static String? _cleanUrl(String raw) {
|
||||
var url = raw.trim();
|
||||
// Trim trailing punctuation like ), ., ,, ;, etc.
|
||||
while (url.isNotEmpty && (url.endsWith(')') || url.endsWith('.') || url.endsWith(',') || url.endsWith(';') || url.endsWith('>'))) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
final parsed = Uri.tryParse(url);
|
||||
if (parsed == null || !parsed.hasScheme || (!parsed.scheme.startsWith('http'))) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
static String _stripHtml(String? html) {
|
||||
if (html == null) return '';
|
||||
return html.replaceAll(RegExp(r'<[^>]*>'), '').trim();
|
||||
}
|
||||
|
||||
static CloudMediaLink? _classifyLink(
|
||||
String url, {
|
||||
String? customTitle,
|
||||
String? password,
|
||||
}) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return null;
|
||||
final host = uri.host.toLowerCase();
|
||||
final path = uri.path.toLowerCase();
|
||||
|
||||
// Google Drive
|
||||
if (host.contains('drive.google.com')) {
|
||||
final isFolder = path.contains('/folders/') || uri.queryParameters.containsKey('folder');
|
||||
String? directStreamUrl;
|
||||
final fileIdMatch = RegExp(r'/file/d/([a-zA-Z0-9_-]+)').firstMatch(uri.path);
|
||||
final idParam = fileIdMatch?.group(1) ?? uri.queryParameters['id'];
|
||||
if (idParam != null && !isFolder) {
|
||||
directStreamUrl = 'https://drive.google.com/uc?export=download&id=$idParam';
|
||||
}
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.googleDrive,
|
||||
title: customTitle ?? (isFolder ? 'Google Drive Folder' : 'Google Drive File'),
|
||||
directStreamUrl: directStreamUrl,
|
||||
isFolder: isFolder,
|
||||
isStreamable: directStreamUrl != null,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// MEGA
|
||||
if (host.contains('mega.nz') || host.contains('mega.co.nz')) {
|
||||
final isFolder = url.contains('/folder/') || url.contains('/#F!') || url.contains('#F!');
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.mega,
|
||||
title: customTitle ?? (isFolder ? 'MEGA Cloud Folder' : 'MEGA Video / Archive'),
|
||||
directStreamUrl: null, // End-to-end encrypted; launches in MEGA app/browser
|
||||
isFolder: isFolder,
|
||||
isStreamable: false,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// Dropbox
|
||||
if (host.contains('dropbox.com')) {
|
||||
final isFolder = path.contains('/sh/') || path.contains('/folder');
|
||||
var direct = url;
|
||||
if (direct.contains('?dl=0')) {
|
||||
direct = direct.replaceAll('?dl=0', '?raw=1');
|
||||
} else if (!direct.contains('?raw=1') && !isFolder) {
|
||||
direct = direct.contains('?') ? '$direct&raw=1' : '$direct?raw=1';
|
||||
}
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.dropbox,
|
||||
title: customTitle ?? (isFolder ? 'Dropbox Folder' : 'Dropbox Media'),
|
||||
directStreamUrl: isFolder ? null : direct,
|
||||
isFolder: isFolder,
|
||||
isStreamable: !isFolder,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// Pixeldrain
|
||||
if (host.contains('pixeldrain.com')) {
|
||||
final fileMatch = RegExp(r'/u/([a-zA-Z0-9_-]+)').firstMatch(uri.path);
|
||||
final isList = path.contains('/l/');
|
||||
final fileId = fileMatch?.group(1);
|
||||
final direct = fileId != null ? 'https://pixeldrain.com/api/file/$fileId' : null;
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.pixeldrain,
|
||||
title: customTitle ?? (isList ? 'Pixeldrain Gallery/List' : 'Pixeldrain Video'),
|
||||
directStreamUrl: direct,
|
||||
isFolder: isList,
|
||||
isStreamable: direct != null,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// Catbox / Litterbox
|
||||
if (host.contains('catbox.moe')) {
|
||||
final isVideo = path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v');
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.catbox,
|
||||
title: customTitle ?? 'Catbox Upload',
|
||||
directStreamUrl: url,
|
||||
isFolder: false,
|
||||
isStreamable: isVideo,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// MediaFire
|
||||
if (host.contains('mediafire.com')) {
|
||||
final isFolder = path.contains('/folder/') || path.contains('/?');
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.mediafire,
|
||||
title: customTitle ?? (isFolder ? 'MediaFire Folder' : 'MediaFire Download'),
|
||||
directStreamUrl: null,
|
||||
isFolder: isFolder,
|
||||
isStreamable: false,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// Bunkr
|
||||
if (host.contains('bunkr.')) {
|
||||
final isAlbum = path.contains('/a/') || path.contains('/v/');
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.bunkr,
|
||||
title: customTitle ?? (isAlbum ? 'Bunkr Album' : 'Bunkr Media'),
|
||||
directStreamUrl: null,
|
||||
isFolder: isAlbum,
|
||||
isStreamable: false,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// GoFile
|
||||
if (host.contains('gofile.io')) {
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.gofile,
|
||||
title: customTitle ?? 'GoFile Folder / Archive',
|
||||
directStreamUrl: null,
|
||||
isFolder: true,
|
||||
isStreamable: false,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// TeraBox
|
||||
if (host.contains('terabox') || host.contains('1024tera') || host.contains('dubox')) {
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.terabox,
|
||||
title: customTitle ?? 'TeraBox Cloud Share',
|
||||
directStreamUrl: null,
|
||||
isFolder: true,
|
||||
isStreamable: false,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// Direct Video Link (MP4, WEBM, MKV, MOV)
|
||||
if (path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v') || path.endsWith('.mkv')) {
|
||||
final filename = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'Video Stream';
|
||||
return CloudMediaLink(
|
||||
url: url,
|
||||
service: CloudServiceType.genericVideo,
|
||||
title: customTitle ?? filename,
|
||||
directStreamUrl: url,
|
||||
isFolder: false,
|
||||
isStreamable: true,
|
||||
detectedPassword: password,
|
||||
);
|
||||
}
|
||||
|
||||
// Ignore known booru and search engine domains
|
||||
if (host.contains('kemono') ||
|
||||
host.contains('coomer') ||
|
||||
host.contains('danbooru') ||
|
||||
host.contains('gelbooru') ||
|
||||
host.contains('google.com') && !host.contains('drive') ||
|
||||
host.contains('twitter.com') ||
|
||||
host.contains('x.com') ||
|
||||
host.contains('patreon.com') ||
|
||||
host.contains('fanbox.cc') ||
|
||||
host.contains('pixiv.net')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extracts clean, readable text (stripping HTML tags and formatting links) suitable for author commentary and announcements.
|
||||
static String cleanCommentary(String? rawHtml) {
|
||||
if (rawHtml == null || rawHtml.trim().isEmpty) return '';
|
||||
var text = rawHtml;
|
||||
|
||||
// Format anchor tags: <a href="URL">TEXT</a>
|
||||
text = text.replaceAllMapped(
|
||||
RegExp(r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
|
||||
caseSensitive: false, dotAll: true),
|
||||
(match) {
|
||||
final href = match.group(1)?.trim() ?? '';
|
||||
final label = match.group(2)?.replaceAll(RegExp(r'<[^>]*>'), '').trim() ?? '';
|
||||
if (label.isEmpty || label == href || href.contains(label)) {
|
||||
return href;
|
||||
}
|
||||
return '$label ($href)';
|
||||
},
|
||||
);
|
||||
|
||||
// Convert line breaks and paragraph endings
|
||||
text = text
|
||||
.replaceAll(RegExp(r'<br\s*\/?>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<\/p\s*>', caseSensitive: false), '\n\n')
|
||||
.replaceAll(RegExp(r'<\/div\s*>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<li\s*>', caseSensitive: false), '• ')
|
||||
.replaceAll(RegExp(r'<\/li\s*>', caseSensitive: false), '\n');
|
||||
|
||||
// Strip remaining HTML tags
|
||||
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||
|
||||
// Decode HTML entities
|
||||
text = text
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
|
||||
// Normalize multiple consecutive blank lines
|
||||
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
@@ -48,4 +48,39 @@ class DownloadedMediaService {
|
||||
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
|
||||
|
||||
Future<Result<int>> count() => _repository.count();
|
||||
|
||||
static int getFileSizeSync(DownloadedMedia media) {
|
||||
if (media.savedPath.isEmpty) return 0;
|
||||
final file = File(media.savedPath);
|
||||
if (!file.existsSync()) return 0;
|
||||
try {
|
||||
return file.lengthSync();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<int> totalDiskSizeBytes(Iterable<DownloadedMedia> items) async {
|
||||
int total = 0;
|
||||
for (final media in items) {
|
||||
if (media.savedPath.isNotEmpty) {
|
||||
final file = File(media.savedPath);
|
||||
if (await file.exists()) {
|
||||
try {
|
||||
total += await file.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
static String formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/favorite_artist_item.dart';
|
||||
import '../models/pawchive_account.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
final pawchiveSyncServiceProvider = Provider<PawchiveSyncService>((ref) {
|
||||
return PawchiveSyncService(dio: DioClient().dio);
|
||||
});
|
||||
|
||||
class PawchiveSyncResult {
|
||||
const PawchiveSyncResult({
|
||||
required this.updatedSettings,
|
||||
required this.newlyAddedToLocal,
|
||||
required this.totalSyncedCount,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final AppSettings updatedSettings;
|
||||
final int newlyAddedToLocal;
|
||||
final int totalSyncedCount;
|
||||
final String? errorMessage;
|
||||
|
||||
bool get isSuccess => errorMessage == null;
|
||||
}
|
||||
|
||||
class PawchiveSyncService {
|
||||
PawchiveSyncService({Dio? dio}) : _dio = dio ?? Dio();
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static String _normalizeCookie(String rawCookie) {
|
||||
var c = rawCookie.trim();
|
||||
if (c.startsWith('session=')) {
|
||||
c = c.substring('session='.length);
|
||||
}
|
||||
// strip semicolons if trailing
|
||||
final semiIdx = c.indexOf(';');
|
||||
if (semiIdx != -1) {
|
||||
c = c.substring(0, semiIdx);
|
||||
}
|
||||
return c.trim();
|
||||
}
|
||||
|
||||
/// Logs into Pawchive with username & password, returns new PawchiveAccount on success.
|
||||
Future<PawchiveAccount> loginWithCredentials({
|
||||
required String username,
|
||||
required String password,
|
||||
String baseUrl = 'https://pawchive.pw',
|
||||
}) async {
|
||||
final cleanUser = username.trim();
|
||||
final cleanPass = password.trim();
|
||||
if (cleanUser.isEmpty || cleanPass.isEmpty) {
|
||||
throw ArgumentError('Логин и пароль не могут быть пустыми');
|
||||
}
|
||||
|
||||
final normalizedBase = baseUrl.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl;
|
||||
|
||||
try {
|
||||
final response = await _dio.post<dynamic>(
|
||||
'$normalizedBase/account/login',
|
||||
data: {
|
||||
'username': cleanUser,
|
||||
'password': cleanPass,
|
||||
'location': '/artists',
|
||||
},
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
headers: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final redirectLocation = response.headers.value('location') ?? '';
|
||||
final setCookies = response.headers['set-cookie'] ?? const [];
|
||||
|
||||
String? sessionValue;
|
||||
for (final cookie in setCookies) {
|
||||
final match = RegExp(r'session=([^;]+)').firstMatch(cookie);
|
||||
if (match != null) {
|
||||
sessionValue = match.group(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionValue == null || sessionValue.isEmpty) {
|
||||
throw StateError('Не удалось получить сессию. Проверьте данные для входа.');
|
||||
}
|
||||
|
||||
// Check if redirect points back to login with flash error
|
||||
if (redirectLocation.contains('/account/login') ||
|
||||
redirectLocation.contains('/login')) {
|
||||
throw StateError('Неверный логин или пароль');
|
||||
}
|
||||
|
||||
// Check for flash errors in session cookie if present
|
||||
try {
|
||||
final dotIdx = sessionValue.indexOf('.');
|
||||
final payloadBase64 =
|
||||
dotIdx != -1 ? sessionValue.substring(0, dotIdx) : sessionValue;
|
||||
final normalizedB64 = base64Url.normalize(payloadBase64);
|
||||
final decoded = utf8.decode(base64Url.decode(normalizedB64));
|
||||
if (decoded.contains('is incorrect') || decoded.contains('_flashes')) {
|
||||
throw StateError('Неверный логин или пароль');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is StateError) rethrow;
|
||||
}
|
||||
|
||||
// Verify session and fetch initial artists
|
||||
final remoteArtists = await fetchRemoteFavoriteArtists(
|
||||
baseUrl: normalizedBase,
|
||||
sessionCookie: sessionValue,
|
||||
);
|
||||
|
||||
return PawchiveAccount(
|
||||
id: cleanUser.toLowerCase(),
|
||||
username: cleanUser,
|
||||
sessionCookie: sessionValue,
|
||||
baseUrl: normalizedBase,
|
||||
createdAt: DateTime.now(),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
syncedArtistsCount: remoteArtists.length,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final code = e.response?.statusCode;
|
||||
if (code == 401 || code == 403) {
|
||||
throw StateError('Неверный логин или пароль');
|
||||
} else if (code != null) {
|
||||
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
|
||||
} else {
|
||||
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is StateError || e is ArgumentError) rethrow;
|
||||
throw StateError('Ошибка входа в Pawchive: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates session cookie directly and creates PawchiveAccount.
|
||||
Future<PawchiveAccount> loginWithSession({
|
||||
required String sessionCookie,
|
||||
required String username,
|
||||
String baseUrl = 'https://pawchive.pw',
|
||||
}) async {
|
||||
final cleanUser = username.trim().isEmpty ? 'Pawchive User' : username.trim();
|
||||
final cleanCookie = _normalizeCookie(sessionCookie);
|
||||
if (cleanCookie.isEmpty) {
|
||||
throw ArgumentError('Сессионная кука не может быть пустой');
|
||||
}
|
||||
|
||||
final normalizedBase = baseUrl.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl;
|
||||
|
||||
try {
|
||||
final remoteArtists = await fetchRemoteFavoriteArtists(
|
||||
baseUrl: normalizedBase,
|
||||
sessionCookie: cleanCookie,
|
||||
);
|
||||
|
||||
return PawchiveAccount(
|
||||
id: cleanUser.toLowerCase(),
|
||||
username: cleanUser,
|
||||
sessionCookie: cleanCookie,
|
||||
baseUrl: normalizedBase,
|
||||
createdAt: DateTime.now(),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
syncedArtistsCount: remoteArtists.length,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final code = e.response?.statusCode;
|
||||
if (code == 401 || code == 403) {
|
||||
throw StateError('Сессия недействительна или истекла');
|
||||
} else if (code != null) {
|
||||
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
|
||||
} else {
|
||||
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is StateError || e is ArgumentError) rethrow;
|
||||
throw StateError('Сессия недействительна или истекла: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches favorite creators from Pawchive account.
|
||||
Future<List<FavoriteArtistItem>> fetchRemoteFavoriteArtists({
|
||||
required String baseUrl,
|
||||
required String sessionCookie,
|
||||
}) async {
|
||||
final cleanCookie = _normalizeCookie(sessionCookie);
|
||||
final normalizedBase = baseUrl.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl;
|
||||
|
||||
final response = await _dio.get<dynamic>(
|
||||
'$normalizedBase/api/v1/account/favorites',
|
||||
queryParameters: {'type': 'artist'},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Cookie': 'session=$cleanCookie',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw StateError('Сессия истекла или недействительна');
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Ошибка сервера Pawchive (HTTP ${response.statusCode})');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data is! List) return const [];
|
||||
|
||||
final list = <FavoriteArtistItem>[];
|
||||
for (final raw in data) {
|
||||
if (raw is! Map) continue;
|
||||
final item = Map<String, dynamic>.from(raw);
|
||||
final id = (item['id'] ?? '').toString();
|
||||
final service = (item['service'] ?? '').toString();
|
||||
final name = (item['name'] ?? id).toString();
|
||||
if (id.isEmpty || service.isEmpty) continue;
|
||||
|
||||
final avatarUrl = '$normalizedBase/icons/$service/$id';
|
||||
list.add(FavoriteArtistItem(
|
||||
id: id,
|
||||
service: service,
|
||||
providerId: 'pawchive',
|
||||
name: name,
|
||||
avatarUrl: avatarUrl,
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Synchronizes a specific Pawchive account with current local AppSettings.
|
||||
Future<PawchiveSyncResult> syncAccountFavorites({
|
||||
required PawchiveAccount account,
|
||||
required AppSettings settings,
|
||||
bool pushLocalPawchiveArtists = false,
|
||||
}) async {
|
||||
try {
|
||||
final remoteArtists = await fetchRemoteFavoriteArtists(
|
||||
baseUrl: account.baseUrl,
|
||||
sessionCookie: account.sessionCookie,
|
||||
);
|
||||
|
||||
final currentFavorites = List<String>.from(settings.favoriteArtists);
|
||||
final existingKeys = <String>{};
|
||||
for (final raw in currentFavorites) {
|
||||
try {
|
||||
final parsed = FavoriteArtistItem.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
existingKeys.add(parsed.key);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
var addedCount = 0;
|
||||
for (final artist in remoteArtists) {
|
||||
if (!existingKeys.contains(artist.key)) {
|
||||
currentFavorites.insert(0, jsonEncode(artist.toJson()));
|
||||
existingKeys.add(artist.key);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally push local Pawchive artists to remote
|
||||
if (pushLocalPawchiveArtists) {
|
||||
final remoteKeys = remoteArtists.map((a) => a.key).toSet();
|
||||
for (final raw in currentFavorites) {
|
||||
try {
|
||||
final parsed = FavoriteArtistItem.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
if (parsed.providerId == 'pawchive' &&
|
||||
!remoteKeys.contains(parsed.key)) {
|
||||
await toggleRemoteFavorite(
|
||||
account: account,
|
||||
service: parsed.service,
|
||||
artistId: parsed.id,
|
||||
isFavorite: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Update account sync stats in settings
|
||||
final updatedAccount = account.copyWith(
|
||||
lastSyncedAt: DateTime.now(),
|
||||
syncedArtistsCount: remoteArtists.length,
|
||||
);
|
||||
|
||||
final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) {
|
||||
return a.id == updatedAccount.id ? updatedAccount : a;
|
||||
}).toList();
|
||||
|
||||
final newSettings = settings.copyWith(
|
||||
favoriteArtists: currentFavorites,
|
||||
pawchiveAccounts:
|
||||
updatedAccountsList.map((a) => jsonEncode(a.toJson())).toList(),
|
||||
);
|
||||
|
||||
return PawchiveSyncResult(
|
||||
updatedSettings: newSettings,
|
||||
newlyAddedToLocal: addedCount,
|
||||
totalSyncedCount: remoteArtists.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return PawchiveSyncResult(
|
||||
updatedSettings: settings,
|
||||
newlyAddedToLocal: 0,
|
||||
totalSyncedCount: 0,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronizes ALL connected Pawchive accounts with local AppSettings.
|
||||
Future<PawchiveSyncResult> syncAllAccounts({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
final accounts = settings.parsedPawchiveAccounts;
|
||||
if (accounts.isEmpty) {
|
||||
return PawchiveSyncResult(
|
||||
updatedSettings: settings,
|
||||
newlyAddedToLocal: 0,
|
||||
totalSyncedCount: 0,
|
||||
errorMessage: 'Нет добавленных аккаунтов Pawchive',
|
||||
);
|
||||
}
|
||||
|
||||
var runningSettings = settings;
|
||||
var totalAdded = 0;
|
||||
var totalSynced = 0;
|
||||
String? firstError;
|
||||
|
||||
for (final account in accounts) {
|
||||
final res = await syncAccountFavorites(
|
||||
account: account,
|
||||
settings: runningSettings,
|
||||
);
|
||||
if (res.isSuccess) {
|
||||
runningSettings = res.updatedSettings;
|
||||
totalAdded += res.newlyAddedToLocal;
|
||||
totalSynced += res.totalSyncedCount;
|
||||
} else {
|
||||
firstError ??= res.errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return PawchiveSyncResult(
|
||||
updatedSettings: runningSettings,
|
||||
newlyAddedToLocal: totalAdded,
|
||||
totalSyncedCount: totalSynced,
|
||||
errorMessage: firstError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggles favorite creator on Pawchive server (POST or DELETE).
|
||||
Future<bool> toggleRemoteFavorite({
|
||||
required PawchiveAccount account,
|
||||
required String service,
|
||||
required String artistId,
|
||||
required bool isFavorite,
|
||||
}) async {
|
||||
final cleanCookie = _normalizeCookie(account.sessionCookie);
|
||||
final normalizedBase = account.baseUrl.endsWith('/')
|
||||
? account.baseUrl.substring(0, account.baseUrl.length - 1)
|
||||
: account.baseUrl;
|
||||
|
||||
final path = '$normalizedBase/api/v1/favorites/creator/$service/$artistId';
|
||||
final options = Options(
|
||||
headers: {
|
||||
'Cookie': 'session=$cleanCookie',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
validateStatus: (status) =>
|
||||
status != null && (status == 200 || status == 201 || status == 204),
|
||||
);
|
||||
|
||||
try {
|
||||
if (isFavorite) {
|
||||
final response = await _dio.post<dynamic>(path, options: options);
|
||||
return response.statusCode != null && response.statusCode! < 300;
|
||||
} else {
|
||||
final response = await _dio.delete<dynamic>(path, options: options);
|
||||
return response.statusCode != null && response.statusCode! < 300;
|
||||
}
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,15 @@ class SearchService {
|
||||
}
|
||||
|
||||
List<String> parseTags(String query) {
|
||||
return query
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.map(sanitizeToken)
|
||||
.where((tag) => tag.isNotEmpty && tag != 'and')
|
||||
.toSet()
|
||||
.toList();
|
||||
final seen = <String>{};
|
||||
final result = <String>[];
|
||||
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||
final tag = raw.trim();
|
||||
if (tag.isEmpty) continue;
|
||||
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||
result.add(tag);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Result<void>> saveSearch(
|
||||
@@ -159,6 +161,9 @@ class SearchService {
|
||||
return Success(sorted.take(limit).toList(growable: false));
|
||||
}
|
||||
|
||||
Future<Result<bool>> deleteHistoryItem(String historyId) =>
|
||||
_repository.delete(historyId);
|
||||
|
||||
Future<Result<void>> clearHistory() => _repository.clear();
|
||||
|
||||
Future<void> clearTagCache() async {
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../core/database/database_service.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/pawchive_account.dart';
|
||||
import '../models/provider_diagnostics.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
|
||||
@@ -49,6 +50,7 @@ class AppSettings {
|
||||
required this.lastFeedTopPeriod,
|
||||
required this.lastFeedScrollOffset,
|
||||
this.favoriteArtists = const [],
|
||||
this.pawchiveAccounts = const [],
|
||||
this.skippedUpdateVersion,
|
||||
this.lastUpdateCheckAt,
|
||||
this.defaultRatingFilter,
|
||||
@@ -63,6 +65,7 @@ class AppSettings {
|
||||
});
|
||||
|
||||
final List<String> favoriteArtists;
|
||||
final List<String> pawchiveAccounts;
|
||||
final List<String> enabledProviderIds;
|
||||
final bool nsfwEnabled;
|
||||
final int cacheTtlHours;
|
||||
@@ -118,6 +121,7 @@ class AppSettings {
|
||||
cacheMaxItems: 2000,
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
|
||||
favoriteArtists: [],
|
||||
pawchiveAccounts: [],
|
||||
themeMode: 'dark',
|
||||
languageCode: 'ru',
|
||||
appSeedColor: 0xFFE84D8A,
|
||||
@@ -200,6 +204,7 @@ class AppSettings {
|
||||
String? lastFeedRating,
|
||||
bool clearLastFeedRating = false,
|
||||
List<String>? favoriteArtists,
|
||||
List<String>? pawchiveAccounts,
|
||||
bool? amoledMode,
|
||||
bool? useDynamicColor,
|
||||
String? gridMode,
|
||||
@@ -210,6 +215,7 @@ class AppSettings {
|
||||
}) {
|
||||
return AppSettings(
|
||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||
pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts,
|
||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
||||
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
||||
@@ -269,6 +275,7 @@ class AppSettings {
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'favoriteArtists': favoriteArtists,
|
||||
'pawchiveAccounts': pawchiveAccounts,
|
||||
'enabledProviderIds': enabledProviderIds,
|
||||
'nsfwEnabled': nsfwEnabled,
|
||||
'cacheTtlHours': cacheTtlHours,
|
||||
@@ -411,6 +418,9 @@ class AppSettings {
|
||||
favoriteArtists: List<String>.from(
|
||||
(json['favoriteArtists'] as List?) ?? defaults.favoriteArtists,
|
||||
),
|
||||
pawchiveAccounts: List<String>.from(
|
||||
(json['pawchiveAccounts'] as List?) ?? defaults.pawchiveAccounts,
|
||||
),
|
||||
skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
|
||||
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
@@ -428,6 +438,25 @@ class AppSettings {
|
||||
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
|
||||
defaults.tagCacheLimit,
|
||||
);
|
||||
|
||||
List<PawchiveAccount> get parsedPawchiveAccounts {
|
||||
final list = <PawchiveAccount>[];
|
||||
for (final raw in pawchiveAccounts) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
list.add(PawchiveAccount.fromJson(decoded));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
PawchiveAccount? get activePawchiveAccount {
|
||||
final accounts = parsedPawchiveAccounts;
|
||||
if (accounts.isEmpty) return null;
|
||||
return accounts.firstWhere((a) => a.isActive, orElse: () => accounts.first);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsService {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../app/app_version.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
@@ -15,9 +17,39 @@ class UpdateService {
|
||||
static const releasesUrl =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
|
||||
|
||||
static const _deviceChannel = MethodChannel('rulegel/device');
|
||||
|
||||
final Dio _dio;
|
||||
final SettingsService _settingsService;
|
||||
|
||||
// Cached primary ABI (e.g. "arm64-v8a").
|
||||
String? _cachedPrimaryAbi;
|
||||
|
||||
/// Returns the primary ABI of the running device.
|
||||
/// On non-Android platforms returns an empty string.
|
||||
Future<String> getPrimaryAbi() async {
|
||||
if (_cachedPrimaryAbi != null) return _cachedPrimaryAbi!;
|
||||
if (!Platform.isAndroid) return '';
|
||||
try {
|
||||
final abi = await _deviceChannel.invokeMethod<String>('getPrimaryAbi');
|
||||
_cachedPrimaryAbi = abi ?? 'armeabi-v7a';
|
||||
} catch (_) {
|
||||
_cachedPrimaryAbi = 'armeabi-v7a';
|
||||
}
|
||||
return _cachedPrimaryAbi!;
|
||||
}
|
||||
|
||||
/// Returns all supported ABIs of the running device.
|
||||
Future<List<String>> getSupportedAbis() async {
|
||||
if (!Platform.isAndroid) return const [];
|
||||
try {
|
||||
final abis = await _deviceChannel.invokeMethod<List<Object?>>('getSupportedAbis');
|
||||
return abis?.whereType<String>().toList() ?? const [];
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<AppUpdateInfo?>> checkForUpdates({bool force = false}) async {
|
||||
final settingsResult = await _settingsService.getSettings();
|
||||
final settings = settingsResult is Success<AppSettings>
|
||||
@@ -97,21 +129,40 @@ class UpdateService {
|
||||
AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) {
|
||||
final tag = (json['tag_name'] ?? '').toString();
|
||||
final assets = (json['assets'] as List?) ?? const [];
|
||||
|
||||
String? apkUrl;
|
||||
String? apkArm64Url;
|
||||
String? apkArmv7Url;
|
||||
String? apkX86_64Url;
|
||||
String? installerUrl;
|
||||
String? portableUrl;
|
||||
String? linuxTarGzUrl;
|
||||
|
||||
for (final item in assets.whereType<Map>()) {
|
||||
final name = (item['name'] ?? '').toString().toLowerCase();
|
||||
final url = (item['browser_download_url'] ?? '').toString();
|
||||
if (url.isEmpty) continue;
|
||||
if (name.endsWith('.apk')) apkUrl = url;
|
||||
|
||||
if (name.endsWith('.apk')) {
|
||||
// Detect per-ABI APKs by name pattern produced by --split-per-abi.
|
||||
if (name.contains('arm64-v8a') || name.contains('arm64_v8a')) {
|
||||
apkArm64Url = url;
|
||||
} else if (name.contains('armeabi-v7a') || name.contains('armeabi_v7a')) {
|
||||
apkArmv7Url = url;
|
||||
} else if (name.contains('x86_64')) {
|
||||
apkX86_64Url = url;
|
||||
} else {
|
||||
// Universal / fat APK
|
||||
apkUrl = url;
|
||||
}
|
||||
}
|
||||
if (name.endsWith('.exe')) installerUrl = url;
|
||||
if (name.endsWith('.zip')) portableUrl = url;
|
||||
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||
linuxTarGzUrl = url;
|
||||
}
|
||||
}
|
||||
|
||||
return AppUpdateInfo(
|
||||
version: _versionFromTag(tag),
|
||||
tagName: tag,
|
||||
@@ -120,15 +171,21 @@ class UpdateService {
|
||||
htmlUrl: (json['html_url'] ?? '').toString(),
|
||||
publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()),
|
||||
apkUrl: apkUrl,
|
||||
apkArm64Url: apkArm64Url,
|
||||
apkArmv7Url: apkArmv7Url,
|
||||
apkX86_64Url: apkX86_64Url,
|
||||
windowsInstallerUrl: installerUrl,
|
||||
portableZipUrl: portableUrl,
|
||||
linuxTarGzUrl: linuxTarGzUrl,
|
||||
);
|
||||
}
|
||||
|
||||
String? assetUrlForCurrentPlatform(AppUpdateInfo info) {
|
||||
/// Returns the download URL for the current platform/ABI.
|
||||
/// Pass [abi] (e.g. from [getPrimaryAbi]) on Android for per-ABI selection.
|
||||
Future<String?> assetUrlForCurrentPlatform(AppUpdateInfo info) async {
|
||||
if (Platform.isAndroid) {
|
||||
return info.apkUrl;
|
||||
final abi = await getPrimaryAbi();
|
||||
return info.apkUrlForAbi(abi);
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return info.linuxTarGzUrl ?? info.portableZipUrl;
|
||||
|
||||
@@ -59,6 +59,10 @@ class ViewedHistoryService {
|
||||
]);
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||
return _repository.deleteItem(providerId, postId);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() {
|
||||
return _repository.clear();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
@@ -11,6 +14,7 @@ import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import '../../settings/presentation/settings_controller.dart';
|
||||
|
||||
final artistPostsProvider =
|
||||
FutureProvider.family<List<Post>, ArtistWorkQuery>((ref, query) async {
|
||||
@@ -230,6 +234,9 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
separatorBuilder: (_, __) => const Divider(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final a = _announcements[index];
|
||||
final cleanContent = CloudLinkExtractor.cleanCommentary(a.content);
|
||||
final detectedLinks = CloudLinkExtractor.extractLinks(content: a.content);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -244,9 +251,35 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SelectableText(
|
||||
a.content,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
cleanContent.isNotEmpty ? cleanContent : a.content,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
if (detectedLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: detectedLinks.map((link) {
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
link.iconData,
|
||||
size: 16,
|
||||
color: link.brandColor,
|
||||
),
|
||||
label: Text(
|
||||
link.title.isNotEmpty ? link.title : link.serviceName,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse(link.url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -371,6 +404,54 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleArtistFavorite(
|
||||
AppSettings settings,
|
||||
bool isCurrentlyFav,
|
||||
) async {
|
||||
final current = List<String>.from(settings.favoriteArtists);
|
||||
final artistKey =
|
||||
'${widget.providerId}:${widget.service}:${widget.artistId}';
|
||||
if (isCurrentlyFav) {
|
||||
current.removeWhere((e) {
|
||||
try {
|
||||
return FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>)
|
||||
.key ==
|
||||
artistKey;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
final avatarUrl = widget.providerId == 'pawchive'
|
||||
? 'https://pawchive.pw/icons/${widget.service}/${widget.artistId}'
|
||||
: null;
|
||||
final item = FavoriteArtistItem(
|
||||
id: widget.artistId,
|
||||
service: widget.service,
|
||||
providerId: widget.providerId,
|
||||
name: widget.artistName,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
current.insert(0, jsonEncode(item.toJson()));
|
||||
}
|
||||
await ref.read(settingsControllerProvider.notifier).saveSettings(
|
||||
settings.copyWith(favoriteArtists: current),
|
||||
);
|
||||
|
||||
if (widget.providerId == 'pawchive') {
|
||||
final activeAcc = settings.activePawchiveAccount;
|
||||
if (activeAcc != null) {
|
||||
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
|
||||
account: activeAcc,
|
||||
service: widget.service,
|
||||
artistId: widget.artistId,
|
||||
isFavorite: !isCurrentlyFav,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings =
|
||||
@@ -378,12 +459,33 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
|
||||
final artistKey =
|
||||
'${widget.providerId}:${widget.service}:${widget.artistId}';
|
||||
final isArtistFavorite = settings.favoriteArtists.any((e) {
|
||||
try {
|
||||
return FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>)
|
||||
.key ==
|
||||
artistKey;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
final displayedPosts =
|
||||
_posts.where(_matchesTypeFilter).toList(growable: false);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: widget.artistName,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: isArtistFavorite ? 'В избранном' : 'В избранное',
|
||||
icon: Icon(
|
||||
isArtistFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||
color: isArtistFavorite ? Colors.amber : null,
|
||||
),
|
||||
onPressed: () => _toggleArtistFavorite(settings, isArtistFavorite),
|
||||
),
|
||||
if (_announcements.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Анонсы автора',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -13,6 +14,7 @@ import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../settings/presentation/settings_controller.dart';
|
||||
import 'artist_posts_screen.dart';
|
||||
import 'widgets/pawchive_accounts_sheet.dart';
|
||||
|
||||
final artistProviderConfigsProvider =
|
||||
FutureProvider<List<ContentProviderConfig>>((ref) async {
|
||||
@@ -49,7 +51,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
ArtistProfile artist,
|
||||
) async {
|
||||
final current = List<String>.from(settings.favoriteArtists);
|
||||
final item = _FavoriteArtistItem(
|
||||
final item = FavoriteArtistItem(
|
||||
id: artist.id,
|
||||
service: artist.service,
|
||||
providerId: artist.providerId,
|
||||
@@ -60,13 +62,14 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
final index = current.indexWhere((e) {
|
||||
try {
|
||||
final parsed =
|
||||
_FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>);
|
||||
FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>);
|
||||
return parsed.key == key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (index >= 0) {
|
||||
final isAdding = index < 0;
|
||||
if (!isAdding) {
|
||||
current.removeAt(index);
|
||||
} else {
|
||||
current.insert(0, jsonEncode(item.toJson()));
|
||||
@@ -74,6 +77,19 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
await ref.read(settingsControllerProvider.notifier).saveSettings(
|
||||
settings.copyWith(favoriteArtists: current),
|
||||
);
|
||||
|
||||
// Sync with active Pawchive account if artist belongs to Pawchive
|
||||
if (artist.providerId == 'pawchive') {
|
||||
final activeAcc = settings.activePawchiveAccount;
|
||||
if (activeAcc != null) {
|
||||
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
|
||||
account: activeAcc,
|
||||
service: artist.service,
|
||||
artistId: artist.id,
|
||||
isFavorite: isAdding,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -99,19 +115,29 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
final favoriteItems = settings.favoriteArtists
|
||||
.map((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
return FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<_FavoriteArtistItem>()
|
||||
.whereType<FavoriteArtistItem>()
|
||||
.toList();
|
||||
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Artists',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Синхронизация Pawchive',
|
||||
icon: Badge(
|
||||
isLabelVisible: settings.parsedPawchiveAccounts.isNotEmpty,
|
||||
backgroundColor: Colors.green,
|
||||
smallSize: 8,
|
||||
child: const Icon(Icons.cloud_sync_rounded),
|
||||
),
|
||||
onPressed: () => PawchiveAccountsSheet.show(context),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Любимые авторы',
|
||||
icon: Badge(
|
||||
@@ -318,7 +344,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
void _showFavoriteArtistsModal(
|
||||
BuildContext context,
|
||||
AppSettings settings,
|
||||
List<_FavoriteArtistItem> favorites,
|
||||
List<FavoriteArtistItem> favorites,
|
||||
) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -341,7 +367,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
final updated = List<String>.from(settings.favoriteArtists)
|
||||
..removeWhere((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
return FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>)
|
||||
.key ==
|
||||
fav.key;
|
||||
@@ -352,6 +378,18 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(settings.copyWith(favoriteArtists: updated));
|
||||
|
||||
if (fav.providerId == 'pawchive') {
|
||||
final activeAcc = settings.activePawchiveAccount;
|
||||
if (activeAcc != null) {
|
||||
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
|
||||
account: activeAcc,
|
||||
service: fav.service,
|
||||
artistId: fav.id,
|
||||
isFavorite: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
@@ -570,49 +608,6 @@ class _ArtistCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteArtistItem {
|
||||
const _FavoriteArtistItem({
|
||||
required this.id,
|
||||
required this.service,
|
||||
required this.providerId,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
});
|
||||
|
||||
const _FavoriteArtistItem.empty()
|
||||
: id = '',
|
||||
service = '',
|
||||
providerId = '',
|
||||
name = '',
|
||||
avatarUrl = null;
|
||||
|
||||
final String id;
|
||||
final String service;
|
||||
final String providerId;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
|
||||
bool get isEmpty => id.isEmpty;
|
||||
String get key => '$providerId:$service:$id';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'service': service,
|
||||
'providerId': providerId,
|
||||
'name': name,
|
||||
'avatarUrl': avatarUrl,
|
||||
};
|
||||
|
||||
factory _FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
|
||||
_FavoriteArtistItem(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
service: (json['service'] ?? '').toString(),
|
||||
providerId: (json['providerId'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
avatarUrl: json['avatarUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _FavoriteArtistsModal extends StatefulWidget {
|
||||
const _FavoriteArtistsModal({
|
||||
required this.favorites,
|
||||
@@ -622,11 +617,11 @@ class _FavoriteArtistsModal extends StatefulWidget {
|
||||
required this.onRemoveFavorite,
|
||||
});
|
||||
|
||||
final List<_FavoriteArtistItem> favorites;
|
||||
final List<FavoriteArtistItem> favorites;
|
||||
final String? initialSelectedKey;
|
||||
final ValueChanged<_FavoriteArtistItem> onSelect;
|
||||
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
|
||||
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
|
||||
final ValueChanged<FavoriteArtistItem> onSelect;
|
||||
final ValueChanged<FavoriteArtistItem> onOpenArtist;
|
||||
final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
|
||||
|
||||
@override
|
||||
State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState();
|
||||
@@ -648,7 +643,7 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
|
||||
(e) => e.key == _selectedKey,
|
||||
orElse: () => widget.favorites.isNotEmpty
|
||||
? widget.favorites.first
|
||||
: const _FavoriteArtistItem.empty(),
|
||||
: const FavoriteArtistItem.empty(),
|
||||
);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
@@ -716,6 +711,77 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final activeAcc = settings.activePawchiveAccount;
|
||||
final accounts = settings.parsedPawchiveAccounts;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: activeAcc != null
|
||||
? scheme.primary.withValues(alpha: 0.35)
|
||||
: scheme.outlineVariant.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_sync_rounded,
|
||||
size: 20,
|
||||
color: activeAcc != null
|
||||
? scheme.primary
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
activeAcc != null
|
||||
? 'Pawchive: @${activeAcc.username}'
|
||||
: 'Pawchive не подключён',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
activeAcc != null
|
||||
? '${activeAcc.syncedArtistsCount} авторов в облаке'
|
||||
: 'Синхронизируйте избранное с сервером',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
onPressed: () => PawchiveAccountsSheet.show(context),
|
||||
child: Text(accounts.isEmpty ? 'Войти' : 'Аккаунты'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.favorites.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 36, 24, 40),
|
||||
@@ -792,11 +858,11 @@ class _FavoriteArtistsSection extends ConsumerWidget {
|
||||
required this.onRemoveFavorite,
|
||||
});
|
||||
|
||||
final List<_FavoriteArtistItem> favorites;
|
||||
final List<FavoriteArtistItem> favorites;
|
||||
final String? selectedKey;
|
||||
final ValueChanged<_FavoriteArtistItem> onSelect;
|
||||
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
|
||||
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
|
||||
final ValueChanged<FavoriteArtistItem> onSelect;
|
||||
final ValueChanged<FavoriteArtistItem> onOpenArtist;
|
||||
final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -986,7 +1052,7 @@ class _FavoriteArtistsSection extends ConsumerWidget {
|
||||
class _FavoriteArtistMediaStrip extends ConsumerWidget {
|
||||
const _FavoriteArtistMediaStrip({required this.artist});
|
||||
|
||||
final _FavoriteArtistItem artist;
|
||||
final FavoriteArtistItem artist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../app/app.dart';
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../settings/presentation/settings_controller.dart';
|
||||
|
||||
class PawchiveAccountsSheet extends ConsumerStatefulWidget {
|
||||
const PawchiveAccountsSheet({super.key});
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const PawchiveAccountsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<PawchiveAccountsSheet> createState() =>
|
||||
_PawchiveAccountsSheetState();
|
||||
}
|
||||
|
||||
class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _addTabController;
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _cookieController = TextEditingController();
|
||||
|
||||
bool _isAdding = false;
|
||||
bool _obscurePassword = true;
|
||||
String? _syncingAccountId;
|
||||
bool _syncingAll = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_addTabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_addTabController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_cookieController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLoginCredentials() async {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() => _errorMessage = 'Введите логин и пароль');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isAdding = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final syncService = ref.read(pawchiveSyncServiceProvider);
|
||||
final account = await syncService.loginWithCredentials(
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final existingAccounts = settings.parsedPawchiveAccounts.where((a) => a.id != account.id).toList();
|
||||
// Set as active
|
||||
final updatedList = [
|
||||
account.copyWith(isActive: true),
|
||||
...existingAccounts.map((a) => a.copyWith(isActive: false)),
|
||||
];
|
||||
|
||||
var newSettings = settings.copyWith(
|
||||
pawchiveAccounts:
|
||||
updatedList.map((a) => jsonEncode(a.toJson())).toList(),
|
||||
);
|
||||
|
||||
// Perform initial sync
|
||||
final syncResult = await syncService.syncAccountFavorites(
|
||||
account: account,
|
||||
settings: newSettings,
|
||||
);
|
||||
if (syncResult.isSuccess) {
|
||||
newSettings = syncResult.updatedSettings;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(newSettings);
|
||||
|
||||
_usernameController.clear();
|
||||
_passwordController.clear();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Аккаунт @${account.username} добавлен! Синхронизировано ${account.syncedArtistsCount} авторов.',
|
||||
),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _errorMessage = e.toString().replaceFirst('Exception: ', '').replaceFirst('StateError: ', ''));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isAdding = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLoginCookie() async {
|
||||
final cookie = _cookieController.text.trim();
|
||||
final username = _usernameController.text.trim();
|
||||
if (cookie.isEmpty) {
|
||||
setState(() => _errorMessage = 'Введите сессионную куку');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isAdding = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final syncService = ref.read(pawchiveSyncServiceProvider);
|
||||
final account = await syncService.loginWithSession(
|
||||
sessionCookie: cookie,
|
||||
username: username.isEmpty ? 'Pawchive User' : username,
|
||||
);
|
||||
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final existingAccounts = settings.parsedPawchiveAccounts.where((a) => a.id != account.id).toList();
|
||||
final updatedList = [
|
||||
account.copyWith(isActive: true),
|
||||
...existingAccounts.map((a) => a.copyWith(isActive: false)),
|
||||
];
|
||||
|
||||
var newSettings = settings.copyWith(
|
||||
pawchiveAccounts:
|
||||
updatedList.map((a) => jsonEncode(a.toJson())).toList(),
|
||||
);
|
||||
|
||||
final syncResult = await syncService.syncAccountFavorites(
|
||||
account: account,
|
||||
settings: newSettings,
|
||||
);
|
||||
if (syncResult.isSuccess) {
|
||||
newSettings = syncResult.updatedSettings;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(newSettings);
|
||||
|
||||
_cookieController.clear();
|
||||
_usernameController.clear();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Кука сохранена! Синхронизировано ${account.syncedArtistsCount} авторов.',
|
||||
),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _errorMessage = e.toString().replaceFirst('Exception: ', '').replaceFirst('StateError: ', ''));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isAdding = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncSingleAccount(PawchiveAccount account) async {
|
||||
setState(() => _syncingAccountId = account.id);
|
||||
try {
|
||||
final syncService = ref.read(pawchiveSyncServiceProvider);
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final result = await syncService.syncAccountFavorites(
|
||||
account: account,
|
||||
settings: settings,
|
||||
);
|
||||
|
||||
if (result.isSuccess) {
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(result.updatedSettings);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Синхронизировано: ${result.totalSyncedCount} авторов (+${result.newlyAddedToLocal} новых)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка синхронизации: ${result.errorMessage}'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _syncingAccountId = null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAllAccounts() async {
|
||||
setState(() => _syncingAll = true);
|
||||
try {
|
||||
final syncService = ref.read(pawchiveSyncServiceProvider);
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final result = await syncService.syncAllAccounts(settings: settings);
|
||||
|
||||
if (result.isSuccess) {
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(result.updatedSettings);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Все аккаунты синхронизированы! Всего: ${result.totalSyncedCount} (+${result.newlyAddedToLocal} новых)',
|
||||
),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка синхронизации: ${result.errorMessage}'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _syncingAll = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setActiveAccount(PawchiveAccount target) async {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final updatedList = settings.parsedPawchiveAccounts.map((a) {
|
||||
return a.copyWith(isActive: a.id == target.id);
|
||||
}).toList();
|
||||
|
||||
await ref.read(settingsControllerProvider.notifier).saveSettings(
|
||||
settings.copyWith(
|
||||
pawchiveAccounts:
|
||||
updatedList.map((a) => jsonEncode(a.toJson())).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _removeAccount(PawchiveAccount target) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Удалить аккаунт Pawchive?'),
|
||||
content: Text(
|
||||
'Вы уверены, что хотите удалить аккаунт @${target.username}? Локальные авторы останутся в приложении.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final filtered =
|
||||
settings.parsedPawchiveAccounts.where((a) => a.id != target.id).toList();
|
||||
|
||||
// If active was removed, make first remaining active
|
||||
if (filtered.isNotEmpty && !filtered.any((a) => a.isActive)) {
|
||||
filtered[0] = filtered[0].copyWith(isActive: true);
|
||||
}
|
||||
|
||||
await ref.read(settingsControllerProvider.notifier).saveSettings(
|
||||
settings.copyWith(
|
||||
pawchiveAccounts:
|
||||
filtered.map((a) => jsonEncode(a.toJson())).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final accounts = settings.parsedPawchiveAccounts;
|
||||
|
||||
final keyboardPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: accounts.isEmpty ? 0.8 : 0.75,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 10, bottom: 6),
|
||||
width: 44,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.cloud_sync_rounded,
|
||||
color: scheme.onPrimaryContainer,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Аккаунты Pawchive',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Синхронизация авторов и избранного',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (accounts.isNotEmpty)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _syncingAll ? null : _syncAllAccounts,
|
||||
icon: _syncingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync_rounded, size: 18),
|
||||
label: const Text('Синхронизировать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Body
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + keyboardPadding),
|
||||
children: [
|
||||
// Connected accounts section
|
||||
if (accounts.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8, left: 4),
|
||||
child: Text(
|
||||
'ПОДКЛЮЧЁННЫЕ АККАУНТЫ (${accounts.length})',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
...accounts.map((acc) => _buildAccountCard(acc, scheme, theme)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Add account section
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8, left: 4),
|
||||
child: Text(
|
||||
accounts.isEmpty ? 'ДОБАВИТЬ АККАУНТ' : 'ДОБАВИТЬ ЕЩЁ АККАУНТ',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: scheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _addTabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Логин и пароль'),
|
||||
Tab(text: 'Session Cookie'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_errorMessage != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: 18,
|
||||
color: scheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: scheme.onErrorContainer,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
SizedBox(
|
||||
height: 190,
|
||||
child: TabBarView(
|
||||
controller: _addTabController,
|
||||
children: [
|
||||
// Tab 1: Username & Password
|
||||
Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Имя пользователя',
|
||||
prefixIcon: Icon(Icons.person_outline_rounded),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Пароль',
|
||||
prefixIcon: const Icon(Icons.lock_outline_rounded),
|
||||
isDense: true,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _isAdding ? null : _handleLoginCredentials,
|
||||
icon: _isAdding
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.login_rounded),
|
||||
label: const Text('Войти и синхронизировать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Tab 2: Session Cookie
|
||||
Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Никнейм (необязательно)',
|
||||
prefixIcon: Icon(Icons.badge_outlined),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _cookieController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Сессионная кука',
|
||||
hintText: 'session=ey... или значение куки',
|
||||
prefixIcon: Icon(Icons.cookie_outlined),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _isAdding ? null : _handleLoginCookie,
|
||||
icon: _isAdding
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.check_circle_outline_rounded),
|
||||
label: const Text('Сохранить куку'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountCard(PawchiveAccount acc, ColorScheme scheme, ThemeData theme) {
|
||||
final isSyncing = _syncingAccountId == acc.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 0,
|
||||
color: acc.isActive
|
||||
? scheme.primaryContainer.withValues(alpha: 0.35)
|
||||
: scheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: acc.isActive
|
||||
? scheme.primary.withValues(alpha: 0.6)
|
||||
: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||
width: acc.isActive ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: acc.isActive ? scheme.primary : scheme.surfaceContainerHighest,
|
||||
foregroundColor: acc.isActive ? scheme.onPrimary : scheme.onSurfaceVariant,
|
||||
child: Text(
|
||||
acc.username.isNotEmpty ? acc.username[0].toUpperCase() : 'P',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
acc.username,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (acc.isActive)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'Активен',
|
||||
style: TextStyle(
|
||||
color: scheme.onPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Авторов: ${acc.syncedArtistsCount} • ${acc.lastSyncedAt != null ? _formatDate(acc.lastSyncedAt!) : "не синхр."}',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: isSyncing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync_rounded),
|
||||
tooltip: 'Синхронизировать этот аккаунт',
|
||||
onPressed: isSyncing ? null : () => _syncSingleAccount(acc),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
onSelected: (val) {
|
||||
if (val == 'active') {
|
||||
_setActiveAccount(acc);
|
||||
} else if (val == 'delete') {
|
||||
_removeAccount(acc);
|
||||
}
|
||||
},
|
||||
itemBuilder: (ctx) => [
|
||||
if (!acc.isActive)
|
||||
const PopupMenuItem(
|
||||
value: 'active',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Сделать активным'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline_rounded, color: Colors.red, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Удалить аккаунт', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
if (now.difference(dt).inMinutes < 1) return 'только что';
|
||||
if (now.difference(dt).inHours < 1) return '${now.difference(dt).inMinutes} мин назад';
|
||||
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import 'collection_form_dialog.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
class CollectionDetailsScreen extends ConsumerWidget {
|
||||
@@ -19,60 +20,181 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final posts = ref.watch(collectionPostsProvider(collectionId));
|
||||
final postsAsync = ref.watch(collectionPostsProvider(collectionId));
|
||||
final collections = ref.watch(collectionsControllerProvider).valueOrNull;
|
||||
final title = collections
|
||||
?.where((collection) => collection.id == collectionId)
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
'Collection';
|
||||
final collection = collections
|
||||
?.where((c) => c.id == collectionId)
|
||||
.firstOrNull;
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: title,
|
||||
body: posts.when(
|
||||
title: collection?.name ?? (isRu ? 'Коллекция' : 'Collection'),
|
||||
actions: [
|
||||
if (collection != null) ...[
|
||||
IconButton(
|
||||
tooltip: isRu ? 'Редактировать' : 'Edit',
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
onPressed: () => showCollectionFormDialog(
|
||||
context,
|
||||
ref,
|
||||
collection: collection,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: isRu ? 'Удалить' : 'Delete',
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||
message: isRu
|
||||
? 'Удалить подборку "${collection.name}"?'
|
||||
: 'Remove "${collection.name}"?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.delete(collection.id);
|
||||
if (context.mounted) context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
body: postsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(title: 'Collection is empty')
|
||||
: PostMasonryGrid(
|
||||
posts: items,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isRu ? 'В коллекции пока нет постов' : 'Collection is empty',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isRu
|
||||
? 'Добавляйте посты из ленты через меню «Добавить в коллекцию» на карточке поста'
|
||||
: 'Add posts to this collection using "Add to collection" from post cards in the feed',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.go('/feed'),
|
||||
icon: const Icon(Icons.explore_rounded),
|
||||
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||||
),
|
||||
],
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (collection?.description != null &&
|
||||
collection!.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
collection.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
},
|
||||
Expanded(
|
||||
child: PostMasonryGrid(
|
||||
posts: items,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import 'collections_controller.dart';
|
||||
|
||||
@@ -9,56 +10,117 @@ Future<void> showCollectionFormDialog(
|
||||
WidgetRef ref, {
|
||||
Collection? collection,
|
||||
}) async {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
final nameController = TextEditingController(text: collection?.name);
|
||||
final descriptionController =
|
||||
TextEditingController(text: collection?.description);
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(collection == null ? 'New collection' : 'Edit collection'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
builder: (context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(24, 16, 24, 20),
|
||||
title: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
collection == null
|
||||
? Icons.create_new_folder_rounded
|
||||
: Icons.edit_note_rounded,
|
||||
color: scheme.onPrimaryContainer,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
collection == null
|
||||
? (isRu ? 'Новая коллекция' : 'New collection')
|
||||
: (isRu ? 'Редактировать' : 'Edit collection'),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
autofocus: collection == null,
|
||||
decoration: InputDecoration(
|
||||
labelText: isRu ? 'Название' : 'Name',
|
||||
hintText: isRu ? 'Например, Избранные обои' : 'e.g. Best Wallpapers',
|
||||
prefixIcon: const Icon(Icons.bookmark_border_rounded),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: isRu ? 'Описание (необязательно)' : 'Description (optional)',
|
||||
hintText: isRu ? 'О чем эта подборка' : 'What is this collection about',
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(isRu ? 'Отмена' : 'Cancel'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
if (collection == null) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.create(name, descriptionController.text.trim());
|
||||
} else {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.updateCollection(
|
||||
collection.id,
|
||||
name: name,
|
||||
description: descriptionController.text.trim(),
|
||||
coverUrl: collection.coverUrl,
|
||||
);
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: Text(isRu ? 'Сохранить' : 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
if (collection == null) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.create(name, descriptionController.text.trim());
|
||||
} else {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.updateCollection(
|
||||
collection.id,
|
||||
name: name,
|
||||
description: descriptionController.text.trim(),
|
||||
coverUrl: collection.coverUrl,
|
||||
);
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
nameController.dispose();
|
||||
descriptionController.dispose();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../backend/models/collection.dart';
|
||||
import '../../../app/app.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'collection_form_dialog.dart';
|
||||
import 'collections_controller.dart';
|
||||
@@ -16,11 +17,15 @@ class CollectionsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final collections = ref.watch(collectionsControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Collections',
|
||||
title: isRu ? 'Коллекции' : 'Collections',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'New collection',
|
||||
tooltip: isRu ? 'Новая коллекция' : 'New collection',
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
onPressed: () => showCollectionFormDialog(context, ref),
|
||||
),
|
||||
@@ -28,141 +33,251 @@ class CollectionsScreen extends ConsumerWidget {
|
||||
body: collections.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(title: 'No collections yet')
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.35,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return _CollectionCard(
|
||||
collection: item,
|
||||
onOpen: () => context.go('/collections/${item.id}'),
|
||||
onEdit: () => showCollectionFormDialog(
|
||||
context,
|
||||
ref,
|
||||
collection: item,
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
onDelete: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Delete collection',
|
||||
message: 'Remove ${item.name}?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.delete(item.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isRu ? 'Нет коллекций' : 'No collections yet',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isRu
|
||||
? 'Создавайте тематические коллекции и объединяйте в них понравившиеся работы'
|
||||
: 'Create themed collections and organize your favorite posts',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => showCollectionFormDialog(context, ref),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(
|
||||
isRu ? 'Создать коллекцию' : 'Create collection',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisSpacing: 14,
|
||||
crossAxisSpacing: 14,
|
||||
childAspectRatio: 0.88,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return _CollectionCard(
|
||||
collection: item,
|
||||
isRu: isRu,
|
||||
onOpen: () => context.go('/collections/${item.id}'),
|
||||
onEdit: () => showCollectionFormDialog(
|
||||
context,
|
||||
ref,
|
||||
collection: item,
|
||||
),
|
||||
onDelete: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||
message: isRu
|
||||
? 'Удалить подборку "${item.name}"?'
|
||||
: 'Remove "${item.name}"?',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(collectionsControllerProvider.notifier)
|
||||
.delete(item.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CollectionCard extends StatelessWidget {
|
||||
class _CollectionCard extends ConsumerWidget {
|
||||
const _CollectionCard({
|
||||
required this.collection,
|
||||
required this.isRu,
|
||||
required this.onOpen,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Collection collection;
|
||||
final bool isRu;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final postsAsync = ref.watch(collectionPostsProvider(collection.id));
|
||||
final posts = postsAsync.value ?? const <Post>[];
|
||||
final previewUrls = posts
|
||||
.map((p) => MediaUrlSelector.preview(p).firstOrNull)
|
||||
.whereType<String>()
|
||||
.take(4)
|
||||
.toList();
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: scheme.surfaceContainerLow,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
child: Stack(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 54,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
scheme.primaryContainer.withValues(alpha: 0.56),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// Cover Collage (2x2 or Hero)
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCoverMosaic(previewUrls, scheme),
|
||||
// Glass count badge
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 28,
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${posts.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
collection.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
collection.description ?? 'No description',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: PopupMenuButton<String>(
|
||||
tooltip: 'Collection actions',
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') onEdit();
|
||||
if (value == 'delete') onDelete();
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.edit_rounded),
|
||||
title: Text('Edit'),
|
||||
// Info Row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
collection.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (collection.description != null &&
|
||||
collection.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
collection.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.delete_rounded),
|
||||
title: Text('Delete'),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: isRu ? 'Действия' : 'Actions',
|
||||
icon: Icon(
|
||||
Icons.more_vert_rounded,
|
||||
size: 20,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') onEdit();
|
||||
if (value == 'delete') onDelete();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.edit_rounded, size: 20),
|
||||
title: Text(isRu ? 'Редактировать' : 'Edit'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.delete_rounded, size: 20),
|
||||
title: Text(isRu ? 'Удалить' : 'Delete'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -172,4 +287,63 @@ class _CollectionCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverMosaic(List<String> urls, ColorScheme scheme) {
|
||||
if (urls.isEmpty) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
scheme.primaryContainer.withValues(alpha: 0.6),
|
||||
scheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 40,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (urls.length == 1) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: urls[0],
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_rounded),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mosaic grid (up to 4)
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
for (int i = 0; i < 4; i++)
|
||||
if (i < urls.length)
|
||||
CachedNetworkImage(
|
||||
imageUrl: urls[i],
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) =>
|
||||
Container(color: scheme.surfaceContainerHighest),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.image_not_supported_rounded, size: 16),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(color: scheme.surfaceContainerHighest),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -802,15 +802,22 @@ class _SearchPresetsBar extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
child: FilterChip(
|
||||
avatar: Icon(
|
||||
active ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
||||
size: 15,
|
||||
color: active ? scheme.primary : scheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(name),
|
||||
selected: active,
|
||||
onSelected: (_) => onApply(raw),
|
||||
selectedColor: scheme.primaryContainer,
|
||||
selectedColor: scheme.primaryContainer.withValues(alpha: 0.8),
|
||||
checkmarkColor: scheme.onPrimaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
|
||||
fontWeight:
|
||||
active ? FontWeight.w600 : FontWeight.normal,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -77,6 +77,8 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
}
|
||||
|
||||
Widget _buildDesktop(BuildContext context) {
|
||||
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
@@ -96,38 +98,54 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
_query = value;
|
||||
widget.onSuggestionTap(value);
|
||||
},
|
||||
onTagRemoved: (value) {
|
||||
_query = value;
|
||||
widget.onSearch(value);
|
||||
},
|
||||
onCleared: () {
|
||||
_query = '';
|
||||
widget.onClearFilters();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
const SizedBox(width: 4),
|
||||
Badge(
|
||||
isLabelVisible: hasActiveFilters,
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'Провайдеры',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
tooltip: 'Рейтинг',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
tooltip: 'Очистить фильтры',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
tooltip: 'Обновить',
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
tooltip: 'Случайный пост',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
),
|
||||
@@ -154,7 +172,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All providers'),
|
||||
label: const Text('Все источники'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -162,14 +180,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
FilterChip(
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
avatar: const Icon(Icons.hub_rounded, size: 16),
|
||||
avatar: const Icon(Icons.hub_rounded, size: 15),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
@@ -188,25 +208,70 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
}
|
||||
|
||||
Widget _buildMobile(BuildContext context) {
|
||||
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
TagInputSearchBar(
|
||||
initialValue: _query,
|
||||
suggestions: widget.tagSuggestions,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
onSuggestionApplied: (value) {
|
||||
_query = value;
|
||||
widget.onSuggestionTap(value);
|
||||
},
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: _query,
|
||||
suggestions: widget.tagSuggestions,
|
||||
onChanged: (value) {
|
||||
_query = value;
|
||||
widget.onSearchChanged(value);
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
onSuggestionApplied: (value) {
|
||||
_query = value;
|
||||
widget.onSuggestionTap(value);
|
||||
},
|
||||
onTagRemoved: (value) {
|
||||
_query = value;
|
||||
widget.onSearch(value);
|
||||
},
|
||||
onCleared: () {
|
||||
_query = '';
|
||||
widget.onClearFilters();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Badge(
|
||||
isLabelVisible: hasActiveFilters,
|
||||
child: IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Фильтры',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Случайный пост',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded, size: 20),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(
|
||||
widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.providerStatusMessage != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InputChip(
|
||||
@@ -214,55 +279,54 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
label: Text(widget.providerStatusMessage!),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
const SizedBox(height: 7),
|
||||
_HorizontalWheelScroller(
|
||||
height: 40,
|
||||
height: 38,
|
||||
builder: (controller) => ListView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
primary: false,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip:
|
||||
widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Rating',
|
||||
onPressed: widget.onRatingFilter,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: 'Clear filters',
|
||||
onPressed: widget.onClearFilters,
|
||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
|
||||
ActionChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
|
||||
label: const Text('Сбросить'),
|
||||
onPressed: widget.onClearFilters,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
ActionChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.refresh_rounded, size: 15),
|
||||
label: const Text('Обновить'),
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Random post',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
const SizedBox(width: 6),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.rating != null,
|
||||
avatar: const Icon(Icons.shield_outlined, size: 15),
|
||||
label: Text(widget.rating ?? 'Рейтинг'),
|
||||
onSelected: (_) => widget.onRatingFilter(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 6),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('All'),
|
||||
label: const Text('Все'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
label: Text(provider.name),
|
||||
@@ -271,20 +335,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
const SizedBox(width: 2),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_HorizontalWheelScroller(
|
||||
height: 36,
|
||||
builder: (controller) => ListView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
primary: false,
|
||||
children: [
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
@@ -300,6 +357,20 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolbarDivider extends StatelessWidget {
|
||||
const _ToolbarDivider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HorizontalWheelScroller extends StatefulWidget {
|
||||
const _HorizontalWheelScroller({
|
||||
required this.builder,
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../../favorites/presentation/favorites_controller.dart';
|
||||
import '../../feed/presentation/feed_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';
|
||||
@@ -94,6 +95,21 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
: null;
|
||||
final qualityMode =
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode);
|
||||
final localMedia = ref
|
||||
.watch(downloadedMediaByKeyProvider(post.cacheKey))
|
||||
.value;
|
||||
final fileSizeBytes = localMedia != null
|
||||
? DownloadedMediaService.getFileSizeSync(localMedia)
|
||||
: null;
|
||||
final isDownloading = ref.watch(downloadTasksProvider).value?.any(
|
||||
(t) =>
|
||||
(t.post?.cacheKey == post.cacheKey ||
|
||||
t.id == post.cacheKey) &&
|
||||
(t.status == DownloadTaskStatus.running ||
|
||||
t.status == DownloadTaskStatus.queued),
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (Responsive.isMobile(context)) {
|
||||
if (currentIndex >= 0 && feedPosts.length > 1) {
|
||||
return _MobilePostPager(
|
||||
@@ -111,6 +127,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
strings,
|
||||
mediaGestureLocked,
|
||||
onMediaGestureLockChanged,
|
||||
feedPosts,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -124,6 +141,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
strings,
|
||||
false,
|
||||
null,
|
||||
feedPosts,
|
||||
);
|
||||
}
|
||||
return Shortcuts(
|
||||
@@ -207,6 +225,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
localFilePath: localMedia?.savedPath,
|
||||
qualityMode: qualityMode,
|
||||
mediaHeaders: ref
|
||||
.watch(postMediaHeadersProvider(post))
|
||||
@@ -251,11 +270,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
PostActionBar(
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
labels: _postActionLabels(strings),
|
||||
downloaded: ref
|
||||
.watch(
|
||||
downloadedMediaByKeyProvider(post.cacheKey))
|
||||
.value !=
|
||||
null,
|
||||
downloaded: localMedia != null,
|
||||
isDownloading: isDownloading,
|
||||
onFavorite: () =>
|
||||
_toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
@@ -273,26 +289,69 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
onShare: () => _sharePost(context, ref, post),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Chip(label: Text(post.providerName)),
|
||||
RatingBadge(rating: post.rating),
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: Text(strings.source),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
_PostInfoCard(
|
||||
post: post,
|
||||
strings: strings,
|
||||
localMedia: localMedia,
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
),
|
||||
if (post.cloudLinks.isNotEmpty ||
|
||||
(post.commentary != null &&
|
||||
post.commentary!.trim().isNotEmpty)) ...[
|
||||
const SizedBox(height: 16),
|
||||
CloudMirrorsCard(
|
||||
links: post.cloudLinks,
|
||||
strings: strings,
|
||||
commentary: 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),
|
||||
Text(strings.tags,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
PostTagsPanel(post: post),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tag_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${strings.tags} (${post.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),
|
||||
],
|
||||
@@ -314,9 +373,24 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
MediaQualityMode qualityMode,
|
||||
AppStrings strings,
|
||||
bool mediaGestureLocked,
|
||||
ValueChanged<bool>? onMediaGestureLockChanged,
|
||||
) {
|
||||
ValueChanged<bool>? onMediaGestureLockChanged, [
|
||||
List<Post>? feedPosts,
|
||||
]) {
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
final localMedia =
|
||||
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value;
|
||||
final fileSizeBytes = localMedia != null
|
||||
? DownloadedMediaService.getFileSizeSync(localMedia)
|
||||
: null;
|
||||
final isDownloading = ref.watch(downloadTasksProvider).value?.any(
|
||||
(t) =>
|
||||
(t.post?.cacheKey == post.cacheKey ||
|
||||
t.id == post.cacheKey) &&
|
||||
(t.status == DownloadTaskStatus.running ||
|
||||
t.status == DownloadTaskStatus.queued),
|
||||
) ??
|
||||
false;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onVerticalDragEnd: (details) {
|
||||
@@ -340,6 +414,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
localFilePath: localMedia?.savedPath,
|
||||
qualityMode: qualityMode,
|
||||
mediaHeaders:
|
||||
ref.watch(postMediaHeadersProvider(post)).value ?? const {},
|
||||
@@ -365,9 +440,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
PostActionBar(
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
labels: _postActionLabels(strings),
|
||||
downloaded:
|
||||
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value !=
|
||||
null,
|
||||
downloaded: localMedia != null,
|
||||
isDownloading: isDownloading,
|
||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
@@ -382,33 +456,79 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
onShare: () => _sharePost(context, ref, post),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text(post.providerName)),
|
||||
RatingBadge(rating: post.rating),
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: Text(strings.source),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
_PostInfoCard(
|
||||
post: post,
|
||||
strings: strings,
|
||||
localMedia: localMedia,
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
title: Text(strings.tags,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: PostTagsPanel(post: post),
|
||||
if (post.cloudLinks.isNotEmpty ||
|
||||
(post.commentary != null &&
|
||||
post.commentary!.trim().isNotEmpty)) ...[
|
||||
const SizedBox(height: 12),
|
||||
CloudMirrorsCard(
|
||||
links: post.cloudLinks,
|
||||
strings: strings,
|
||||
commentary: 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).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ExpansionTile(
|
||||
tilePadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
initiallyExpanded: false,
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
leading: Icon(
|
||||
Icons.tag_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
'${strings.tags} (${post.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),
|
||||
],
|
||||
),
|
||||
@@ -451,6 +571,16 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _downloadUrl(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
String targetUrl,
|
||||
) async {
|
||||
final customPost = post.copyWith(fileUrl: targetUrl, sampleUrl: targetUrl);
|
||||
await _download(context, ref, customPost);
|
||||
}
|
||||
|
||||
Future<void> _sharePost(BuildContext context, WidgetRef ref, Post post) async {
|
||||
final strings = ref.read(appStringsProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -939,53 +1069,446 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
),
|
||||
)
|
||||
: null;
|
||||
return ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
onExpansionChanged: (value) => setState(() => _expanded = value),
|
||||
title: Text(strings.comments,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
children: [
|
||||
(comments ?? const AsyncValue<List<PostComment>>.data([])).when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (_, __) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(strings.commentsUnavailable),
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(strings.noComments),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final comment in items)
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
comment.authorName.isEmpty
|
||||
? strings.anonymous
|
||||
: comment.authorName,
|
||||
),
|
||||
subtitle: Text(comment.body),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
initiallyExpanded: false,
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
leading: Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
color: scheme.primary,
|
||||
),
|
||||
onExpansionChanged: (value) => setState(() => _expanded = value),
|
||||
title: Text(
|
||||
strings.comments,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
(comments ?? const AsyncValue<List<PostComment>>.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 = <String, (int, int)>{};
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../../app/app_strings.dart';
|
||||
import '../../../../backend/models/cloud_media_link.dart';
|
||||
|
||||
class CloudMirrorsCard extends StatelessWidget {
|
||||
const CloudMirrorsCard({
|
||||
super.key,
|
||||
required this.links,
|
||||
required this.strings,
|
||||
this.commentary,
|
||||
this.onPlayStream,
|
||||
this.onDownloadStream,
|
||||
this.initiallyExpanded = false,
|
||||
});
|
||||
|
||||
final List<CloudMediaLink> links;
|
||||
final AppStrings strings;
|
||||
final String? commentary;
|
||||
final ValueChanged<String>? onPlayStream;
|
||||
final ValueChanged<String>? onDownloadStream;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (links.isEmpty && (commentary == null || commentary!.trim().isEmpty)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final detectedPassword = links
|
||||
.map((l) => l.detectedPassword)
|
||||
.whereType<String>()
|
||||
.firstWhere((p) => p.isNotEmpty, orElse: () => '');
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
leading: Icon(
|
||||
Icons.cloud_sync_rounded,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.ru
|
||||
? 'Облачные диски и зеркала (${links.length})'
|
||||
: 'Cloud Mirrors & Drives (${links.length})',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (links.any((l) => l.isStreamable)) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.play_circle_fill_rounded,
|
||||
size: 13, color: Colors.green),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
strings.ru ? 'Плеер' : 'Stream',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
children: [
|
||||
// Archive Password Banner
|
||||
if (detectedPassword.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.key_rounded, size: 16, color: Colors.amber),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: strings.ru ? 'Пароль к архиву: ' : 'Archive Password: ',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: detectedPassword,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: strings.ru ? 'Копировать пароль' : 'Copy Password',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: detectedPassword));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
strings.ru
|
||||
? 'Пароль скопирован: $detectedPassword'
|
||||
: 'Password copied: $detectedPassword',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
|
||||
// Links list
|
||||
if (links.isNotEmpty) ...[
|
||||
...links.map((link) => _buildLinkTile(context, link, strings)),
|
||||
],
|
||||
|
||||
// Commentary / Description
|
||||
if (commentary != null && commentary!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Divider(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
height: 1,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notes_rounded,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
strings.ru ? 'Описание от автора' : 'Artist Commentary',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
commentary!.trim(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
height: 1.45,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkTile(
|
||||
BuildContext context,
|
||||
CloudMediaLink link,
|
||||
AppStrings strings,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final brandColor = link.brandColor;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: brandColor.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: brandColor.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: brandColor.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Icon(link.iconData, size: 20, color: brandColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
link.serviceName,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (link.isFolder)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
strings.ru ? 'Папка' : 'Folder',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
link.title,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Action buttons row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Play button (if streamable)
|
||||
if (link.isStreamable && onPlayStream != null) ...[
|
||||
FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 16),
|
||||
label: Text(strings.ru ? 'Смотреть' : 'Play'),
|
||||
onPressed: () {
|
||||
final target = link.directStreamUrl ?? link.url;
|
||||
onPlayStream!(target);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// Download button (if direct streamable and callback exists)
|
||||
if (link.isStreamable &&
|
||||
link.directStreamUrl != null &&
|
||||
onDownloadStream != null) ...[
|
||||
IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: strings.download,
|
||||
icon: const Icon(Icons.download_rounded, size: 16),
|
||||
onPressed: () => onDownloadStream!(link.directStreamUrl!),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// Copy link button
|
||||
IconButton.outlined(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: strings.copyLink,
|
||||
icon: const Icon(Icons.copy_rounded, size: 16),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: link.url));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
strings.ru
|
||||
? 'Ссылка скопирована: ${link.serviceName}'
|
||||
: 'Link copied: ${link.serviceName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Open in browser / app button
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: brandColor,
|
||||
foregroundColor: Colors.white,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
icon: const Icon(Icons.open_in_new_rounded, size: 15),
|
||||
label: Text(strings.ru ? 'Открыть' : 'Open'),
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse(link.url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class PostActionBar extends StatelessWidget {
|
||||
this.onDeleteLocalFile,
|
||||
this.onShare,
|
||||
this.downloaded = false,
|
||||
this.isDownloading = false,
|
||||
this.labels,
|
||||
this.onSimilar,
|
||||
this.onHide,
|
||||
@@ -30,76 +31,268 @@ class PostActionBar extends StatelessWidget {
|
||||
final VoidCallback? onSimilar;
|
||||
final VoidCallback? onHide;
|
||||
final bool downloaded;
|
||||
final bool isDownloading;
|
||||
final PostActionLabels? labels;
|
||||
|
||||
void _showMoreActions(BuildContext context, PostActionLabels text) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Copy Link
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.copy_rounded, size: 20),
|
||||
),
|
||||
title: Text(text.copyLink),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onCopy();
|
||||
},
|
||||
),
|
||||
// Open Original in Browser
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.open_in_new_rounded, size: 20),
|
||||
),
|
||||
title: Text(text.openOriginal),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onOpen();
|
||||
},
|
||||
),
|
||||
// Open Source Page
|
||||
if (onOpenSource != null)
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.article_rounded, size: 20),
|
||||
),
|
||||
title: Text(text.openSource),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onOpenSource!();
|
||||
},
|
||||
),
|
||||
// Find Similar
|
||||
if (onSimilar != null)
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(Icons.auto_awesome_rounded,
|
||||
size: 20, color: scheme.primary),
|
||||
),
|
||||
title: Text(text.similar),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onSimilar!();
|
||||
},
|
||||
),
|
||||
const Divider(height: 16),
|
||||
// Delete Local File (if downloaded)
|
||||
if (downloaded && onDeleteLocalFile != null)
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(Icons.delete_outline_rounded,
|
||||
size: 20, color: scheme.error),
|
||||
),
|
||||
title: Text(
|
||||
text.deleteLocalFile,
|
||||
style: TextStyle(color: scheme.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onDeleteLocalFile!();
|
||||
},
|
||||
),
|
||||
// Hide Post
|
||||
if (onHide != null)
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.visibility_off_rounded, size: 20),
|
||||
),
|
||||
title: Text(text.hidePost),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onHide!();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final text = labels ?? const PostActionLabels();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
),
|
||||
label: Text(isFavorite ? text.unfavorite : text.favorite),
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCollection,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(text.collection),
|
||||
),
|
||||
if (onSimilar != null)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onSimilar,
|
||||
icon: const Icon(Icons.auto_awesome_rounded),
|
||||
label: Text(text.similar),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.openOriginal,
|
||||
onPressed: onOpen,
|
||||
icon: const Icon(Icons.open_in_new_rounded),
|
||||
),
|
||||
if (onOpenSource != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.openSource,
|
||||
onPressed: onOpenSource,
|
||||
icon: const Icon(Icons.article_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.copyLink,
|
||||
onPressed: onCopy,
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
),
|
||||
if (onDownload != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.download,
|
||||
onPressed: onDownload,
|
||||
icon: Icon(
|
||||
downloaded ? Icons.download_done_rounded : Icons.download_rounded,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Favorite / Like Button
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: isFavorite
|
||||
? Colors.redAccent.withValues(alpha: 0.18)
|
||||
: scheme.secondaryContainer.withValues(alpha: 0.5),
|
||||
foregroundColor:
|
||||
isFavorite ? Colors.redAccent : scheme.onSecondaryContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(
|
||||
isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
size: 20,
|
||||
color: isFavorite ? Colors.redAccent : null,
|
||||
),
|
||||
label: Text(
|
||||
isFavorite ? text.unfavorite : text.favorite,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (downloaded && onDeleteLocalFile != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.deleteLocalFile,
|
||||
onPressed: onDeleteLocalFile,
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Download Button with Status
|
||||
if (onDownload != null) ...[
|
||||
_ActionButton(
|
||||
tooltip: downloaded ? text.downloaded : text.download,
|
||||
icon: isDownloading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
downloaded
|
||||
? Icons.offline_pin_rounded
|
||||
: Icons.download_rounded,
|
||||
color: downloaded ? Colors.green : null,
|
||||
size: 22,
|
||||
),
|
||||
onTap: onDownload!,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
|
||||
// Add to Collection
|
||||
_ActionButton(
|
||||
tooltip: text.collection,
|
||||
icon: const Icon(Icons.bookmark_add_rounded, size: 22),
|
||||
onTap: onCollection,
|
||||
),
|
||||
if (onHide != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.hidePost,
|
||||
onPressed: onHide,
|
||||
icon: const Icon(Icons.visibility_off_rounded),
|
||||
const SizedBox(width: 6),
|
||||
|
||||
// Share
|
||||
if (onShare != null) ...[
|
||||
_ActionButton(
|
||||
tooltip: text.share,
|
||||
icon: const Icon(Icons.share_rounded, size: 22),
|
||||
onTap: onShare!,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
|
||||
// More Options Bottom Sheet
|
||||
_ActionButton(
|
||||
tooltip: text.more,
|
||||
icon: const Icon(Icons.more_horiz_rounded, size: 22),
|
||||
onTap: () => _showMoreActions(context, text),
|
||||
),
|
||||
if (onShare != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: text.share,
|
||||
onPressed: onShare,
|
||||
icon: const Icon(Icons.share_rounded),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
const _ActionButton({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final Widget icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onTap,
|
||||
icon: icon,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.35),
|
||||
foregroundColor: scheme.onSecondaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
minimumSize: const Size(44, 44),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,16 +300,18 @@ class PostActionBar extends StatelessWidget {
|
||||
class PostActionLabels {
|
||||
const PostActionLabels({
|
||||
this.favorite = 'Favorite',
|
||||
this.unfavorite = 'Unfavorite',
|
||||
this.unfavorite = 'Saved',
|
||||
this.collection = 'Collection',
|
||||
this.similar = 'Similar',
|
||||
this.openOriginal = 'Open original',
|
||||
this.openSource = 'Open source/page',
|
||||
this.copyLink = 'Copy link',
|
||||
this.download = 'Download',
|
||||
this.downloaded = 'Downloaded',
|
||||
this.deleteLocalFile = 'Delete local file',
|
||||
this.hidePost = 'Hide post locally',
|
||||
this.share = 'Share',
|
||||
this.more = 'More',
|
||||
});
|
||||
|
||||
final String favorite;
|
||||
@@ -127,7 +322,9 @@ class PostActionLabels {
|
||||
final String openSource;
|
||||
final String copyLink;
|
||||
final String download;
|
||||
final String downloaded;
|
||||
final String deleteLocalFile;
|
||||
final String hidePost;
|
||||
final String share;
|
||||
final String more;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -219,6 +219,14 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
static const _ignoredTagGroupKeys = {
|
||||
'cloud_links',
|
||||
'description',
|
||||
'content',
|
||||
'links',
|
||||
'external_links',
|
||||
};
|
||||
|
||||
Map<String, List<String>> _groups(Post post) {
|
||||
if (post.tagGroups.isNotEmpty) {
|
||||
final ordered = <String, List<String>>{};
|
||||
@@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
|
||||
'meta',
|
||||
'general',
|
||||
]) {
|
||||
final tags = post.tagGroups[key];
|
||||
if (tags != null && tags.isNotEmpty) ordered[key] = tags;
|
||||
final tags = _filterValidTags(post.tagGroups[key]);
|
||||
if (tags.isNotEmpty) ordered[key] = tags;
|
||||
}
|
||||
for (final entry in post.tagGroups.entries) {
|
||||
if (entry.value.isNotEmpty) {
|
||||
ordered.putIfAbsent(entry.key, () => entry.value);
|
||||
if (_ignoredTagGroupKeys.contains(entry.key.toLowerCase())) continue;
|
||||
final tags = _filterValidTags(entry.value);
|
||||
if (tags.isNotEmpty) {
|
||||
ordered.putIfAbsent(entry.key, () => tags);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
return post.tags.isEmpty ? const {} : {'general': post.tags};
|
||||
final filtered = _filterValidTags(post.tags);
|
||||
return filtered.isEmpty ? const {} : {'general': filtered};
|
||||
}
|
||||
|
||||
List<String> _filterValidTags(List<String>? rawTags) {
|
||||
if (rawTags == null || rawTags.isEmpty) return const [];
|
||||
return rawTags.where((tag) {
|
||||
final t = tag.trim();
|
||||
if (t.isEmpty) return false;
|
||||
if (t.startsWith('{') && t.endsWith('}')) return false;
|
||||
if (t.contains('"url"') || t.contains('"service"')) return false;
|
||||
if (t.startsWith('http://') || t.startsWith('https://')) return false;
|
||||
if (t.contains('mega.nz') ||
|
||||
t.contains('drive.google.com') ||
|
||||
t.contains('dropbox.com') ||
|
||||
t.contains('pixeldrain.com') ||
|
||||
t.contains('catbox.moe') ||
|
||||
t.contains('mediafire.com')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
String _label(String key) {
|
||||
|
||||
@@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
controller: _apiKey,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API key',
|
||||
helperText: 'Gelbooru: api_key, Danbooru: api_key',
|
||||
helperText:
|
||||
'Gelbooru/Rule34/Danbooru API key (Account -> Options)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
controller: _userId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'User ID',
|
||||
helperText: 'Gelbooru-compatible APIs may require user_id',
|
||||
helperText:
|
||||
'Gelbooru/Rule34 User ID (required for API access, see Options)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -74,6 +74,14 @@ class SearchController extends AsyncNotifier<SearchState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteHistory(String historyId) async {
|
||||
await ref.read(searchServiceProvider).deleteHistoryItem(historyId);
|
||||
final recent = await _recent();
|
||||
state = AsyncData(
|
||||
(state.value ?? const SearchState()).copyWith(recent: recent),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearHistory() async {
|
||||
await ref.read(searchServiceProvider).clearHistory();
|
||||
state = const AsyncData(SearchState());
|
||||
|
||||
@@ -2,58 +2,607 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_strings.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/app_search_bar.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import 'search_controller.dart';
|
||||
import 'widgets/recent_searches.dart';
|
||||
|
||||
class SearchScreen extends ConsumerWidget {
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
bool _showTips = false;
|
||||
|
||||
void _onTagTap(String tag, String currentQuery) {
|
||||
final trimmed = currentQuery.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
|
||||
} else {
|
||||
final newQuery = '$trimmed $tag';
|
||||
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||
context.go('/?q=${Uri.encodeQueryComponent(newQuery)}');
|
||||
}
|
||||
}
|
||||
|
||||
void _appendToken(String token, String currentQuery) {
|
||||
final trimmed = currentQuery.trim();
|
||||
final newQuery = trimmed.isEmpty ? token : '$trimmed $token';
|
||||
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(searchControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final strings = AppStrings(settings.languageCode);
|
||||
final isRu = strings.ru;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Search',
|
||||
title: strings.search,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear history',
|
||||
onPressed: () =>
|
||||
ref.read(searchControllerProvider.notifier).clearHistory(),
|
||||
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||
onPressed: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Очистить историю поиска?' : 'Clear search history?',
|
||||
message: isRu
|
||||
? 'Все недавние поисковые запросы будут удалены.'
|
||||
: 'All recent search queries will be removed.',
|
||||
);
|
||||
if (ok) {
|
||||
await ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.clearHistory();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
),
|
||||
],
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (data) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TagInputSearchBar(
|
||||
initialValue: data.query,
|
||||
suggestions: data.suggestions,
|
||||
onSubmitted: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onChanged: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
onSuggestionApplied: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
data: (data) => Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 860),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
children: [
|
||||
// 1. Search Bar with Quick Operators
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TagInputSearchBar(
|
||||
initialValue: data.query,
|
||||
suggestions: data.suggestions,
|
||||
onSubmitted: (query) => context
|
||||
.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onChanged: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
onSuggestionApplied: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
onTagRemoved: (query) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(query),
|
||||
onCleared: () => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.updateQuery(''),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_QuickOperatorChip(
|
||||
label: 'and',
|
||||
icon: Icons.alt_route_rounded,
|
||||
color: const Color(0xFFF59E0B),
|
||||
onTap: () => _appendToken('and', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'type:video',
|
||||
icon: Icons.videocam_rounded,
|
||||
color: const Color(0xFF3B82F6),
|
||||
onTap: () =>
|
||||
_appendToken('type:video', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'type:gif',
|
||||
icon: Icons.gif_rounded,
|
||||
color: const Color(0xFF8B5CF6),
|
||||
onTap: () => _appendToken('type:gif', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'rating:safe',
|
||||
icon: Icons.verified_user_rounded,
|
||||
color: const Color(0xFF10B981),
|
||||
onTap: () =>
|
||||
_appendToken('rating:safe', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'rating:explicit',
|
||||
icon: Icons.explicit_rounded,
|
||||
color: const Color(0xFFEF4444),
|
||||
onTap: () =>
|
||||
_appendToken('rating:explicit', data.query),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_QuickOperatorChip(
|
||||
label: 'score:>100',
|
||||
icon: Icons.star_rounded,
|
||||
color: const Color(0xFFF97316),
|
||||
onTap: () =>
|
||||
_appendToken('score:>100', data.query),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. Recent Searches Card
|
||||
_SearchSectionCard(
|
||||
icon: Icons.history_rounded,
|
||||
accentColor: const Color(0xFF6366F1),
|
||||
title: isRu ? 'Недавние запросы' : 'Recent searches',
|
||||
badgeCount: data.recent.length,
|
||||
trailing: data.recent.isEmpty
|
||||
? null
|
||||
: TextButton(
|
||||
onPressed: () => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.clearHistory(),
|
||||
child: Text(
|
||||
isRu ? 'Очистить' : 'Clear',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: RecentSearches(
|
||||
items: data.recent,
|
||||
onTap: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
onDelete: (id) => ref
|
||||
.read(searchControllerProvider.notifier)
|
||||
.deleteHistory(id),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. Explore Popular Categories & Tags
|
||||
_SearchSectionCard(
|
||||
icon: Icons.explore_rounded,
|
||||
accentColor: const Color(0xFF10B981),
|
||||
title: isRu ? 'Популярные категории' : 'Explore categories',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CategoryTagRow(
|
||||
categoryName: isRu ? 'Тематика' : 'Theme & Ambience',
|
||||
color: const Color(0xFF0EA5E9),
|
||||
tags: const [
|
||||
'scenery',
|
||||
'cyberpunk',
|
||||
'monochrome',
|
||||
'night',
|
||||
'fantasy',
|
||||
'original',
|
||||
],
|
||||
onTap: (tag) => _onTagTap(tag, data.query),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_CategoryTagRow(
|
||||
categoryName:
|
||||
isRu ? 'Персонажи и детали' : 'Characters & Attire',
|
||||
color: const Color(0xFFEC4899),
|
||||
tags: const [
|
||||
'1girl',
|
||||
'solo',
|
||||
'cat_ears',
|
||||
'short_hair',
|
||||
'long_hair',
|
||||
'maid',
|
||||
'uniform',
|
||||
],
|
||||
onTap: (tag) => _onTagTap(tag, data.query),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_CategoryTagRow(
|
||||
categoryName: isRu ? 'Провайдеры' : 'Providers',
|
||||
color: const Color(0xFF8B5CF6),
|
||||
tags: const [
|
||||
'provider:gelbooru',
|
||||
'provider:danbooru',
|
||||
'provider:e621',
|
||||
'provider:rule34',
|
||||
'provider:pawchive',
|
||||
],
|
||||
onTap: (tag) => _onTagTap(tag, data.query),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 4. Search Tips / Cheat Sheet Card
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
leading: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF59E0B).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.lightbulb_outline_rounded,
|
||||
size: 18,
|
||||
color: Color(0xFFF59E0B),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
isRu ? 'Шпаргалка по поиску' : 'Search tips & operators',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
initiallyExpanded: _showTips,
|
||||
onExpansionChanged: (exp) => setState(() => _showTips = exp),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_TipRow(
|
||||
code: 'tag_a and tag_b',
|
||||
description: isRu
|
||||
? 'Независимый опрос обоих тегов с чередованием постов в ленте.'
|
||||
: 'Interleaves results from both queries independently.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'type:video / type:gif',
|
||||
description: isRu
|
||||
? 'Показывает только видео или анимированные GIF.'
|
||||
: 'Filters results to only videos or animated GIFs.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'rating:safe / rating:explicit',
|
||||
description: isRu
|
||||
? 'Фильтр по возрастному рейтингу медиа.'
|
||||
: 'Filter by age rating classification.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'score:>50 / score:>100',
|
||||
description: isRu
|
||||
? 'Посты с оценкой пользователей выше указанной.'
|
||||
: 'Posts with community score greater than threshold.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TipRow(
|
||||
code: 'provider:gelbooru',
|
||||
description: isRu
|
||||
? 'Поиск исключительно в указанном источнике.'
|
||||
: 'Search specifically on the given booru provider.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
Text('Recent', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
RecentSearches(
|
||||
items: data.recent,
|
||||
onTap: (query) =>
|
||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchSectionCard extends StatelessWidget {
|
||||
const _SearchSectionCard({
|
||||
required this.icon,
|
||||
required this.accentColor,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.badgeCount,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color accentColor;
|
||||
final String title;
|
||||
final Widget child;
|
||||
final int? badgeCount;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: accentColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (badgeCount != null && badgeCount! > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'$badgeCount',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickOperatorChip extends StatelessWidget {
|
||||
const _QuickOperatorChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryTagRow extends StatelessWidget {
|
||||
const _CategoryTagRow({
|
||||
required this.categoryName,
|
||||
required this.color,
|
||||
required this.tags,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String categoryName;
|
||||
final Color color;
|
||||
final List<String> tags;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
categoryName,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => onTap(tag),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
tag,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TipRow extends StatelessWidget {
|
||||
const _TipRow({required this.code, required this.description});
|
||||
|
||||
final String code;
|
||||
final String description;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@ import 'package:flutter/material.dart';
|
||||
import '../../../../backend/backend.dart';
|
||||
|
||||
class RecentSearches extends StatefulWidget {
|
||||
const RecentSearches({required this.items, required this.onTap, super.key});
|
||||
const RecentSearches({
|
||||
required this.items,
|
||||
required this.onTap,
|
||||
this.onDelete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<SearchHistory> items;
|
||||
final ValueChanged<String> onTap;
|
||||
final ValueChanged<String>? onDelete;
|
||||
|
||||
@override
|
||||
State<RecentSearches> createState() => _RecentSearchesState();
|
||||
@@ -17,31 +23,136 @@ class _RecentSearchesState extends State<RecentSearches> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.items.isEmpty) return const Text('No recent searches');
|
||||
final theme = Theme.of(context);
|
||||
if (widget.items.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history_toggle_off_rounded,
|
||||
size: 40,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'История поиска пуста',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final visibleItems =
|
||||
_expanded ? widget.items : widget.items.take(30).toList();
|
||||
_expanded ? widget.items : widget.items.take(20).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final item in visibleItems)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history_rounded),
|
||||
title: Text(item.query),
|
||||
subtitle:
|
||||
Text('${item.tags.join(' ')} - ${item.resultCount} results'),
|
||||
onTap: () => widget.onTap(item.query),
|
||||
),
|
||||
if (widget.items.length > 30)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
icon: Icon(
|
||||
_expanded ? Icons.unfold_less_rounded : Icons.unfold_more_rounded,
|
||||
),
|
||||
label: Text(
|
||||
_expanded
|
||||
? 'Show latest 30'
|
||||
: 'Show all ${widget.items.length} searches',
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final item in visibleItems)
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => widget.onTap(item.query),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history_rounded,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 140),
|
||||
child: Text(
|
||||
item.query,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.resultCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${item.resultCount}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.onDelete != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => widget.onDelete!(item.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.items.length > 20) ...[
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
icon: Icon(
|
||||
_expanded
|
||||
? Icons.unfold_less_rounded
|
||||
: Icons.unfold_more_rounded,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(
|
||||
_expanded
|
||||
? 'Свернуть'
|
||||
: 'Показать все (${widget.items.length})',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,14 @@ class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> deleteItem(String providerId, String postId) async {
|
||||
await ref
|
||||
.read(viewedHistoryServiceProvider)
|
||||
.deleteItem(providerId, postId);
|
||||
ref.invalidate(viewedKeysProvider);
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await ref.read(viewedHistoryServiceProvider).clearHistory();
|
||||
ref.invalidate(viewedKeysProvider);
|
||||
|
||||
@@ -1,85 +1,251 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/confirm_dialog.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
import 'viewed_controller.dart';
|
||||
|
||||
class ViewedScreen extends ConsumerWidget {
|
||||
class ViewedScreen extends ConsumerStatefulWidget {
|
||||
const ViewedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ViewedScreen> createState() => _ViewedScreenState();
|
||||
}
|
||||
|
||||
class _ViewedScreenState extends ConsumerState<ViewedScreen> {
|
||||
bool _isGridView = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(viewedControllerProvider);
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final isRu = settings.languageCode == 'ru';
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
||||
title: isRu ? 'История' : 'History',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear viewed history',
|
||||
onPressed: () => ref.read(viewedControllerProvider.notifier).clear(),
|
||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
||||
tooltip: _isGridView
|
||||
? (isRu ? 'Список' : 'List view')
|
||||
: (isRu ? 'Сетка' : 'Grid view'),
|
||||
icon: Icon(_isGridView
|
||||
? Icons.view_agenda_rounded
|
||||
: Icons.grid_view_rounded),
|
||||
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||
onPressed: () async {
|
||||
final confirm = await showConfirmDialog(
|
||||
context,
|
||||
title: isRu ? 'Очистить историю' : 'Clear history',
|
||||
message: isRu
|
||||
? 'Вы действительно хотите очистить всю историю просмотров?'
|
||||
: 'Are you sure you want to clear your entire viewing history?',
|
||||
);
|
||||
if (confirm) {
|
||||
await ref.read(viewedControllerProvider.notifier).clear();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_sweep_rounded),
|
||||
),
|
||||
],
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (groups) => groups.isEmpty
|
||||
? const EmptyView(title: 'No viewed posts yet')
|
||||
: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
data: (groups) {
|
||||
if (groups.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final group in groups) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
||||
child: Text(
|
||||
group.label,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
for (final item in group.items)
|
||||
_ViewedPostTile(
|
||||
post: item.post,
|
||||
viewedAt: item.viewedAt,
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
onOpen: () {
|
||||
final allPosts = [
|
||||
for (final g in groups)
|
||||
for (final i in g.items) i.post
|
||||
];
|
||||
context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
ref,
|
||||
item.post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
child: Icon(
|
||||
Icons.history_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isRu ? 'История просмотров пуста' : 'No viewed posts yet',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isRu
|
||||
? 'Посты, которые вы открываете в ленте, будут сохраняться здесь'
|
||||
: 'Posts you open from the feed will appear here',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.go('/feed'),
|
||||
icon: const Icon(Icons.explore_rounded),
|
||||
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allPosts = [
|
||||
for (final g in groups)
|
||||
for (final i in g.items) i.post
|
||||
];
|
||||
|
||||
if (_isGridView) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||
child: PostMasonryGrid(
|
||||
posts: allPosts,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: allPosts,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) => _toggleFavorite(
|
||||
ref,
|
||||
post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
for (final group in groups) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today_rounded,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_localizedGroupLabel(group.label, isRu),
|
||||
style:
|
||||
Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${group.items.length}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final item in group.items)
|
||||
_ViewedPostTile(
|
||||
post: item.post,
|
||||
viewedAt: item.viewedAt,
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
isRu: isRu,
|
||||
onOpen: () {
|
||||
context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
ref,
|
||||
item.post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
),
|
||||
onDelete: () => ref
|
||||
.read(viewedControllerProvider.notifier)
|
||||
.deleteItem(item.post.providerId, item.post.id),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _localizedGroupLabel(String label, bool isRu) {
|
||||
if (!isRu) return label;
|
||||
if (label == 'Today') return 'Сегодня';
|
||||
if (label == 'Yesterday') return 'Вчера';
|
||||
return label;
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(
|
||||
WidgetRef ref,
|
||||
Post post,
|
||||
@@ -98,6 +264,7 @@ class ViewedScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,44 +273,212 @@ class _ViewedPostTile extends StatelessWidget {
|
||||
required this.post,
|
||||
required this.viewedAt,
|
||||
required this.isFavorite,
|
||||
required this.isRu,
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final DateTime viewedAt;
|
||||
final bool isFavorite;
|
||||
final bool isRu;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
bool get _isVideo {
|
||||
final type = post.fileType.toLowerCase();
|
||||
final url = post.fileUrl.toLowerCase();
|
||||
return type == 'mp4' ||
|
||||
type == 'webm' ||
|
||||
url.endsWith('.mp4') ||
|
||||
url.endsWith('.webm');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final image = MediaUrlSelector.preview(post).firstOrNull;
|
||||
final timeStr =
|
||||
'${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: scheme.surfaceContainerLow,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: image == null
|
||||
? const Icon(Icons.image_not_supported_rounded)
|
||||
: Image.network(image, fit: BoxFit.cover),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Squircle Thumbnail
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: 68,
|
||||
height: 68,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
image == null
|
||||
? Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.image_not_supported_rounded),
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: image,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: const Icon(
|
||||
Icons.broken_image_rounded,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isVideo)
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Post details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
scheme.primaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
post.providerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
timeStr,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
post.tags.isEmpty ? '#${post.id}' : post.tags.take(4).join(' '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (post.score != 0) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.thumb_up_rounded,
|
||||
size: 12,
|
||||
color: scheme.outline,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${post.score}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: scheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Action buttons
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
tooltip: isFavorite
|
||||
? (isRu ? 'В избранном' : 'In favorites')
|
||||
: (isRu ? 'В избранное' : 'Add to favorites'),
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(
|
||||
isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
color: isFavorite ? Colors.redAccent : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
tooltip: isRu ? 'Удалить из истории' : 'Delete from history',
|
||||
onPressed: onDelete,
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 20,
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text(post.tags.take(4).join(' '), maxLines: 1),
|
||||
subtitle: Text(
|
||||
'${post.providerName} • ${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
||||
onPressed: onFavorite,
|
||||
icon: Icon(isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class TagInputSearchBar extends StatefulWidget {
|
||||
required this.onSubmitted,
|
||||
this.onChanged,
|
||||
this.onSuggestionApplied,
|
||||
this.onTagRemoved,
|
||||
this.onCleared,
|
||||
this.initialValue,
|
||||
this.hintText = 'Search tags',
|
||||
this.suggestions = const [],
|
||||
@@ -37,6 +39,8 @@ class TagInputSearchBar extends StatefulWidget {
|
||||
final ValueChanged<String> onSubmitted;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSuggestionApplied;
|
||||
final ValueChanged<String>? onTagRemoved;
|
||||
final VoidCallback? onCleared;
|
||||
final String? initialValue;
|
||||
final String hintText;
|
||||
final List<TagSuggestion> suggestions;
|
||||
@@ -48,10 +52,10 @@ class TagInputSearchBar extends StatefulWidget {
|
||||
class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
final _fieldKey = GlobalKey();
|
||||
final _layerLink = LayerLink();
|
||||
final _portalController = OverlayPortalController();
|
||||
late final TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
late final ScrollController _tagScrollController;
|
||||
OverlayEntry? _suggestionsOverlay;
|
||||
Timer? _debounce;
|
||||
List<String> _tags = [];
|
||||
String _lastExternalValue = '';
|
||||
@@ -64,10 +68,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_focusNode = FocusNode();
|
||||
_tagScrollController = ScrollController();
|
||||
_focusNode.addListener(_handleFocusChanged);
|
||||
_controller.addListener(_handleControllerChanged);
|
||||
_lastExternalValue = widget.initialValue ?? '';
|
||||
_setFromQuery(_lastExternalValue);
|
||||
}
|
||||
|
||||
void _handleControllerChanged() {
|
||||
if (_controller.text.trim().isEmpty && _portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -80,15 +91,18 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_localDirty = false;
|
||||
}
|
||||
if (oldWidget.suggestions != widget.suggestions) {
|
||||
_syncSuggestionsOverlay();
|
||||
_syncSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_removeSuggestionsOverlay();
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
_tagScrollController.dispose();
|
||||
_controller.removeListener(_handleControllerChanged);
|
||||
_focusNode.removeListener(_handleFocusChanged);
|
||||
_focusNode.dispose();
|
||||
_controller.dispose();
|
||||
@@ -97,108 +111,207 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
|
||||
void _handleFocusChanged() {
|
||||
if (!mounted) return;
|
||||
if (!_focusNode.hasFocus && !_localDirty) {
|
||||
final next = widget.initialValue ?? '';
|
||||
if (next != _lastExternalValue) {
|
||||
_setFromQuery(next);
|
||||
_lastExternalValue = next;
|
||||
if (!_focusNode.hasFocus) {
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
if (!_localDirty) {
|
||||
final next = widget.initialValue ?? '';
|
||||
if (next != _lastExternalValue) {
|
||||
_setFromQuery(next);
|
||||
_lastExternalValue = next;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_syncSuggestions();
|
||||
}
|
||||
setState(() {});
|
||||
_syncSuggestionsOverlay();
|
||||
}
|
||||
|
||||
void _syncSuggestions() {
|
||||
if (!mounted) return;
|
||||
final token = _activeToken;
|
||||
if (!_focusNode.hasFocus || token.isEmpty) {
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
final matches = _matchingSuggestions;
|
||||
if (matches.isEmpty) {
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!_portalController.isShowing) {
|
||||
_portalController.show();
|
||||
}
|
||||
}
|
||||
|
||||
List<TagSuggestion> get _matchingSuggestions {
|
||||
final token = _activeToken;
|
||||
if (token.isEmpty) return const [];
|
||||
return widget.suggestions
|
||||
.where((item) => item.name.toLowerCase().startsWith(token))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
void _hideSuggestions() {
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
|
||||
void _scrollToEnd() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_tagScrollController.hasClients) {
|
||||
_tagScrollController.animateTo(
|
||||
_tagScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _syncSuggestionsOverlay();
|
||||
});
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: DecoratedBox(
|
||||
key: _fieldKey,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? scheme.primary.withValues(alpha: 0.45)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
child: Scrollbar(
|
||||
controller: _tagScrollController,
|
||||
thumbVisibility: false,
|
||||
child: SingleChildScrollView(
|
||||
controller: _tagScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final tag in _tags) ...[
|
||||
_buildTagChip(context, tag),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 120,
|
||||
maxWidth: 300,
|
||||
),
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: _handleDraftChanged,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
|
||||
return TapRegion(
|
||||
groupId: _fieldKey,
|
||||
onTapOutside: (_) => _hideSuggestions(),
|
||||
child: OverlayPortal(
|
||||
controller: _portalController,
|
||||
overlayChildBuilder: (context) {
|
||||
final token = _activeToken;
|
||||
final suggestions = _matchingSuggestions;
|
||||
if (suggestions.isEmpty || token.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final renderBox =
|
||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final width =
|
||||
renderBox?.hasSize == true ? renderBox!.size.width : 360.0;
|
||||
final height =
|
||||
renderBox?.hasSize == true ? renderBox!.size.height : 48.0;
|
||||
|
||||
return CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(0, height + 6),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: TapRegion(
|
||||
groupId: _fieldKey,
|
||||
child: SizedBox(
|
||||
width: width.clamp(280.0, 620.0),
|
||||
child: _TagSuggestionDropdown(
|
||||
suggestions: suggestions,
|
||||
query: token,
|
||||
onSelected: _applySuggestion,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: DecoratedBox(
|
||||
key: _fieldKey,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? scheme.primary.withValues(alpha: 0.6)
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_rounded,
|
||||
color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
child: Scrollbar(
|
||||
controller: _tagScrollController,
|
||||
thumbVisibility: false,
|
||||
child: SingleChildScrollView(
|
||||
controller: _tagScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final tag in _tags) ...[
|
||||
_buildTagChip(context, tag),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 120,
|
||||
maxWidth: 300,
|
||||
),
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction:
|
||||
TextInputAction.search,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: _handleDraftChanged,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
hintText: _tags.isEmpty
|
||||
? widget.hintText
|
||||
: 'tag',
|
||||
prefixIcon: null,
|
||||
suffixIcon: null,
|
||||
),
|
||||
),
|
||||
hintText:
|
||||
_tags.isEmpty ? widget.hintText : 'tag',
|
||||
prefixIcon: null,
|
||||
suffixIcon: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: _clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: _clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -206,54 +319,36 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
);
|
||||
}
|
||||
|
||||
void _syncSuggestionsOverlay() {
|
||||
final token = _activeToken;
|
||||
final suggestions = widget.suggestions
|
||||
.where((item) => item.name.toLowerCase().startsWith(token))
|
||||
.toList(growable: false);
|
||||
if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) {
|
||||
_removeSuggestionsOverlay();
|
||||
return;
|
||||
}
|
||||
final overlay = Overlay.maybeOf(context);
|
||||
final renderBox =
|
||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (overlay == null || renderBox == null || !renderBox.hasSize) return;
|
||||
|
||||
final size = renderBox.size;
|
||||
_removeSuggestionsOverlay();
|
||||
_suggestionsOverlay = OverlayEntry(
|
||||
builder: (context) => _TagSuggestionOverlay(
|
||||
link: _layerLink,
|
||||
width: size.width,
|
||||
yOffset: size.height + 8,
|
||||
suggestions: suggestions,
|
||||
query: token,
|
||||
onSelected: _applySuggestion,
|
||||
),
|
||||
);
|
||||
overlay.insert(_suggestionsOverlay!);
|
||||
}
|
||||
|
||||
void _removeSuggestionsOverlay() {
|
||||
_suggestionsOverlay?.remove();
|
||||
_suggestionsOverlay = null;
|
||||
}
|
||||
|
||||
Widget _buildTagChip(BuildContext context, String tag) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isAnd = tag.toLowerCase() == 'and';
|
||||
return InputChip(
|
||||
label: Text(tag),
|
||||
avatar: const Icon(Icons.tag_rounded, size: 16),
|
||||
label: Text(
|
||||
isAnd ? 'AND' : tag,
|
||||
style: TextStyle(
|
||||
color: isAnd
|
||||
? scheme.onSecondaryContainer
|
||||
: scheme.onPrimaryContainer,
|
||||
fontWeight: isAnd ? FontWeight.w900 : FontWeight.w700,
|
||||
letterSpacing: isAnd ? 0.8 : 0,
|
||||
),
|
||||
),
|
||||
avatar: Icon(
|
||||
isAnd ? Icons.alt_route_rounded : Icons.tag_rounded,
|
||||
size: 16,
|
||||
color: isAnd ? scheme.secondary : scheme.primary,
|
||||
),
|
||||
deleteIcon: const Icon(Icons.close_rounded, size: 16),
|
||||
onPressed: () => _editTag(tag),
|
||||
onDeleted: () => _removeTag(tag),
|
||||
backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54),
|
||||
labelStyle: TextStyle(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
backgroundColor: isAnd
|
||||
? scheme.secondaryContainer.withValues(alpha: 0.7)
|
||||
: scheme.primaryContainer.withValues(alpha: 0.54),
|
||||
side: BorderSide(
|
||||
color: isAnd
|
||||
? scheme.secondary.withValues(alpha: 0.4)
|
||||
: scheme.primary.withValues(alpha: 0.18),
|
||||
),
|
||||
side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
@@ -285,10 +380,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
|
||||
void _handleDraftChanged(String value) {
|
||||
_localDirty = true;
|
||||
if (value.trim().isEmpty) {
|
||||
_debounce?.cancel();
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
setState(() {});
|
||||
_notifyChanged();
|
||||
return;
|
||||
}
|
||||
final composing = _controller.value.composing;
|
||||
if (!composing.isValid &&
|
||||
value.isNotEmpty &&
|
||||
RegExp(r'\s$').hasMatch(value)) {
|
||||
if (!composing.isValid && RegExp(r'\s$').hasMatch(value)) {
|
||||
_commitDraft(value);
|
||||
return;
|
||||
}
|
||||
@@ -306,35 +408,53 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
for (final tag in additions) {
|
||||
if (!_tags.contains(tag)) _tags.add(tag);
|
||||
if (tag.toLowerCase() == 'and' || !_tags.contains(tag)) {
|
||||
_tags.add(tag);
|
||||
}
|
||||
}
|
||||
_controller.clear();
|
||||
});
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
_scrollToEnd();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
_debounce?.cancel();
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
_commitDraft(_controller.text);
|
||||
_removeSuggestionsOverlay();
|
||||
_lastExternalValue = _query;
|
||||
_localDirty = false;
|
||||
_focusNode.unfocus();
|
||||
widget.onSubmitted(_query);
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_debounce?.cancel();
|
||||
_localDirty = true;
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
_localDirty = false;
|
||||
setState(() {
|
||||
_tags = [];
|
||||
_controller.clear();
|
||||
});
|
||||
_lastExternalValue = '';
|
||||
_notifyChanged();
|
||||
_focusNode.requestFocus();
|
||||
_focusNode.unfocus();
|
||||
widget.onCleared?.call();
|
||||
widget.onSubmitted('');
|
||||
}
|
||||
|
||||
void _editTag(String tag) {
|
||||
_localDirty = true;
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
setState(() {
|
||||
_tags = _tags.where((item) => item != tag).toList(growable: true);
|
||||
_controller.value = TextEditingValue(
|
||||
@@ -348,10 +468,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
|
||||
void _removeTag(String tag) {
|
||||
_localDirty = true;
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
setState(() {
|
||||
_tags = _tags.where((item) => item != tag).toList(growable: true);
|
||||
});
|
||||
_lastExternalValue = _query;
|
||||
_notifyChanged();
|
||||
if (widget.onTagRemoved != null) {
|
||||
widget.onTagRemoved!(_query);
|
||||
}
|
||||
}
|
||||
|
||||
void _applySuggestion(String suggestion) {
|
||||
@@ -359,14 +486,27 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
if (draft.isNotEmpty) {
|
||||
final draftTags = _parseTags(draft);
|
||||
_tags.removeWhere(draftTags.contains);
|
||||
final tokens = _parseTags(draft);
|
||||
if (tokens.isNotEmpty) {
|
||||
final prefixTokens = tokens.sublist(0, tokens.length - 1);
|
||||
for (final token in prefixTokens) {
|
||||
if (token.toLowerCase() == 'and' || !_tags.contains(token)) {
|
||||
_tags.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!_tags.contains(suggestion)) {
|
||||
_tags.add(suggestion);
|
||||
}
|
||||
if (!_tags.contains(suggestion)) _tags.add(suggestion);
|
||||
_controller.clear();
|
||||
});
|
||||
if (_portalController.isShowing) {
|
||||
_portalController.hide();
|
||||
}
|
||||
_lastExternalValue = _query;
|
||||
_scrollToEnd();
|
||||
_notifyChanged();
|
||||
_removeSuggestionsOverlay();
|
||||
widget.onSuggestionApplied?.call(_query);
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
@@ -379,7 +519,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
void _notifyChanged() {
|
||||
_localDirty = true;
|
||||
widget.onChanged?.call(_query);
|
||||
_syncSuggestionsOverlay();
|
||||
_syncSuggestions();
|
||||
}
|
||||
|
||||
List<String> _parseTags(String query) {
|
||||
@@ -387,7 +527,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
final result = <String>[];
|
||||
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||
final tag = raw.trim();
|
||||
if (tag.isEmpty || !seen.add(tag)) continue;
|
||||
if (tag.isEmpty) continue;
|
||||
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||
result.add(tag);
|
||||
}
|
||||
return result;
|
||||
@@ -409,46 +550,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
bool get _isEditing => _focusNode.hasFocus || _localDirty;
|
||||
}
|
||||
|
||||
class _TagSuggestionOverlay extends StatelessWidget {
|
||||
const _TagSuggestionOverlay({
|
||||
required this.link,
|
||||
required this.width,
|
||||
required this.yOffset,
|
||||
required this.suggestions,
|
||||
required this.query,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final LayerLink link;
|
||||
final double width;
|
||||
final double yOffset;
|
||||
final List<TagSuggestion> suggestions;
|
||||
final String query;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: CompositedTransformFollower(
|
||||
link: link,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(0, yOffset),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: width.clamp(280, 620).toDouble(),
|
||||
child: _TagSuggestionDropdown(
|
||||
suggestions: suggestions,
|
||||
query: query,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagSuggestionDropdown extends StatelessWidget {
|
||||
const _TagSuggestionDropdown({
|
||||
required this.suggestions,
|
||||
|
||||
+251
-153
@@ -177,114 +177,161 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
],
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||
child: BlurContent(
|
||||
enabled: widget.blurExplicit && sensitive,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (!mobile &&
|
||||
_hovered &&
|
||||
_resolvedPost != null &&
|
||||
MediaUrlSelector.isVideo(post) &&
|
||||
videoUrls.isNotEmpty) {
|
||||
return _FeedVideoPreview(
|
||||
videoUrl: videoUrls.first,
|
||||
headers: headers,
|
||||
fallbackImageUrl: imageUrl,
|
||||
cacheWidth: cacheWidth,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: headers,
|
||||
memCacheWidth: cacheWidth,
|
||||
maxWidthDiskCache: cacheWidth,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const LoadingSkeleton(),
|
||||
errorWidget: (context, url, error) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_rounded),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.09),
|
||||
width: 0.8,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(13.2),
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||
child: BlurContent(
|
||||
enabled: widget.blurExplicit && sensitive,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (!mobile &&
|
||||
_hovered &&
|
||||
_resolvedPost != null &&
|
||||
MediaUrlSelector.isVideo(post) &&
|
||||
videoUrls.isNotEmpty) {
|
||||
return _FeedVideoPreview(
|
||||
videoUrl: videoUrls.first,
|
||||
headers: headers,
|
||||
fallbackImageUrl: imageUrl,
|
||||
cacheWidth: cacheWidth,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: headers,
|
||||
memCacheWidth: cacheWidth,
|
||||
maxWidthDiskCache: cacheWidth,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const LoadingSkeleton(),
|
||||
errorWidget: (context, url, error) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showBadges) ...[
|
||||
// Bottom cinematic gradient overlay for badges and favorite
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
child: _ProviderBadge(name: post.providerName),
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 64,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.72),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: RatingBadge(rating: post.rating),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: _MediaBadge(fileType: post.fileType),
|
||||
),
|
||||
if (widget.isViewed)
|
||||
const Positioned(
|
||||
if (widget.showBadges) ...[
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
child: _ProviderBadge(name: post.providerName),
|
||||
),
|
||||
if (post.fileType.toLowerCase().contains('video') ||
|
||||
post.fileType.toLowerCase().contains('webm') ||
|
||||
post.fileType.toLowerCase().contains('mp4') ||
|
||||
post.fileType.toLowerCase().contains('gif'))
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: _MediaBadge(fileType: post.fileType),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isViewed || widget.isDownloaded) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isViewed) const _SeenBadge(),
|
||||
if (widget.isViewed && widget.isDownloaded)
|
||||
const SizedBox(width: 4),
|
||||
if (widget.isDownloaded)
|
||||
const _DownloadedBadge(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
RatingBadge(rating: post.rating),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.selected)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.check_circle_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mobile)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: _SeenBadge(),
|
||||
),
|
||||
if (widget.isDownloaded)
|
||||
Positioned(
|
||||
right: widget.isViewed ? 72 : 8,
|
||||
bottom: 8,
|
||||
child: const _DownloadedBadge(),
|
||||
),
|
||||
],
|
||||
if (widget.selected)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.check_circle_rounded),
|
||||
),
|
||||
child: _FavoriteButton(
|
||||
isFavorite: widget.isFavorite,
|
||||
onPressed: widget.onFavorite,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mobile)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: _FavoriteButton(
|
||||
isFavorite: widget.isFavorite,
|
||||
onPressed: widget.onFavorite,
|
||||
),
|
||||
),
|
||||
// Double-tap heart animation overlay
|
||||
if (_showHeart)
|
||||
Positioned.fill(
|
||||
@@ -369,7 +416,8 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -635,13 +683,37 @@ class _FavoriteButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
size: 18,
|
||||
return InkResponse(
|
||||
onTap: onPressed,
|
||||
radius: 20,
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, 150),
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.58),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isFavorite
|
||||
? Colors.redAccent.withValues(alpha: 0.6)
|
||||
: Colors.white.withValues(alpha: 0.16),
|
||||
width: 0.9,
|
||||
),
|
||||
boxShadow: [
|
||||
if (isFavorite)
|
||||
BoxShadow(
|
||||
color: Colors.redAccent.withValues(alpha: 0.35),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -654,16 +726,23 @@ class _ProviderBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -678,25 +757,32 @@ class _MediaBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = _type(fileType);
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_icon(type), size: 12, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
type,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
],
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_icon(type), size: 13, color: Colors.white),
|
||||
const SizedBox(width: 3.5),
|
||||
Text(
|
||||
type,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -723,25 +809,31 @@ class _SeenBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.visibility_rounded, size: 12, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'seen',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
],
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.visibility_rounded, size: 12, color: Colors.white70),
|
||||
SizedBox(width: 3.5),
|
||||
Text(
|
||||
'seen',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -751,14 +843,20 @@ class _DownloadedBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Icon(Icons.download_done_rounded, size: 13, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.download_done_rounded,
|
||||
size: 13,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= posts.length) return const LoadingSkeleton();
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox.expand(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
|
||||
@@ -14,16 +14,23 @@ class RatingBadge extends StatelessWidget {
|
||||
'explicit' || 'e' => Colors.red,
|
||||
_ => Colors.blueGrey,
|
||||
};
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6.5, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: color.withValues(alpha: 0.88),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.18),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
child: Text(
|
||||
rating.isEmpty ? 'unknown' : rating,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
child: Text(
|
||||
rating.isEmpty ? 'unknown' : rating,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.9+31
|
||||
version: 2.3.9+49
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/cloud_media_link.dart';
|
||||
import 'package:gel_rule_app/backend/services/cloud_link_extractor.dart';
|
||||
|
||||
void main() {
|
||||
group('CloudLinkExtractor tests', () {
|
||||
test('extracts and parses Google Drive links with direct stream URLs', () {
|
||||
const html = '''
|
||||
<p>New animation release!</p>
|
||||
<p>Download here: <a href="https://drive.google.com/file/d/1AbC-DeFgHiJkLmNoPqRsTuVwXyZ123/view?usp=sharing">Google Drive 4K</a></p>
|
||||
<p>Or browse the full folder: https://drive.google.com/drive/folders/9ZyX-WvUtSrQpOnMlKjIhGfEdCbA456</p>
|
||||
''';
|
||||
|
||||
final links = CloudLinkExtractor.extractLinks(content: html);
|
||||
expect(links.length, 2);
|
||||
|
||||
final fileLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && !l.isFolder);
|
||||
expect(fileLink.title, 'Google Drive 4K');
|
||||
expect(fileLink.isStreamable, isTrue);
|
||||
expect(fileLink.directStreamUrl, 'https://drive.google.com/uc?export=download&id=1AbC-DeFgHiJkLmNoPqRsTuVwXyZ123');
|
||||
|
||||
final folderLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && l.isFolder);
|
||||
expect(folderLink.isFolder, isTrue);
|
||||
expect(folderLink.isStreamable, isFalse);
|
||||
});
|
||||
|
||||
test('extracts MEGA links and detects folders', () {
|
||||
const content = '''
|
||||
Archive mirrors:
|
||||
Mega file: https://mega.nz/file/xyz123#abcdefg
|
||||
Mega folder: https://mega.nz/folder/abc456#1234567
|
||||
''';
|
||||
|
||||
final links = CloudLinkExtractor.extractLinks(content: content);
|
||||
expect(links.length, 2);
|
||||
|
||||
final fileLink = links.firstWhere((l) => !l.isFolder);
|
||||
expect(fileLink.service, CloudServiceType.mega);
|
||||
expect(fileLink.serviceName, 'MEGA');
|
||||
expect(fileLink.isFolder, isFalse);
|
||||
|
||||
final folderLink = links.firstWhere((l) => l.isFolder);
|
||||
expect(folderLink.service, CloudServiceType.mega);
|
||||
expect(folderLink.isFolder, isTrue);
|
||||
});
|
||||
|
||||
test('transforms Dropbox links into direct stream URLs with ?raw=1', () {
|
||||
const content = 'https://www.dropbox.com/s/sample123/video.mp4?dl=0';
|
||||
final links = CloudLinkExtractor.extractLinks(content: content);
|
||||
expect(links.length, 1);
|
||||
final link = links.first;
|
||||
expect(link.service, CloudServiceType.dropbox);
|
||||
expect(link.isStreamable, isTrue);
|
||||
expect(link.directStreamUrl, 'https://www.dropbox.com/s/sample123/video.mp4?raw=1');
|
||||
});
|
||||
|
||||
test('extracts Pixeldrain file IDs and generates API endpoints', () {
|
||||
const content = 'Pixeldrain mirror: https://pixeldrain.com/u/abc123xy';
|
||||
final links = CloudLinkExtractor.extractLinks(content: content);
|
||||
expect(links.length, 1);
|
||||
final link = links.first;
|
||||
expect(link.service, CloudServiceType.pixeldrain);
|
||||
expect(link.isStreamable, isTrue);
|
||||
expect(link.directStreamUrl, 'https://pixeldrain.com/api/file/abc123xy');
|
||||
});
|
||||
|
||||
test('extracts archive passwords from content', () {
|
||||
expect(
|
||||
CloudLinkExtractor.extractPassword('Here is the zip! Password: SuperSecret123'),
|
||||
'SuperSecret123',
|
||||
);
|
||||
expect(
|
||||
CloudLinkExtractor.extractPassword('Pass = pass_word_2026!'),
|
||||
'pass_word_2026!',
|
||||
);
|
||||
expect(
|
||||
CloudLinkExtractor.extractPassword('Пароль: mypass777'),
|
||||
'mypass777',
|
||||
);
|
||||
});
|
||||
|
||||
test('cleans HTML commentary correctly', () {
|
||||
const html = '<p>Hello world!<br/>Check out the new video.</p><p>& enjoy!</p>';
|
||||
final clean = CloudLinkExtractor.cleanCommentary(html);
|
||||
expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!');
|
||||
});
|
||||
|
||||
test('cleans HTML commentary and announcements correctly without style artifacts', () {
|
||||
const html =
|
||||
'<p style=""><strong>Thank you!</strong> </p><p style="">Here\'s the link to the archive</p><p style=""><a href="https://www.patreon.com/posts/full-fugtrup-14581297" rel="noopener noreferrer nofollow">https://www.patreon.com/posts/full-fugtrup-14581297</a></p><p style="">and the link to my <strong>discord server</strong>, if you\'d like to join it <a href="https://discord.com/invite/xR2AbkX" rel="noopener noreferrer nofollow">https://discord.com/invite/xR2AbkX</a></p>';
|
||||
final clean = CloudLinkExtractor.cleanCommentary(html);
|
||||
expect(clean.contains('<p'), isFalse);
|
||||
expect(clean.contains('style='), isFalse);
|
||||
expect(clean.contains('<strong>'), isFalse);
|
||||
expect(clean.contains('Thank you!'), isTrue);
|
||||
expect(clean.contains('https://discord.com/invite/xR2AbkX'), isTrue);
|
||||
});
|
||||
|
||||
test('encodes and decodes CloudMediaLink to and from JSON', () {
|
||||
const link = CloudMediaLink(
|
||||
url: 'https://mega.nz/file/123#abc',
|
||||
service: CloudServiceType.mega,
|
||||
title: 'Full 4K Video',
|
||||
isFolder: false,
|
||||
isStreamable: false,
|
||||
detectedPassword: 'pass',
|
||||
);
|
||||
|
||||
final encoded = link.encode();
|
||||
final decoded = CloudMediaLink.tryDecode(encoded);
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.url, link.url);
|
||||
expect(decoded.service, CloudServiceType.mega);
|
||||
expect(decoded.title, 'Full 4K Video');
|
||||
expect(decoded.detectedPassword, 'pass');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/providers/gelbooru_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
import 'package:gel_rule_app/core/http/dio_client.dart';
|
||||
|
||||
void main() {
|
||||
test('ProviderFactory creates GelbooruProvider with query credentials', () {
|
||||
final factory = ProviderFactory();
|
||||
final config = ContentProviderConfig(
|
||||
id: 'gelbooru',
|
||||
name: 'Gelbooru',
|
||||
baseUrl: 'https://gelbooru.com',
|
||||
apiType: 'gelbooru',
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.api_key': 'test_api_key',
|
||||
'query.user_id': '123456',
|
||||
},
|
||||
createdAt: DateTime(2026),
|
||||
updatedAt: DateTime(2026),
|
||||
);
|
||||
|
||||
final provider = factory.create(config);
|
||||
expect(provider, isA<GelbooruProvider>());
|
||||
final gelbooru = provider as GelbooruProvider;
|
||||
expect(gelbooru.id, 'gelbooru');
|
||||
expect(gelbooru.queryParameters['api_key'], 'test_api_key');
|
||||
expect(gelbooru.queryParameters['user_id'], '123456');
|
||||
});
|
||||
|
||||
test('GelbooruProvider mediaHeaders contains Referer and User-Agent', () {
|
||||
final client = DioClient(baseUrl: 'https://gelbooru.com');
|
||||
final provider = GelbooruProvider(
|
||||
id: 'gelbooru',
|
||||
name: 'Gelbooru',
|
||||
baseUrl: 'https://gelbooru.com',
|
||||
dioClient: client,
|
||||
);
|
||||
|
||||
expect(provider.baseUrl, 'https://gelbooru.com');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/favorite_artist_item.dart';
|
||||
import 'package:gel_rule_app/backend/models/pawchive_account.dart';
|
||||
import 'package:gel_rule_app/backend/services/pawchive_sync_service.dart';
|
||||
import 'package:gel_rule_app/backend/services/settings_service.dart';
|
||||
|
||||
class _FakeAdapter implements HttpClientAdapter {
|
||||
_FakeAdapter(this.handler);
|
||||
final Future<ResponseBody> Function(RequestOptions options) handler;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<dynamic>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) {
|
||||
return handler(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('FavoriteArtistItem Tests', () {
|
||||
test('roundtrip serialization and key generation', () {
|
||||
const item = FavoriteArtistItem(
|
||||
id: '12345',
|
||||
service: 'fanbox',
|
||||
providerId: 'pawchive',
|
||||
name: 'CoolArtist',
|
||||
avatarUrl: 'https://pawchive.pw/icons/fanbox/12345',
|
||||
);
|
||||
|
||||
expect(item.key, equals('pawchive:fanbox:12345'));
|
||||
expect(item.isEmpty, isFalse);
|
||||
|
||||
final json = item.toJson();
|
||||
final restored = FavoriteArtistItem.fromJson(json);
|
||||
expect(restored.key, equals(item.key));
|
||||
expect(restored.name, equals('CoolArtist'));
|
||||
expect(restored.avatarUrl, equals('https://pawchive.pw/icons/fanbox/12345'));
|
||||
expect(restored == item, isTrue);
|
||||
});
|
||||
|
||||
test('empty factory behaves correctly', () {
|
||||
const empty = FavoriteArtistItem.empty();
|
||||
expect(empty.isEmpty, isTrue);
|
||||
expect(empty.key, equals('::'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PawchiveAccount Tests', () {
|
||||
test('roundtrip serialization and copyWith', () {
|
||||
final now = DateTime(2026, 9, 4, 12, 0, 0);
|
||||
final account = PawchiveAccount(
|
||||
id: 'tester',
|
||||
username: 'Tester',
|
||||
sessionCookie: 'eySessionToken123',
|
||||
baseUrl: 'https://pawchive.pw',
|
||||
createdAt: now,
|
||||
lastSyncedAt: now,
|
||||
isActive: true,
|
||||
syncedArtistsCount: 15,
|
||||
);
|
||||
|
||||
final json = account.toJson();
|
||||
final restored = PawchiveAccount.fromJson(json);
|
||||
|
||||
expect(restored.id, equals('tester'));
|
||||
expect(restored.username, equals('Tester'));
|
||||
expect(restored.sessionCookie, equals('eySessionToken123'));
|
||||
expect(restored.isActive, isTrue);
|
||||
expect(restored.syncedArtistsCount, equals(15));
|
||||
expect(restored == account, isTrue);
|
||||
|
||||
final updated = account.copyWith(isActive: false, syncedArtistsCount: 20);
|
||||
expect(updated.isActive, isFalse);
|
||||
expect(updated.syncedArtistsCount, equals(20));
|
||||
expect(updated.id, equals('tester'));
|
||||
});
|
||||
});
|
||||
|
||||
group('AppSettings Pawchive integration tests', () {
|
||||
test('serializes and parses pawchive accounts correctly', () {
|
||||
final acc1 = PawchiveAccount(
|
||||
id: 'user1',
|
||||
username: 'User1',
|
||||
sessionCookie: 'token1',
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
isActive: true,
|
||||
syncedArtistsCount: 5,
|
||||
);
|
||||
final acc2 = PawchiveAccount(
|
||||
id: 'user2',
|
||||
username: 'User2',
|
||||
sessionCookie: 'token2',
|
||||
createdAt: DateTime(2026, 9, 2),
|
||||
isActive: false,
|
||||
syncedArtistsCount: 10,
|
||||
);
|
||||
|
||||
final settings = AppSettings.defaults.copyWith(
|
||||
pawchiveAccounts: [
|
||||
jsonEncode(acc1.toJson()),
|
||||
jsonEncode(acc2.toJson()),
|
||||
],
|
||||
);
|
||||
|
||||
expect(settings.parsedPawchiveAccounts.length, equals(2));
|
||||
expect(settings.activePawchiveAccount?.id, equals('user1'));
|
||||
|
||||
final json = settings.toJson();
|
||||
final restored = AppSettings.fromJson(json);
|
||||
expect(restored.parsedPawchiveAccounts.length, equals(2));
|
||||
expect(restored.activePawchiveAccount?.username, equals('User1'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PawchiveSyncService Tests', () {
|
||||
test('fetchRemoteFavoriteArtists parses server response correctly', () async {
|
||||
final dio = Dio();
|
||||
dio.httpClientAdapter = _FakeAdapter((options) async {
|
||||
if (options.path.endsWith('/account/favorites') &&
|
||||
options.queryParameters['type'] == 'artist') {
|
||||
final cookie = options.headers['Cookie'] as String?;
|
||||
expect(cookie, contains('session=test_cookie_123'));
|
||||
|
||||
final payload = jsonEncode([
|
||||
{
|
||||
'id': '98765',
|
||||
'service': 'fanbox',
|
||||
'name': 'AlphaCreator',
|
||||
'updated': '2026-09-04T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
'id': '43210',
|
||||
'service': 'patreon',
|
||||
'name': 'BetaCreator',
|
||||
'updated': '2026-09-03T15:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
return ResponseBody.fromString(
|
||||
payload,
|
||||
200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
return ResponseBody.fromString('Not found', 404);
|
||||
});
|
||||
|
||||
final service = PawchiveSyncService(dio: dio);
|
||||
final items = await service.fetchRemoteFavoriteArtists(
|
||||
baseUrl: 'https://pawchive.pw',
|
||||
sessionCookie: 'session=test_cookie_123;',
|
||||
);
|
||||
|
||||
expect(items.length, equals(2));
|
||||
expect(items[0].id, equals('98765'));
|
||||
expect(items[0].service, equals('fanbox'));
|
||||
expect(items[0].name, equals('AlphaCreator'));
|
||||
expect(items[0].avatarUrl, equals('https://pawchive.pw/icons/fanbox/98765'));
|
||||
|
||||
expect(items[1].id, equals('43210'));
|
||||
expect(items[1].service, equals('patreon'));
|
||||
expect(items[1].name, equals('BetaCreator'));
|
||||
});
|
||||
|
||||
test('syncAccountFavorites merges remote artists into AppSettings', () async {
|
||||
final dio = Dio();
|
||||
dio.httpClientAdapter = _FakeAdapter((options) async {
|
||||
final payload = jsonEncode([
|
||||
{
|
||||
'id': 'remote_1',
|
||||
'service': 'fanbox',
|
||||
'name': 'RemoteArtistOne',
|
||||
},
|
||||
{
|
||||
'id': 'existing_local',
|
||||
'service': 'patreon',
|
||||
'name': 'AlreadyPresent',
|
||||
},
|
||||
]);
|
||||
return ResponseBody.fromString(
|
||||
payload,
|
||||
200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final service = PawchiveSyncService(dio: dio);
|
||||
|
||||
const existingItem = FavoriteArtistItem(
|
||||
id: 'existing_local',
|
||||
service: 'patreon',
|
||||
providerId: 'pawchive',
|
||||
name: 'AlreadyPresent',
|
||||
);
|
||||
|
||||
final initialSettings = AppSettings.defaults.copyWith(
|
||||
favoriteArtists: [jsonEncode(existingItem.toJson())],
|
||||
);
|
||||
|
||||
final account = PawchiveAccount(
|
||||
id: 'myuser',
|
||||
username: 'myuser',
|
||||
sessionCookie: 'cookie123',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final result = await service.syncAccountFavorites(
|
||||
account: account,
|
||||
settings: initialSettings,
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(result.totalSyncedCount, equals(2));
|
||||
expect(result.newlyAddedToLocal, equals(1)); // remote_1 added, existing_local skipped
|
||||
expect(result.updatedSettings.favoriteArtists.length, equals(2));
|
||||
});
|
||||
|
||||
test('toggleRemoteFavorite sends POST and DELETE successfully', () async {
|
||||
var lastMethod = '';
|
||||
var lastPath = '';
|
||||
|
||||
final dio = Dio();
|
||||
dio.httpClientAdapter = _FakeAdapter((options) async {
|
||||
lastMethod = options.method;
|
||||
lastPath = options.path;
|
||||
return ResponseBody.fromString('ok', 200);
|
||||
});
|
||||
|
||||
final service = PawchiveSyncService(dio: dio);
|
||||
final account = PawchiveAccount(
|
||||
id: 'user1',
|
||||
username: 'user1',
|
||||
sessionCookie: 'session=abc',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final addResult = await service.toggleRemoteFavorite(
|
||||
account: account,
|
||||
service: 'fanbox',
|
||||
artistId: '12345',
|
||||
isFavorite: true,
|
||||
);
|
||||
expect(addResult, isTrue);
|
||||
expect(lastMethod, equals('POST'));
|
||||
expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
|
||||
|
||||
final removeResult = await service.toggleRemoteFavorite(
|
||||
account: account,
|
||||
service: 'fanbox',
|
||||
artistId: '12345',
|
||||
isFavorite: false,
|
||||
);
|
||||
expect(removeResult, isTrue);
|
||||
expect(lastMethod, equals('DELETE'));
|
||||
expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
import 'package:gel_rule_app/backend/providers/rule34_provider.dart';
|
||||
|
||||
void main() {
|
||||
test('ProviderFactory creates Rule34Provider with credentials', () {
|
||||
final factory = ProviderFactory();
|
||||
final config = ContentProviderConfig(
|
||||
id: 'rule34',
|
||||
name: 'Rule34',
|
||||
baseUrl: 'https://api.rule34.xxx',
|
||||
apiType: 'rule34',
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'query.api_key': 'test_key',
|
||||
'query.user_id': '99999',
|
||||
},
|
||||
createdAt: DateTime(2026),
|
||||
updatedAt: DateTime(2026),
|
||||
);
|
||||
|
||||
final provider = factory.create(config);
|
||||
expect(provider, isA<Rule34Provider>());
|
||||
final r34 = provider as Rule34Provider;
|
||||
expect(r34.id, 'rule34');
|
||||
expect(r34.baseUrl, 'https://api.rule34.xxx');
|
||||
expect(r34.queryParameters['api_key'], 'test_key');
|
||||
expect(r34.queryParameters['user_id'], '99999');
|
||||
});
|
||||
}
|
||||
@@ -43,6 +43,17 @@ void main() {
|
||||
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
|
||||
});
|
||||
|
||||
test('parses query preserving and operators and grouping', () {
|
||||
expect(
|
||||
service.parseTags('cat and dog and fox'),
|
||||
['cat', 'and', 'dog', 'and', 'fox'],
|
||||
);
|
||||
expect(
|
||||
service.parseTags('(cat and dog) anthro'),
|
||||
['(cat', 'and', 'dog)', 'anthro'],
|
||||
);
|
||||
});
|
||||
|
||||
test('recent searches are ordered by searchedAt', () async {
|
||||
await service.saveSearch('old', 1);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/app/app_strings.dart';
|
||||
import 'package:gel_rule_app/backend/models/cloud_media_link.dart';
|
||||
import 'package:gel_rule_app/features/post/presentation/widgets/cloud_mirrors_card.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('CloudMirrorsCard renders mirrors, password banner, and commentary', (tester) async {
|
||||
const links = [
|
||||
CloudMediaLink(
|
||||
url: 'https://mega.nz/file/xyz123#abc',
|
||||
service: CloudServiceType.mega,
|
||||
title: 'MEGA 4K Archive',
|
||||
isFolder: false,
|
||||
isStreamable: false,
|
||||
detectedPassword: 'archive_pass_42',
|
||||
),
|
||||
CloudMediaLink(
|
||||
url: 'https://drive.google.com/file/d/123/view',
|
||||
service: CloudServiceType.googleDrive,
|
||||
title: 'Google Drive Stream',
|
||||
directStreamUrl: 'https://drive.google.com/uc?export=download&id=123',
|
||||
isFolder: false,
|
||||
isStreamable: true,
|
||||
),
|
||||
];
|
||||
|
||||
var playedStream = '';
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: CloudMirrorsCard(
|
||||
links: links,
|
||||
strings: const AppStrings('ru'),
|
||||
commentary: 'Special Patreon bonus animation for all patrons.',
|
||||
onPlayStream: (stream) => playedStream = stream,
|
||||
initiallyExpanded: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify header and counts
|
||||
expect(find.text('Облачные диски и зеркала (2)'), findsOneWidget);
|
||||
expect(find.text('Плеер'), findsOneWidget);
|
||||
|
||||
// Verify password banner
|
||||
expect(find.textContaining('archive_pass_42'), findsOneWidget);
|
||||
|
||||
// Verify link titles and service names
|
||||
expect(find.text('MEGA'), findsOneWidget);
|
||||
expect(find.text('MEGA 4K Archive'), findsOneWidget);
|
||||
expect(find.text('Google Drive'), findsOneWidget);
|
||||
expect(find.text('Google Drive Stream'), findsOneWidget);
|
||||
|
||||
// Verify commentary
|
||||
expect(find.text('Описание от автора'), findsOneWidget);
|
||||
expect(find.text('Special Patreon bonus animation for all patrons.'), findsOneWidget);
|
||||
|
||||
// Verify play button triggers callback
|
||||
final playBtn = find.text('Смотреть');
|
||||
expect(playBtn, findsOneWidget);
|
||||
await tester.tap(playBtn);
|
||||
expect(playedStream, 'https://drive.google.com/uc?export=download&id=123');
|
||||
});
|
||||
|
||||
testWidgets('CloudMirrorsCard renders nothing if no links and no commentary', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CloudMirrorsCard(
|
||||
links: [],
|
||||
strings: AppStrings('ru'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(Container), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/downloaded_media.dart';
|
||||
import 'package:gel_rule_app/backend/services/downloaded_media_service.dart';
|
||||
import 'package:gel_rule_app/features/post/presentation/widgets/post_action_bar.dart';
|
||||
|
||||
void main() {
|
||||
group('DownloadedMediaService formatting & utility tests', () {
|
||||
test('formatBytes formats B, KB, MB, GB properly', () {
|
||||
expect(DownloadedMediaService.formatBytes(500), '500 B');
|
||||
expect(DownloadedMediaService.formatBytes(1536), '1.5 KB');
|
||||
expect(DownloadedMediaService.formatBytes(2 * 1024 * 1024), '2.0 MB');
|
||||
expect(
|
||||
DownloadedMediaService.formatBytes(
|
||||
(2.5 * 1024 * 1024 * 1024).toInt()),
|
||||
'2.50 GB',
|
||||
);
|
||||
});
|
||||
|
||||
test('getFileSizeSync handles empty or missing path safely', () {
|
||||
final media = DownloadedMedia(
|
||||
cacheKey: 'danbooru:1',
|
||||
providerId: 'danbooru',
|
||||
postId: '1',
|
||||
savedPath: '/non/existent/path.jpg',
|
||||
fileName: 'path.jpg',
|
||||
downloadedAt: DateTime(2026, 1, 1),
|
||||
status: 'completed',
|
||||
);
|
||||
|
||||
expect(DownloadedMediaService.getFileSizeSync(media), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('PostActionBar Action Dock 2.0 tests', () {
|
||||
testWidgets('renders primary action dock buttons', (tester) async {
|
||||
bool favoriteTapped = false;
|
||||
bool downloadTapped = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PostActionBar(
|
||||
isFavorite: false,
|
||||
downloaded: false,
|
||||
labels: const PostActionLabels(),
|
||||
onFavorite: () => favoriteTapped = true,
|
||||
onDownload: () => downloadTapped = true,
|
||||
onCollection: () {},
|
||||
onShare: () {},
|
||||
onOpen: () {},
|
||||
onOpenSource: () {},
|
||||
onCopy: () {},
|
||||
onSimilar: () {},
|
||||
onHide: () {},
|
||||
onDeleteLocalFile: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify buttons exist
|
||||
expect(find.byIcon(Icons.favorite_border_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.download_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.bookmark_add_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.share_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
|
||||
|
||||
// Tap favorite
|
||||
await tester.tap(find.byIcon(Icons.favorite_border_rounded));
|
||||
expect(favoriteTapped, isTrue);
|
||||
|
||||
// Tap download
|
||||
await tester.tap(find.byIcon(Icons.download_rounded));
|
||||
expect(downloadTapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('shows downloaded status badge when downloaded is true',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PostActionBar(
|
||||
isFavorite: true,
|
||||
downloaded: true,
|
||||
labels: const PostActionLabels(),
|
||||
onFavorite: () {},
|
||||
onDownload: () {},
|
||||
onCollection: () {},
|
||||
onShare: () {},
|
||||
onOpen: () {},
|
||||
onOpenSource: () {},
|
||||
onCopy: () {},
|
||||
onSimilar: () {},
|
||||
onHide: () {},
|
||||
onDeleteLocalFile: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.favorite_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.offline_pin_rounded), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/backend.dart';
|
||||
import 'package:gel_rule_app/features/settings/presentation/settings_controller.dart';
|
||||
import 'package:gel_rule_app/features/settings/presentation/settings_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('SettingsScreen renders category quick nav and sections without overflow', (tester) async {
|
||||
// Test on typical mobile viewport: 390x844
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final settings = AppSettings.defaults.copyWith(
|
||||
languageCode: 'ru',
|
||||
themeMode: 'dark',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsControllerProvider.overrideWith(
|
||||
() => _FakeSettingsController(settings),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: SettingsScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify pinned Category Quick Nav renders
|
||||
expect(find.text('Основное'), findsWidgets);
|
||||
expect(find.text('Внешний вид'), findsWidgets);
|
||||
|
||||
// Verify Theme mode segment buttons
|
||||
expect(find.text('Темная'), findsOneWidget);
|
||||
expect(find.text('Светлая'), findsOneWidget);
|
||||
|
||||
// Tap a quick nav chip to test scrolling
|
||||
await tester.tap(find.text('Внешний вид').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify language segment is visible and tappable
|
||||
expect(find.text('🇷🇺 Русский'), findsOneWidget);
|
||||
expect(find.text('🇬🇧 English'), findsOneWidget);
|
||||
|
||||
// Tap English to test selection
|
||||
await tester.tap(find.text('🇬🇧 English'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSettingsController extends SettingsController {
|
||||
_FakeSettingsController(this._initial);
|
||||
|
||||
final AppSettings _initial;
|
||||
|
||||
@override
|
||||
Future<AppSettings> build() async => _initial;
|
||||
|
||||
@override
|
||||
Future<void> saveSettings(AppSettings settings) async {
|
||||
state = AsyncData(settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import 'package:flutter/material.dart' hide SearchController;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/app/app.dart';
|
||||
import 'package:gel_rule_app/backend/backend.dart';
|
||||
import 'package:gel_rule_app/features/collections/presentation/collections_controller.dart';
|
||||
import 'package:gel_rule_app/features/collections/presentation/collections_screen.dart';
|
||||
import 'package:gel_rule_app/features/favorites/presentation/favorites_controller.dart';
|
||||
import 'package:gel_rule_app/features/favorites/presentation/favorites_screen.dart';
|
||||
import 'package:gel_rule_app/features/favorites/presentation/favorites_state.dart';
|
||||
import 'package:gel_rule_app/features/search/presentation/search_controller.dart'
|
||||
as search_feature;
|
||||
import 'package:gel_rule_app/features/search/presentation/search_screen.dart';
|
||||
import 'package:gel_rule_app/features/search/presentation/search_state.dart';
|
||||
import 'package:gel_rule_app/features/viewed/presentation/viewed_controller.dart';
|
||||
import 'package:gel_rule_app/features/viewed/presentation/viewed_screen.dart';
|
||||
|
||||
Post _samplePost({
|
||||
required String id,
|
||||
String providerId = 'danbooru',
|
||||
String providerName = 'Danbooru',
|
||||
List<String> tags = const ['tag1', 'artist:test_artist'],
|
||||
String fileType = 'jpg',
|
||||
}) {
|
||||
return Post(
|
||||
id: id,
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
previewUrl: 'https://example.com/$id.jpg',
|
||||
sampleUrl: 'https://example.com/$id.jpg',
|
||||
fileUrl: 'https://example.com/$id.jpg',
|
||||
tags: tags,
|
||||
rating: 'safe',
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: fileType,
|
||||
score: 42,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
testWidgets('FavoritesScreen renders segments, counters and filter chips',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final post = _samplePost(id: '1');
|
||||
final post2 = _samplePost(
|
||||
id: '2', fileType: 'mp4', tags: ['artist:animator', 'video']);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
favoritesControllerProvider.overrideWith(
|
||||
() => _FakeFavoritesController([post, post2]),
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
downloadedMediaByKeysProvider([post.cacheKey, post2.cacheKey])
|
||||
.overrideWith((ref) => <String, DownloadedMedia>{}),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: FavoritesScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Verify segmented button labels with live counts
|
||||
expect(find.text('Все (2)'), findsOneWidget);
|
||||
expect(find.text('Офлайн (0)'), findsOneWidget);
|
||||
expect(find.text('Авторы (2)'), findsOneWidget);
|
||||
|
||||
// Verify media filter chips
|
||||
expect(find.text('Все'), findsWidgets);
|
||||
expect(find.text('Видео'), findsOneWidget);
|
||||
expect(find.text('Фото'), findsOneWidget);
|
||||
|
||||
// Switch to Artists tab
|
||||
await tester.tap(find.text('Авторы (2)'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('test_artist'), findsOneWidget);
|
||||
expect(find.text('animator'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ViewedScreen renders timeline and switches view mode',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final post = _samplePost(id: '10', tags: ['landscape', 'sunset']);
|
||||
final group = ViewedTimelineGroup(
|
||||
label: 'Today',
|
||||
items: [
|
||||
ViewedPostEntry(post: post, viewedAt: DateTime.now()),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
viewedControllerProvider.overrideWith(
|
||||
() => _FakeViewedController([group]),
|
||||
),
|
||||
favoriteKeysProvider.overrideWith(
|
||||
(ref) => <String>{},
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: ViewedScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Verify timeline group
|
||||
expect(find.text('Сегодня'), findsOneWidget);
|
||||
expect(find.text('landscape sunset'), findsOneWidget);
|
||||
|
||||
// Switch to Grid mode
|
||||
final gridBtn = find.byIcon(Icons.grid_view_rounded);
|
||||
expect(gridBtn, findsOneWidget);
|
||||
await tester.tap(gridBtn);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Now icon should switch to list mode
|
||||
expect(find.byIcon(Icons.view_agenda_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'CollectionsScreen renders collection cards and new collection button',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final collection = Collection(
|
||||
id: 'c1',
|
||||
name: 'Cyberpunk Art',
|
||||
description: 'Futuristic neon cities',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
collectionsControllerProvider.overrideWith(
|
||||
() => _FakeCollectionsController([collection]),
|
||||
),
|
||||
collectionPostsProvider('c1').overrideWith(
|
||||
(ref) => Future.value([_samplePost(id: '1')]),
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: CollectionsScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Cyberpunk Art'), findsOneWidget);
|
||||
expect(find.text('Futuristic neon cities'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'SearchScreen renders hero search bar, quick chips, and categories',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
search_feature.searchControllerProvider.overrideWith(
|
||||
() => _FakeSearchController([
|
||||
SearchHistory(
|
||||
id: 'h1',
|
||||
query: 'genshin_impact',
|
||||
tags: const ['genshin_impact'],
|
||||
searchedAt: DateTime.now(),
|
||||
resultCount: 1500,
|
||||
),
|
||||
]),
|
||||
),
|
||||
appSettingsProvider.overrideWith(
|
||||
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: SearchScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// Verify Search Bar and Quick Operator Chips
|
||||
expect(find.text('type:video'), findsOneWidget);
|
||||
expect(find.text('rating:safe'), findsOneWidget);
|
||||
|
||||
// Verify Recent Searches chip
|
||||
expect(find.text('genshin_impact'), findsOneWidget);
|
||||
|
||||
// Verify Popular Categories
|
||||
expect(find.text('Тематика'), findsOneWidget);
|
||||
expect(find.text('Персонажи и детали'), findsOneWidget);
|
||||
expect(find.text('Провайдеры'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFavoritesController extends FavoritesController {
|
||||
_FakeFavoritesController(this._posts);
|
||||
|
||||
final List<Post> _posts;
|
||||
|
||||
@override
|
||||
Future<FavoritesState> build() async {
|
||||
return FavoritesState(posts: _posts);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeViewedController extends ViewedController {
|
||||
_FakeViewedController(this._groups);
|
||||
|
||||
final List<ViewedTimelineGroup> _groups;
|
||||
|
||||
@override
|
||||
Future<List<ViewedTimelineGroup>> build() async => _groups;
|
||||
}
|
||||
|
||||
class _FakeCollectionsController extends CollectionsController {
|
||||
_FakeCollectionsController(this._collections);
|
||||
|
||||
final List<Collection> _collections;
|
||||
|
||||
@override
|
||||
Future<List<Collection>> build() async => _collections;
|
||||
}
|
||||
|
||||
class _FakeSearchController extends search_feature.SearchController {
|
||||
_FakeSearchController(this._history);
|
||||
|
||||
final List<SearchHistory> _history;
|
||||
|
||||
@override
|
||||
Future<SearchState> build() async {
|
||||
return SearchState(
|
||||
recent: _history,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,174 @@ void main() {
|
||||
expect(size.height, lessThan(96));
|
||||
expect(find.text('touhou'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('preserves AND operator chips when submitted', (tester) async {
|
||||
String submitted = '';
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'cat and dog',
|
||||
onSubmitted: (query) => submitted = query,
|
||||
),
|
||||
));
|
||||
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
expect(find.text('AND'), findsOneWidget);
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
|
||||
await tester.showKeyboard(find.byType(TextField));
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(submitted, 'cat and dog');
|
||||
});
|
||||
|
||||
testWidgets('applying suggestion preserves preceding tokens and AND operator',
|
||||
(tester) async {
|
||||
String applied = '';
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'dog',
|
||||
category: TagCategory.general,
|
||||
postCount: 50,
|
||||
providerId: 'test',
|
||||
),
|
||||
],
|
||||
onSuggestionApplied: (query) => applied = query,
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'cat and d');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
await tester.tap(find.text('dog'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(applied, 'cat and dog');
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
expect(find.text('AND'), findsOneWidget);
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deleting chip triggers onTagRemoved', (tester) async {
|
||||
String? removedQuery;
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'cat dog',
|
||||
onSubmitted: (_) {},
|
||||
onTagRemoved: (query) => removedQuery = query,
|
||||
),
|
||||
));
|
||||
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.close_rounded).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(removedQuery, 'dog');
|
||||
expect(find.text('cat'), findsNothing);
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('clear button triggers onCleared and onSubmitted with empty string',
|
||||
(tester) async {
|
||||
bool cleared = false;
|
||||
String? submitted;
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'cat dog',
|
||||
onSubmitted: (query) => submitted = query,
|
||||
onCleared: () => cleared = true,
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byTooltip('Clear'));
|
||||
await tester.pump();
|
||||
|
||||
expect(cleared, isTrue);
|
||||
expect(submitted, '');
|
||||
expect(find.text('cat'), findsNothing);
|
||||
expect(find.text('dog'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping external button while suggestions are visible triggers button and hides suggestions',
|
||||
(tester) async {
|
||||
bool externalButtonPressed = false;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TagInputSearchBar(
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'genshin',
|
||||
category: TagCategory.copyright,
|
||||
postCount: 100,
|
||||
providerId: 'test',
|
||||
),
|
||||
],
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => externalButtonPressed = true,
|
||||
child: const Text('Обновить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'gen');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('genshin'), findsOneWidget);
|
||||
|
||||
// Tap external 'Обновить' button directly while suggestions are up
|
||||
await tester.tap(find.text('Обновить'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(externalButtonPressed, isTrue);
|
||||
expect(find.text('genshin'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('erasing draft immediately hides suggestions', (tester) async {
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'genshin',
|
||||
category: TagCategory.copyright,
|
||||
postCount: 100,
|
||||
providerId: 'test',
|
||||
),
|
||||
],
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'gen');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('genshin'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('genshin'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
class _Harness extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user