Compare commits

...
14 Commits
Author SHA1 Message Date
RarDog 639bfc50af feat: artist cloud drives & external video mirrors (v2.3.0) 2026-09-04 12:08:01 +03:00
RarDog f603c37c91 feat(offline+post): Offline Mode Overhaul & Post Details Screen Redesign (v2.2.0) 2026-09-04 11:40:19 +03:00
RarDog c6a9dcb4d5 feat: 4-tab major redesign: Search, Favorites, History & Collections v2.1.0 2026-09-04 11:12:38 +03:00
RarDog b33248ef9d fix: settings vertical text wrapping and pinned quick navigation v2.0.15 2026-09-04 10:51:25 +03:00
RarDog 1b08194666 feat: Settings 2.0 modern grouped redesign & ergonomics v2.0.14 2026-09-04 10:40:39 +03:00
RarDog 37f2dbf47d feat: toolbar section dividers & post card badge stacking deconfliction v2.0.13 2026-09-03 23:13:55 +03:00
RarDog 4bbe04dfd8 feat: Feed 2.0 redesign (modern squircle cards, compact toolbar, sleek glass badges) v2.0.12 2026-09-03 23:04:32 +03:00
RarDog e8448eb748 feat: mobile video player redesign and format recognition fix (v2.0.11) 2026-09-03 22:49:11 +03:00
RarDog 7e384b1be7 fix: preserve AND tag operator during search and suggestion draft completions (v2.0.10) 2026-09-03 22:17:55 +03:00
RarDog 5560332b66 feat: instant tag suggestions, persistent disk cache, history limit settings, match highlighting and category badges (v2.0.9) 2026-09-03 21:56:52 +03:00
RarDog 4acd6c29e0 feat: release v2.0.8 with trailing context modifier distribution (e.g. cat and dog anthro) 2026-09-03 21:28:02 +03:00
RarDog fb31a303ff fix: resolve No post yet by adapting postMatchesRequestedTags to tag groups and filtering 'and' from provider queries 2026-09-03 21:15:01 +03:00
RarDog a8b778146d fix: adjust multi-tag search so space groups tags together and 'and' splits independent query streams 2026-09-03 21:06:06 +03:00
RarDog 69d8bfc6bf feat: release v2.0.7 with independent multi-tag search, local 'and' connector, Pawchive in feed, and reverted artist media filters/links 2026-09-03 21:00:11 +03:00
58 changed files with 9559 additions and 1899 deletions
+19 -7
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.6-blueviolet?style=for-the-badge" alt="Version 2.0.6" />
<img src="https://img.shields.io/badge/Version-2.3.0-blueviolet?style=for-the-badge" alt="Version 2.3.0" />
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/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,11 +20,23 @@
## ✨ Основные возможности
- **🌐 Общая лента Pawchive**: Недавние посты со всех платформ прямо в главной ленте (Feed) с поддержкой поиска.
- **💾 Офлайн 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 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
- **🎯 Общие модификаторы в мультипоиске**: При вводе `cat and dog anthro` модификатор `anthro` автоматически применится к обоим тегам (`cat anthro` + `dog anthro`).
- **🔀 Раздельный мульти-поиск по тегам через `and`**: Оператор `and` опрашивает серверную базу по каждому тегу независимо и плавно чередует посты обоих тегов в общей ленте.
- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru.
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
- **🔗 Связанные аккаунты**: Удобный переход между профилями автора на разных платформах (Patreon, Fanbox, Discord и др.).
- **🎬 Серверная фильтрация по типу медиа**: Поиск фото, видео и GIF по всей глубине архива автора через API.
- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек.
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
@@ -65,9 +77,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.6.apk`
- **Linux (x64)**: `Lunaris-v2.0.6-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.6.exe` / `LunarisPortable-v2.0.6.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`
---
+1
View File
@@ -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';
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.6';
const appBuildNumber = 28;
const appDisplayVersion = '2.3.0';
const appBuildNumber = 40;
const appReleaseChannel = 'Stable';
+137
View File
@@ -1,4 +1,141 @@
const lunarisChangelog = [
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',
bullets: [
'Instant 0ms tag suggestions: cached tags and search history are served immediately without network delay.',
'Persistent disk cache: tag suggestions are preserved in lunaris_tag_cache.json across app restarts on Android and PC.',
'Debounce reduced from 530ms to 100ms+160ms for ultra-responsive typing.',
'Match highlighting: matched characters are bolded and highlighted with the primary theme color in the dropdown.',
'Vibrant category badges: distinct colors for Artist (red), Character (green), Copyright (purple), Species (blue), and Meta (grey).',
'Danbooru tag autocomplete support connected (/tags/autocomplete.json).',
'Configurable search history limit (up to 2000 items) and tag cache limit (up to 20000 tags) in Settings.',
'Dedicated Clear Tag Cache actions in Settings and Cache Manager.',
],
),
LunarisChange(
version: '2.0.8',
title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering',
bullets: [
'Trailing context modifiers: typing "cat and dog anthro" automatically distributes "anthro" to all "and" branches (cat anthro + dog anthro).',
'Optional grouping parenthesis support: e.g. "(cat and dog) anthro".',
'Resolved "No posts yet" bug by adapting client post validation to multi-tag stream groups and stripping operator keywords from booru queries.',
],
),
LunarisChange(
version: '2.0.7',
title: 'Independent Multi-Tag Search Streams, Local "and" Connector, Pawchive in Main Feed & Reverts',
bullets: [
'Independent multi-tag search: separate tags query providers individually and interleave results so each tag brings its own content without mutual exclusion.',
'Local "and" tag connector: typing "and" between tags groups them into a strict intersection query (e.g. "genshin and raiden").',
'Enabled Pawchive in the main feed alongside Rule34, Gelbooru, e621, and Realbooru.',
'Restored instantaneous client-side media type filtering in artist posts.',
'Removed external linked accounts bar from artist posts screen.',
],
),
LunarisChange(
version: '2.0.6',
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
+2
View File
@@ -7,6 +7,7 @@ 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';
@@ -28,6 +29,7 @@ export 'services/favorite_service.dart';
export 'services/feed_service.dart';
export 'services/provider_check_service.dart';
export 'services/search_service.dart';
export 'services/tag_cache_service.dart';
export 'services/settings_service.dart';
export 'services/update_service.dart';
export 'services/viewed_history_service.dart';
+15
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -28,6 +30,7 @@ import '../services/favorite_service.dart';
import '../services/feed_service.dart';
import '../services/provider_check_service.dart';
import '../services/search_service.dart';
import '../services/tag_cache_service.dart';
import '../services/settings_service.dart';
import '../services/update_service.dart';
import '../services/viewed_history_service.dart';
@@ -125,10 +128,17 @@ final viewedKeysProvider = FutureProvider<Set<String>>((ref) async {
return result is Success<Set<String>> ? result.data : <String>{};
});
final tagCacheServiceProvider = Provider<TagCacheService>((ref) {
final service = TagCacheService();
unawaited(service.init());
return service;
});
final searchServiceProvider = Provider<SearchService>((ref) {
return SearchService(
ref.watch(searchRepositoryProvider),
ref.watch(providerManagerProvider),
ref.watch(tagCacheServiceProvider),
);
});
@@ -184,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 =
+155
View File
@@ -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;
}
}
+25
View File
@@ -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,28 @@ 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;
}
Post copyWith({
String? id,
String? providerId,
+24
View File
@@ -22,6 +22,30 @@ class TagSuggestion {
final String providerId;
String get categoryLabel => category.name;
Map<String, dynamic> toJson() => {
'name': name,
'category': category.name,
'postCount': postCount,
'providerId': providerId,
};
factory TagSuggestion.fromJson(Map<String, dynamic> json) => TagSuggestion(
name: (json['name'] ?? '').toString(),
category: tagCategoryFromString((json['category'] ?? '').toString()),
postCount: (json['postCount'] as num?)?.toInt() ?? 0,
providerId: (json['providerId'] ?? '').toString(),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TagSuggestion &&
runtimeType == other.runtimeType &&
name.toLowerCase() == other.name.toLowerCase();
@override
int get hashCode => name.toLowerCase().hashCode;
}
TagCategory tagCategoryFromString(String? value) {
+51 -1
View File
@@ -5,11 +5,16 @@ import '../mappers/danbooru_mapper.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class DanbooruProvider
implements ContentProvider, CommentProvider, PostPageProvider {
implements
ContentProvider,
CommentProvider,
PostPageProvider,
TagSuggestionProvider {
DanbooruProvider({
required this.id,
required this.name,
@@ -171,4 +176,49 @@ class DanbooruProvider
'${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}';
}
@override
Future<List<TagSuggestion>> suggestTags(
String query, {
int limit = 20,
}) async {
final cleanQuery = query
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.toLowerCase();
if (cleanQuery.isEmpty) return const [];
try {
final response = await _dio.get<dynamic>(
'/tags/autocomplete.json',
queryParameters: {
'search[name_matches]': cleanQuery,
'limit': limit.clamp(1, 50),
..._queryParameters,
},
);
final items = response.data is List ? response.data as List : const [];
return items
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.from(item);
final name = (json['value'] ?? json['name'] ?? '').toString();
return TagSuggestion(
name: name,
category: tagCategoryFromString(json['category']?.toString()),
postCount: _int(json['post_count']),
providerId: id,
);
})
.where((tag) => tag.name.isNotEmpty)
.toList(growable: false);
} catch (_) {
return const [];
}
}
int _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
}
+57 -2
View File
@@ -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],
},
);
})
+56 -2
View File
@@ -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],
},
);
})
+144 -2
View File
@@ -163,6 +163,68 @@ class ProviderManager {
return Success(results);
}
static List<List<String>> splitTagGroups(List<String> rawTags) {
final segments = <List<String>>[];
List<String> current = [];
for (final raw in rawTags) {
var token = raw.trim();
token = token.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
if (token.isEmpty) continue;
if (token.toLowerCase() == 'and') {
if (current.isNotEmpty) {
segments.add(current);
current = [];
}
} else {
current.add(token);
}
}
if (current.isNotEmpty) {
segments.add(current);
}
if (segments.isEmpty) {
return [const []];
}
if (segments.length == 1) {
return segments;
}
// Multiple segments separated by 'and'
// If the last segment has trailing tags after the branch tag (e.g. 'cat and dog anthro'):
// The trailing tags apply as common modifier tags to ALL branches!
final lastSegment = segments.last;
final prevSegment = segments[segments.length - 2];
final branchLen = prevSegment.length.clamp(1, lastSegment.length);
if (lastSegment.length > branchLen) {
final commonTail = lastSegment.sublist(branchLen);
final adjustedSegments = <List<String>>[];
for (int i = 0; i < segments.length; i++) {
final seg = i == segments.length - 1
? lastSegment
: [...segments[i], ...commonTail];
final seen = <String>{};
final deduped = <String>[];
for (final t in seg) {
if (seen.add(t.toLowerCase())) {
deduped.add(t);
}
}
adjustedSegments.add(deduped);
}
return adjustedSegments;
}
return segments;
}
Future<Result<List<Post>>> searchAcrossProviders({
required List<String> tags,
required int page,
@@ -181,12 +243,68 @@ class ProviderManager {
providers.where((provider) => provider.id == providerId).toList();
}
final tagGroups = splitTagGroups(tags);
if (tagGroups.length <= 1) {
final effectiveTags = tagGroups.isEmpty ? tags : tagGroups.first;
return _searchForTagGroup(
providers: providers,
tags: effectiveTags,
page: page,
limit: limit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}
// Multiple independent tag groups: query each tag group as if other tags are not present, then interleave
final seen = <String>{};
final groupPerLimit = (limit / tagGroups.length).ceil().clamp(10, 50);
final groupFutures = tagGroups.map((groupTags) {
return _searchForTagGroup(
providers: providers,
tags: groupTags,
page: page,
limit: groupPerLimit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}).toList(growable: false);
final results = await Future.wait(groupFutures);
final validGroupPosts = <List<Post>>[];
for (final res in results) {
if (res is Success<List<Post>>) {
validGroupPosts.add(res.data);
}
}
final interleaved = _interleaveTagGroupResults(validGroupPosts, seen);
return Success(interleaved);
}
Future<Result<List<Post>>> _searchForTagGroup({
required List<ContentProvider> providers,
required List<String> tags,
required int page,
required int limit,
required String? rating,
required String? providerId,
required TopPeriodFilter topPeriod,
}) async {
final cleanTags = tags
.map((t) => t.trim())
.where((t) => t.isNotEmpty && t.toLowerCase() != 'and')
.toList(growable: false);
final seen = <String>{};
final providerResults = <List<Post>>[];
for (final provider in providers) {
try {
final providerPosts = await provider.searchPosts(
tags: tags,
tags: cleanTags,
page: page,
limit: limit,
rating: rating,
@@ -234,6 +352,29 @@ class ProviderManager {
return Success(posts);
}
List<Post> _interleaveTagGroupResults(
List<List<Post>> groupResults, Set<String> seen) {
final combined = <Post>[];
var hasMore = true;
var index = 0;
while (hasMore) {
hasMore = false;
for (final group in groupResults) {
if (index < group.length) {
hasMore = true;
final post = group[index];
if (seen.add(post.cacheKey)) {
combined.add(post);
}
}
}
index++;
}
return combined;
}
List<Post> _flattenProviderResults(
List<List<Post>> results, Set<String> seen) {
final posts = <Post>[];
@@ -584,7 +725,8 @@ class ProviderManager {
}
static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config);
final type = config.apiType.toLowerCase();
return type != 'kemono' && type != 'coomer';
}
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
@@ -10,7 +10,7 @@ class SearchRepository {
final DatabaseService _databaseService;
Future<Result<void>> save(SearchHistory history) {
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) {
return _databaseService.safeWrite((isar) async {
await isar.searchHistoryEntitys.put(
SearchHistoryEntity()
@@ -20,6 +20,21 @@ class SearchRepository {
..searchedAt = history.searchedAt
..resultCount = history.resultCount,
);
final count = await isar.searchHistoryEntitys.count();
if (count > maxItems) {
final items = await isar.searchHistoryEntitys.where().findAll();
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
final toRemove = items.skip(maxItems);
for (final item in toRemove) {
await isar.searchHistoryEntitys.delete(item.isarId);
}
}
});
}
Future<Result<int>> count() {
return _databaseService.safeRead((isar) async {
return await isar.searchHistoryEntitys.count();
});
}
@@ -32,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,310 @@
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 text (stripping HTML tags) suitable for author commentary display.
static String cleanCommentary(String? rawHtml) {
if (rawHtml == null || rawHtml.trim().isEmpty) return '';
var text = rawHtml
.replaceAll(RegExp(r'<br\s*\/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<\/p>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
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';
}
}
+22 -13
View File
@@ -112,20 +112,29 @@ class FeedService {
}
bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
final requested = requestedTags
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-'))
.toList(growable: false);
if (requested.isEmpty) return true;
if (post.providerId == 'pawchive') return true;
final groups = ProviderManager.splitTagGroups(requestedTags);
if (groups.isEmpty) return true;
final postTags = _postTagSet(post);
return requested.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
if (postTags.isEmpty) return true;
return groups.any((group) {
final cleanGroup = group
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-') && tag != 'and')
.toList(growable: false);
if (cleanGroup.isEmpty) return true;
return cleanGroup.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
});
}
bool postPassesTagFilters(Post post, AppSettings settings) {
+135 -21
View File
@@ -1,43 +1,101 @@
import 'dart:async';
import '../../core/utils/result.dart';
import '../models/search_history.dart';
import '../models/tag_suggestion.dart';
import '../providers/provider_manager.dart';
import '../repositories/search_repository.dart';
import 'tag_cache_service.dart';
class SearchService {
SearchService(this._repository, [this._providerManager]);
SearchService(
this._repository, [
this._providerManager,
this._tagCacheService,
]);
final SearchRepository _repository;
final ProviderManager? _providerManager;
final TagCacheService? _tagCacheService;
List<String> parseTags(String query) {
return query
TagCacheService? get tagCacheService => _tagCacheService;
static String sanitizeToken(String raw) {
return raw
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.split(RegExp(r'\s+'))
.where((tag) => tag.isNotEmpty)
.toSet()
.toList();
.toLowerCase();
}
Future<Result<void>> saveSearch(String query, int resultCount) {
List<String> parseTags(String query) {
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(
String query,
int resultCount, {
int maxHistory = 500,
}) async {
final tags = parseTags(query);
return _repository.save(
SearchHistory(
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
query: query.trim(),
tags: tags,
searchedAt: DateTime.now(),
resultCount: resultCount,
),
final history = SearchHistory(
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
query: query.trim(),
tags: tags,
searchedAt: DateTime.now(),
resultCount: resultCount,
);
// Also register user-searched tags in persistent tag cache
if (_tagCacheService != null && tags.isNotEmpty) {
unawaited(
_tagCacheService.cacheTags(
tags.map(
(tag) => TagSuggestion(
name: tag,
category: TagCategory.general,
postCount: resultCount,
providerId: 'history',
),
),
),
);
}
return _repository.save(history, maxItems: maxHistory);
}
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
return _repository.recent(limit: limit);
}
Future<Result<List<String>>> autocomplete(String prefix,
{int limit = 10}) async {
/// Instant local tag suggestions (0 ms) from disk cache & recent search history.
List<TagSuggestion> instantSuggestions(
String prefix, {
int limit = 16,
Iterable<String>? priorityTags,
}) {
final clean = sanitizeToken(prefix);
if (clean.isEmpty || clean == 'and') return const [];
return _tagCacheService?.findLocal(
clean,
limit: limit,
priorityTags: priorityTags,
) ??
const [];
}
Future<Result<List<String>>> autocomplete(
String prefix, {
int limit = 10,
}) async {
final result = await autocompleteDetailed(prefix, limit: limit);
return result.fold(
onSuccess: (items) => Success(items.map((item) => item.name).toList()),
@@ -47,12 +105,68 @@ class SearchService {
Future<Result<List<TagSuggestion>>> autocompleteDetailed(
String prefix, {
int limit = 10,
int limit = 16,
int tagCacheLimit = 5000,
Iterable<String>? priorityTags,
}) async {
final clean = sanitizeToken(prefix);
if (clean.isEmpty || clean == 'and') return const Success([]);
final manager = _providerManager;
if (manager == null || prefix.trim().isEmpty) return const Success([]);
return manager.suggestTags(prefix.trim(), limit: limit);
if (manager == null) {
final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
return Success(local);
}
final netResult = await manager.suggestTags(clean, limit: limit);
if (netResult is Error<List<TagSuggestion>>) {
// Fallback to local cached suggestions if network fails
final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
return Success(local);
}
final remoteItems = (netResult as Success<List<TagSuggestion>>).data;
// Cache remote suggestions to disk
if (_tagCacheService != null && remoteItems.isNotEmpty) {
unawaited(_tagCacheService.cacheTags(remoteItems, maxLimit: tagCacheLimit));
}
// Merge local matches with remote results
final merged = <String, TagSuggestion>{};
for (final item in instantSuggestions(clean, limit: limit, priorityTags: priorityTags)) {
merged[item.name.toLowerCase()] = item;
}
for (final item in remoteItems) {
final key = item.name.toLowerCase();
final existing = merged[key];
if (existing == null || item.postCount > existing.postCount) {
merged[key] = item;
}
}
final sorted = merged.values.toList()
..sort((a, b) {
final aKey = a.name.toLowerCase();
final bKey = b.name.toLowerCase();
final aExact = aKey == clean;
final bExact = bKey == clean;
if (aExact != bExact) return aExact ? -1 : 1;
final countCmp = b.postCount.compareTo(a.postCount);
if (countCmp != 0) return countCmp;
return aKey.compareTo(bKey);
});
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 {
await _tagCacheService?.clear();
}
}
@@ -58,6 +58,8 @@ class AppSettings {
this.gridMode = 'masonry',
this.searchPresets = const [],
this.downloadPathTemplate = '{Artist}/{ID}',
this.searchHistoryLimit = 500,
this.tagCacheLimit = 5000,
});
final List<String> favoriteArtists;
@@ -106,6 +108,8 @@ class AppSettings {
final String gridMode;
final List<String> searchPresets;
final String downloadPathTemplate;
final int searchHistoryLimit;
final int tagCacheLimit;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
@@ -149,6 +153,8 @@ class AppSettings {
lastUpdateCheckAt: null,
defaultRatingFilter: null,
lastFeedRating: null,
searchHistoryLimit: 500,
tagCacheLimit: 5000,
);
AppSettings copyWith({
@@ -199,6 +205,8 @@ class AppSettings {
String? gridMode,
List<String>? searchPresets,
String? downloadPathTemplate,
int? searchHistoryLimit,
int? tagCacheLimit,
}) {
return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
@@ -254,6 +262,8 @@ class AppSettings {
gridMode: gridMode ?? this.gridMode,
searchPresets: searchPresets ?? this.searchPresets,
downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate,
searchHistoryLimit: searchHistoryLimit ?? this.searchHistoryLimit,
tagCacheLimit: tagCacheLimit ?? this.tagCacheLimit,
);
}
@@ -304,6 +314,8 @@ class AppSettings {
'gridMode': gridMode,
'searchPresets': searchPresets,
'downloadPathTemplate': downloadPathTemplate,
'searchHistoryLimit': searchHistoryLimit,
'tagCacheLimit': tagCacheLimit,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -411,6 +423,10 @@ class AppSettings {
),
downloadPathTemplate:
(json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
searchHistoryLimit: (json['searchHistoryLimit'] as num?)?.toInt() ??
defaults.searchHistoryLimit,
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
defaults.tagCacheLimit,
);
}
+205
View File
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/tag_suggestion.dart';
class TagCacheService {
TagCacheService({String? customPath}) : _customPath = customPath;
final String? _customPath;
final Map<String, TagSuggestion> _tags = {};
bool _isInitialized = false;
Timer? _saveDebounce;
String? _resolvedPath;
bool get isInitialized => _isInitialized;
int get tagCount => _tags.length;
Future<String> _getCacheFilePath() async {
if (_resolvedPath != null) return _resolvedPath!;
if (_customPath != null) {
_resolvedPath = _customPath;
return _resolvedPath!;
}
final dir = await getApplicationDocumentsDirectory();
_resolvedPath = '${dir.path}/lunaris_tag_cache.json';
return _resolvedPath!;
}
Future<void> init() async {
if (_isInitialized) return;
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
final content = await file.readAsString();
if (content.trim().isNotEmpty) {
final decoded = jsonDecode(content);
if (decoded is List) {
for (final item in decoded) {
if (item is Map) {
final suggestion = TagSuggestion.fromJson(
Map<String, dynamic>.from(item),
);
final key = suggestion.name.trim().toLowerCase();
if (key.isNotEmpty) {
_tags[key] = suggestion;
}
}
}
}
}
}
} catch (_) {
// Non-critical: corrupted cache file can be rebuilt seamlessly
} finally {
_isInitialized = true;
}
}
/// Finds suggestions locally in memory (0 ms response).
List<TagSuggestion> findLocal(
String prefix, {
int limit = 16,
Iterable<String>? priorityTags,
}) {
final cleanPrefix = prefix
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.toLowerCase();
if (cleanPrefix.isEmpty) return const [];
final prioritySet = priorityTags
?.map((t) => t.trim().toLowerCase())
.where((t) => t.isNotEmpty)
.toSet() ??
const <String>{};
final matches = <TagSuggestion>[];
final seen = <String>{};
// First check priority tags (e.g. from recent search history)
for (final tag in prioritySet) {
if (tag.startsWith(cleanPrefix) && !seen.contains(tag)) {
seen.add(tag);
final existing = _tags[tag];
matches.add(
existing ??
TagSuggestion(
name: tag,
category: TagCategory.general,
postCount: 0,
providerId: 'history',
),
);
}
}
// Then check all cached tags
for (final entry in _tags.entries) {
if (seen.contains(entry.key)) continue;
if (entry.key.startsWith(cleanPrefix)) {
seen.add(entry.key);
matches.add(entry.value);
}
}
matches.sort((a, b) {
final aKey = a.name.toLowerCase();
final bKey = b.name.toLowerCase();
final aExact = aKey == cleanPrefix;
final bExact = bKey == cleanPrefix;
if (aExact != bExact) return aExact ? -1 : 1;
final aPriority = prioritySet.contains(aKey);
final bPriority = prioritySet.contains(bKey);
if (aPriority != bPriority) return aPriority ? -1 : 1;
final countCmp = b.postCount.compareTo(a.postCount);
if (countCmp != 0) return countCmp;
return aKey.compareTo(bKey);
});
return matches.take(limit.clamp(1, 50)).toList(growable: false);
}
/// Caches newly discovered suggestions and debounces saving to disk.
Future<void> cacheTags(
Iterable<TagSuggestion> suggestions, {
int maxLimit = 5000,
}) async {
if (!_isInitialized) await init();
var changed = false;
for (final item in suggestions) {
final key = item.name.trim().toLowerCase();
if (key.isEmpty) continue;
final existing = _tags[key];
if (existing == null || item.postCount > existing.postCount) {
_tags[key] = item;
changed = true;
}
}
if (!changed) return;
// Prune least popular tags if exceeding max limit
if (_tags.length > maxLimit) {
final sorted = _tags.values.toList()
..sort((a, b) => b.postCount.compareTo(a.postCount));
_tags.clear();
for (final item in sorted.take(maxLimit)) {
_tags[item.name.toLowerCase()] = item;
}
}
_scheduleSave();
}
void _scheduleSave() {
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 1200), () {
flush();
});
}
Future<void> flush() async {
_saveDebounce?.cancel();
try {
final path = await _getCacheFilePath();
final file = File(path);
final list = _tags.values.map((t) => t.toJson()).toList(growable: false);
final jsonString = jsonEncode(list);
await file.writeAsString(jsonString, flush: true);
} catch (_) {
// Silent error on persistence failure
}
}
Future<int> getFileSizeBytes() async {
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
return await file.length();
}
} catch (_) {}
return 0;
}
Future<void> clear() async {
_saveDebounce?.cancel();
_tags.clear();
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
}
@@ -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();
}
@@ -49,7 +49,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final Set<String> _selectedTypes = <String>{};
List<ArtistTag> _tags = [];
List<ArtistLink> _links = [];
List<ArtistAnnouncement> _announcements = [];
String? _selectedTag;
@@ -87,15 +86,14 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
(item) => (item as ContentProvider).id == widget.providerId,
);
final tagsRes = await provider.getArtistTags(widget.service, widget.artistId);
final linksRes = await provider.getArtistLinks(widget.service, widget.artistId);
final tagsRes =
await provider.getArtistTags(widget.service, widget.artistId);
final annRes =
await provider.getArtistAnnouncements(widget.service, widget.artistId);
if (!mounted) return;
setState(() {
_tags = tagsRes;
_links = linksRes;
_announcements = annRes;
});
} catch (_) {}
@@ -118,18 +116,11 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_error = null;
});
String? queryText;
if (_selectedTypes.length == 1) {
if (_selectedTypes.contains('video')) queryText = 'mp4';
if (_selectedTypes.contains('gif')) queryText = 'gif';
}
final query = ArtistWorkQuery(
providerId: widget.providerId,
service: widget.service,
artistId: widget.artistId,
artistName: widget.artistName,
queryText: queryText,
tagFilter: _selectedTag,
);
@@ -191,21 +182,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_loadMore();
}
void _onMediaTypeToggled(String type, bool selected) {
setState(() {
if (selected) {
_selectedTypes.add(type);
} else {
_selectedTypes.remove(type);
}
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
bool _matchesTypeFilter(Post post) {
if (_selectedTypes.isEmpty) return true;
final isVid = MediaUrlSelector.isVideo(post);
@@ -299,18 +275,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
FilterChip(
selected: _selectedTypes.isEmpty,
label: Text('Все (${_posts.length})'),
onSelected: (_) {
if (_selectedTypes.isNotEmpty) {
setState(() {
_selectedTypes.clear();
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
},
onSelected: (_) => setState(() => _selectedTypes.clear()),
),
const SizedBox(width: 8),
FilterChip(
@@ -321,7 +286,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Фото ($photoCount)'),
onSelected: (selected) => _onMediaTypeToggled('photo', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('photo');
} else {
_selectedTypes.remove('photo');
}
});
},
),
const SizedBox(width: 8),
FilterChip(
@@ -332,7 +305,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Видео ($videoCount)'),
onSelected: (selected) => _onMediaTypeToggled('video', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('video');
} else {
_selectedTypes.remove('video');
}
});
},
),
const SizedBox(width: 8),
FilterChip(
@@ -343,7 +324,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('GIF ($gifCount)'),
onSelected: (selected) => _onMediaTypeToggled('gif', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('gif');
} else {
_selectedTypes.remove('gif');
}
});
},
),
],
),
@@ -382,58 +371,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
);
}
Widget _buildLinksBar() {
if (_links.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final link in _links) ...[
ActionChip(
avatar: Icon(
_serviceIcon(link.service),
size: 16,
color: theme.colorScheme.primary,
),
label: Text('${_serviceLabel(link.service)}: ${link.name}'),
onPressed: () {
context.push(
'/artists/${widget.providerId}/${link.service}/${link.id}?name=${Uri.encodeComponent(link.name)}',
);
},
),
const SizedBox(width: 8),
],
],
),
),
);
}
IconData _serviceIcon(String service) {
return switch (service.toLowerCase()) {
'patreon' => Icons.loyalty_rounded,
'fanbox' => Icons.favorite_border_rounded,
'discord' => Icons.chat_bubble_outline_rounded,
'boosty' => Icons.bolt_rounded,
_ => Icons.link_rounded,
};
}
String _serviceLabel(String service) {
return switch (service.toLowerCase()) {
'patreon' => 'Patreon',
'fanbox' => 'Pixiv Fanbox',
'discord' => 'Discord',
'boosty' => 'Boosty',
'fantia' => 'Fantia',
_ => service,
};
}
@override
Widget build(BuildContext context) {
final settings =
@@ -472,7 +409,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
: _posts.isEmpty
? Column(
children: [
_buildLinksBar(),
_buildTagsBar(),
const Expanded(
child: EmptyView(
@@ -486,7 +422,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
children: [
_buildMediaTypeFilters(),
_buildTagsBar(),
_buildLinksBar(),
Expanded(
child: displayedPosts.isEmpty
? Center(
@@ -510,11 +445,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
setState(() {
_selectedTypes.clear();
_selectedTag = null;
_posts.clear();
_page = 0;
_hasMore = true;
});
_loadMore();
},
child: const Text('Сбросить фильтры'),
),
@@ -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
@@ -120,35 +120,56 @@ class FeedController extends AsyncNotifier<FeedState> {
),
);
await saveSession(scrollOffset: 0);
await ref.read(searchServiceProvider).saveSearch(query, 0);
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final settings =
settingsRes is Success<AppSettings> ? settingsRes.data : AppSettings.defaults;
await ref
.read(searchServiceProvider)
.saveSearch(query, 0, maxHistory: settings.searchHistoryLimit);
await refresh();
final count = state.value?.posts.length ?? 0;
await ref.read(searchServiceProvider).saveSearch(query, count);
await ref
.read(searchServiceProvider)
.saveSearch(query, count, maxHistory: settings.searchHistoryLimit);
}
Future<void> updateTagSuggestions(String query) async {
final current = state.value ?? const FeedState();
final lastToken =
final rawLast =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final cleanToken = SearchService.sanitizeToken(rawLast);
final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel();
if (lastToken.trim().isEmpty) {
if (cleanToken.isEmpty || cleanToken == 'and') {
state = AsyncData(current.copyWith(tagSuggestions: []));
return;
}
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
// Instant local suggestions from disk cache & history (0 ms)
final localMatches = ref
.read(searchServiceProvider)
.instantSuggestions(cleanToken, limit: 16);
if (localMatches.isNotEmpty) {
state = AsyncData(current.copyWith(tagSuggestions: localMatches));
}
_suggestionDebounce = Timer(const Duration(milliseconds: 160), () async {
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final appSettings = settingsRes is Success<AppSettings>
? settingsRes.data
: AppSettings.defaults;
final result = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
.autocompleteDetailed(
cleanToken,
limit: 16,
tagCacheLimit: appSettings.tagCacheLimit,
);
if (requestId != _suggestionRequestId) return;
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = result is Success<List<TagSuggestion>>
? result.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: const <TagSuggestion>[];
: localMatches;
final latest = state.value ?? current;
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
});
@@ -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(
@@ -100,34 +102,42 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
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 +164,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 +172,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 +200,62 @@ 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);
},
),
),
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 +263,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 +319,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 +341,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.tags.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.tags.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,355 @@ 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 StatelessWidget {
const _PostInfoCard({
required this.post,
required this.strings,
this.localMedia,
this.fileSizeBytes,
});
final Post post;
final AppStrings strings;
final DownloadedMedia? localMedia;
final int? fileSizeBytes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final artists = post.tagGroups['artist'] ??
post.tags
.where((t) => t.startsWith('artist:'))
.map((t) => t.replaceFirst('artist:', ''))
.toList();
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Artist / Provider Row
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [scheme.primaryContainer, scheme.tertiaryContainer],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Icon(
artists.isNotEmpty
? Icons.palette_rounded
: Icons.hub_rounded,
size: 18,
color: scheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (artists.isNotEmpty)
InkWell(
onTap: () {
context.push(
'/search?q=${Uri.encodeComponent('artist:${artists.first}')}',
);
},
borderRadius: BorderRadius.circular(6),
child: Text(
artists.join(', '),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: scheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
else
Text(
strings.ru ? 'Автор не указан' : 'Unknown Artist',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
children: [
Text(
post.providerName,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
Text(
' • ',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.outline,
),
),
Text(
_formatDate(post.createdAt),
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
],
),
),
if (localMedia != null)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.offline_pin_rounded,
size: 14,
color: scheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
fileSizeBytes != null && fileSizeBytes! > 0
? DownloadedMediaService.formatBytes(fileSizeBytes!)
: (strings.ru ? 'Офлайн' : 'Offline'),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Metadata Spec Pills
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_SpecBadge(
icon: Icons.aspect_ratio_rounded,
label: '${post.width} × ${post.height}',
),
if (post.fileType.isNotEmpty)
_SpecBadge(
icon: Icons.insert_drive_file_outlined,
label: post.fileType.toUpperCase(),
),
RatingBadge(rating: post.rating),
if (post.score != 0)
_SpecBadge(
icon: Icons.star_rounded,
label: '${post.score}',
iconColor: Colors.amber,
),
if (post.source != null && post.source!.isNotEmpty)
InkWell(
onTap: () => launchUrl(Uri.parse(post.source!)),
borderRadius: BorderRadius.circular(10),
child: _SpecBadge(
icon: Icons.open_in_new_rounded,
label: strings.source,
color: scheme.surfaceContainerHigh,
),
),
],
),
],
),
);
}
static String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
}
class _SpecBadge extends StatelessWidget {
const _SpecBadge({
required this.icon,
required this.label,
this.iconColor,
this.color,
});
final IconData icon;
final String label;
final Color? iconColor;
final Color? color;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: color ?? scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.25),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: iconColor ?? scheme.onSurfaceVariant),
const SizedBox(width: 5),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: scheme.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -0,0 +1,374 @@
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,
});
final List<CloudMediaLink> links;
final AppStrings strings;
final String? commentary;
final ValueChanged<String>? onPlayStream;
final ValueChanged<String>? onDownloadStream;
@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(20),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
Icons.cloud_sync_rounded,
size: 20,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
strings.ru
? 'Облачные диски и зеркала'
: 'Cloud Mirrors & Drives',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
strings.ru
? '${links.length} внешних источников'
: '${links.length} external sources',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (links.any((l) => l.isStreamable))
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
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: 14, color: Colors.green),
const SizedBox(width: 4),
Text(
strings.ru ? 'Плеер доступен' : 'Streamable',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
// Archive Password Banner
if (detectedPassword.isNotEmpty) ...[
const SizedBox(height: 12),
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),
),
);
},
),
],
),
),
],
// Links list
if (links.isNotEmpty) ...[
const SizedBox(height: 14),
...links.map((link) => _buildLinkTile(context, link, strings)),
],
// Commentary / Description
if (commentary != null && commentary!.trim().isNotEmpty) ...[
const SizedBox(height: 14),
Divider(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
height: 1,
),
const SizedBox(height: 12),
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
@@ -21,11 +21,13 @@ class SearchController extends AsyncNotifier<SearchState> {
}
Future<void> updateQuery(String query) async {
final lastToken =
final rawLast =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final cleanToken = SearchService.sanitizeToken(rawLast);
final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel();
if (lastToken.isEmpty) {
if (cleanToken.isEmpty || cleanToken == 'and') {
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -34,24 +36,35 @@ class SearchController extends AsyncNotifier<SearchState> {
);
return;
}
// Instant local suggestions from disk cache & history (0 ms)
final localMatches = ref
.read(searchServiceProvider)
.instantSuggestions(cleanToken, limit: 16);
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: localMatches,
),
);
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
_suggestionDebounce = Timer(const Duration(milliseconds: 160), () async {
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final appSettings = settingsRes is Success<AppSettings>
? settingsRes.data
: AppSettings.defaults;
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
.autocompleteDetailed(
cleanToken,
limit: 16,
tagCacheLimit: appSettings.tagCacheLimit,
);
if (requestId != _suggestionRequestId) return;
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: <TagSuggestion>[];
: localMatches;
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -61,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,601 @@ 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),
),
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})',
),
),
),
],
],
);
}
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
class SearchSuggestions extends StatelessWidget {
const SearchSuggestions(
{required this.suggestions, required this.onTap, super.key});
const SearchSuggestions({
required this.suggestions,
required this.onTap,
super.key,
});
final List<TagSuggestion> suggestions;
final ValueChanged<String> onTap;
@@ -12,16 +15,43 @@ class SearchSuggestions extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (suggestions.isEmpty) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final suggestion in suggestions)
ActionChip(
label: Text('${suggestion.name} · ${suggestion.categoryLabel}'),
avatar: Icon(
Icons.tag_rounded,
size: 16,
color: _categoryColor(suggestion.category, scheme),
),
label: Text(
suggestion.postCount > 0
? '${suggestion.name} (${_compactCount(suggestion.postCount)})'
: suggestion.name,
),
onPressed: () => onTap(suggestion.name),
),
],
);
}
Color _categoryColor(TagCategory category, ColorScheme scheme) {
return switch (category) {
TagCategory.artist => const Color(0xFFFF5252),
TagCategory.character => const Color(0xFF66BB6A),
TagCategory.copyright => const Color(0xFFBA68C8),
TagCategory.species => const Color(0xFF4FC3F7),
TagCategory.meta => const Color(0xFF90A4AE),
TagCategory.general || TagCategory.unknown => scheme.primary,
};
}
String _compactCount(int value) {
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
return '$value';
}
}
@@ -16,6 +16,8 @@ class CacheStats {
required this.imageMemoryCount,
required this.tempDirectoryBytes,
required this.downloadedCount,
this.tagCacheCount = 0,
this.tagCacheBytes = 0,
});
final int cachedPostsCount;
@@ -23,9 +25,14 @@ class CacheStats {
final int imageMemoryCount;
final int tempDirectoryBytes;
final int downloadedCount;
final int tagCacheCount;
final int tagCacheBytes;
int get totalEstimatedBytes =>
imageMemoryBytes + tempDirectoryBytes + (cachedPostsCount * 2048);
imageMemoryBytes +
tempDirectoryBytes +
tagCacheBytes +
(cachedPostsCount * 2048);
static const empty = CacheStats(
cachedPostsCount: 0,
@@ -33,6 +40,8 @@ class CacheStats {
imageMemoryCount: 0,
tempDirectoryBytes: 0,
downloadedCount: 0,
tagCacheCount: 0,
tagCacheBytes: 0,
);
}
@@ -64,14 +73,21 @@ final cacheStatsProvider = FutureProvider.autoDispose<CacheStats>((ref) async {
}
} catch (_) {}
return CacheStats(
cachedPostsCount: postsCount,
imageMemoryBytes: memBytes,
imageMemoryCount: memCount,
tempDirectoryBytes: tempBytes,
downloadedCount: downloadedCount,
);
});
final tagCache = ref.watch(tagCacheServiceProvider);
if (!tagCache.isInitialized) await tagCache.init();
final tagCount = tagCache.tagCount;
final tagBytes = await tagCache.getFileSizeBytes();
return CacheStats(
cachedPostsCount: postsCount,
imageMemoryBytes: memBytes,
imageMemoryCount: memCount,
tempDirectoryBytes: tempBytes,
downloadedCount: downloadedCount,
tagCacheCount: tagCount,
tagCacheBytes: tagBytes,
);
});
class CacheManagerScreen extends ConsumerStatefulWidget {
const CacheManagerScreen({super.key});
@@ -127,9 +143,17 @@ class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearTagCache() async {
setState(() => _isClearing = true);
await ref.read(tagCacheServiceProvider).clear();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearAllCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
await ref.read(tagCacheServiceProvider).clear();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
try {
@@ -244,6 +268,17 @@ class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.label_rounded,
title: strings.ru ? 'Кэш подсказок тегов' : 'Tag suggestions cache',
subtitle: strings.ru
? '${stats.tagCacheCount} закэшированных тегов на диске'
: '${stats.tagCacheCount} cached tags on disk',
sizeText: _formatBytes(stats.tagCacheBytes),
onClear: _isClearing ? null : _clearTagCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.download_done_rounded,
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
@@ -38,6 +38,14 @@ class SettingsController extends AsyncNotifier<AppSettings> {
ref.invalidateSelf();
}
Future<void> clearSearchHistory() async {
await ref.read(searchServiceProvider).clearHistory();
}
Future<void> clearTagCache() async {
await ref.read(searchServiceProvider).clearTagCache();
}
Future<String> diagnosticsReport() async {
final result =
await ref.read(settingsServiceProvider).buildDiagnosticsReport(
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),
),
),
);
}
+170 -27
View File
@@ -121,15 +121,16 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor ??
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: _focusNode.hasFocus
? scheme.primary.withValues(alpha: 0.45)
: Colors.transparent,
? scheme.primary.withValues(alpha: 0.6)
: Colors.white.withValues(alpha: 0.08),
width: 1.0,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(22),
onTap: _focusNode.requestFocus,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
@@ -228,6 +229,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
width: size.width,
yOffset: size.height + 8,
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
),
);
@@ -241,18 +243,34 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
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,
);
@@ -305,7 +323,9 @@ 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();
});
@@ -358,10 +378,19 @@ 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();
});
_notifyChanged();
@@ -372,7 +401,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _notifyChangedDebounced() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 250), _notifyChanged);
_debounce = Timer(const Duration(milliseconds: 100), _notifyChanged);
}
void _notifyChanged() {
@@ -386,7 +415,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;
@@ -401,7 +431,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
String get _activeToken {
final draft = _controller.text.trim().toLowerCase();
if (draft.isEmpty) return '';
return draft.split(RegExp(r'\s+')).last;
final raw = draft.split(RegExp(r'\s+')).last;
return raw.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
}
bool get _isEditing => _focusNode.hasFocus || _localDirty;
@@ -413,6 +444,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
required this.width,
required this.yOffset,
required this.suggestions,
required this.query,
required this.onSelected,
});
@@ -420,6 +452,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
final double width;
final double yOffset;
final List<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> onSelected;
@override
@@ -435,6 +468,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
width: width.clamp(280, 620).toDouble(),
child: _TagSuggestionDropdown(
suggestions: suggestions,
query: query,
onSelected: onSelected,
),
),
@@ -447,10 +481,12 @@ class _TagSuggestionOverlay extends StatelessWidget {
class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({
required this.suggestions,
required this.query,
required this.onSelected,
});
final List<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> onSelected;
@override
@@ -482,18 +518,24 @@ class _TagSuggestionDropdown extends StatelessWidget {
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Icon(Icons.tag_rounded, color: scheme.primary, size: 20),
Icon(
Icons.tag_rounded,
color: _categoryAccentColor(suggestion.category, scheme),
size: 20,
),
const SizedBox(width: 14),
Expanded(
child: Text(
child: _buildHighlightedText(
context,
suggestion.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge,
query,
),
),
const SizedBox(width: 10),
_TagSuggestionBadge(suggestion.categoryLabel),
_TagSuggestionBadge(
category: suggestion.category,
label: suggestion.categoryLabel,
),
if (suggestion.postCount > 0) ...[
const SizedBox(width: 8),
Text(
@@ -514,6 +556,72 @@ class _TagSuggestionDropdown extends StatelessWidget {
);
}
Widget _buildHighlightedText(
BuildContext context,
String text,
String token,
) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final defaultStyle = theme.textTheme.bodyLarge ?? const TextStyle();
final cleanToken = token.trim().toLowerCase();
if (cleanToken.isEmpty) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: defaultStyle,
);
}
final lowerText = text.toLowerCase();
final matchIndex = lowerText.indexOf(cleanToken);
if (matchIndex < 0) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: defaultStyle,
);
}
final before = text.substring(0, matchIndex);
final match = text.substring(matchIndex, matchIndex + cleanToken.length);
final after = text.substring(matchIndex + cleanToken.length);
return Text.rich(
TextSpan(
style: defaultStyle,
children: [
if (before.isNotEmpty) TextSpan(text: before),
TextSpan(
text: match,
style: defaultStyle.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w900,
),
),
if (after.isNotEmpty) TextSpan(text: after),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
Color _categoryAccentColor(TagCategory category, ColorScheme scheme) {
return switch (category) {
TagCategory.artist => const Color(0xFFFF5252),
TagCategory.character => const Color(0xFF66BB6A),
TagCategory.copyright => const Color(0xFFBA68C8),
TagCategory.species => const Color(0xFF4FC3F7),
TagCategory.meta => const Color(0xFF90A4AE),
TagCategory.general || TagCategory.unknown => scheme.primary,
};
}
String _compactCount(int value) {
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
@@ -522,16 +630,22 @@ class _TagSuggestionDropdown extends StatelessWidget {
}
class _TagSuggestionBadge extends StatelessWidget {
const _TagSuggestionBadge(this.label);
const _TagSuggestionBadge({
required this.category,
required this.label,
});
final TagCategory category;
final String label;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (bgColor, fgColor) = _badgeColors(scheme);
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.55),
color: bgColor,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
@@ -539,13 +653,42 @@ class _TagSuggestionBadge extends StatelessWidget {
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer,
color: fgColor,
fontWeight: FontWeight.w700,
),
),
),
);
}
(Color, Color) _badgeColors(ColorScheme scheme) {
return switch (category) {
TagCategory.artist => (
const Color(0xFFFF5252).withValues(alpha: 0.16),
const Color(0xFFFF5252),
),
TagCategory.character => (
const Color(0xFF4CAF50).withValues(alpha: 0.16),
const Color(0xFF43A047),
),
TagCategory.copyright => (
const Color(0xFFAB47BC).withValues(alpha: 0.16),
const Color(0xFFAB47BC),
),
TagCategory.species => (
const Color(0xFF29B6F6).withValues(alpha: 0.16),
const Color(0xFF0288D1),
),
TagCategory.meta => (
const Color(0xFF78909C).withValues(alpha: 0.16),
const Color(0xFF607D8B),
),
TagCategory.general || TagCategory.unknown => (
scheme.primaryContainer.withValues(alpha: 0.55),
scheme.onPrimaryContainer,
),
};
}
}
class _AppSearchBarState extends State<AppSearchBar> {
+251 -153
View File
@@ -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,
),
);
}
+1 -1
View File
@@ -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,
+15 -8
View File
@@ -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
View File
@@ -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.6+28
version: 2.3.0+40
environment:
sdk: ">=3.3.0 <4.0.0"
+107
View File
@@ -0,0 +1,107 @@
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>&amp; enjoy!</p>';
final clean = CloudLinkExtractor.cleanCommentary(html);
expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!');
});
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');
});
});
}
+2 -1
View File
@@ -43,11 +43,12 @@ void main() {
expect(postPassesTagFilters(post, settings), isFalse);
});
test('requested search tags are all required', () {
test('requested search tags are all required unless separated by and', () {
final post = _post(tags: ['touhou', 'hakurei_reimu']);
expect(postMatchesRequestedTags(post, ['touhou']), isTrue);
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
expect(postMatchesRequestedTags(post, ['touhou', 'and', 'cirno']), isTrue);
});
test('requested search tags can match tag prefixes', () {
+12 -1
View File
@@ -21,7 +21,7 @@ class FakeSearchRepository implements SearchRepository {
}
@override
Future<Result<void>> save(SearchHistory history) async {
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) async {
items.add(history);
return const Success(null);
}
@@ -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));
+149
View File
@@ -0,0 +1,149 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/tag_suggestion.dart';
import 'package:gel_rule_app/backend/services/tag_cache_service.dart';
void main() {
late Directory tempDir;
late String cacheFilePath;
late TagCacheService cacheService;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('lunaris_test_tag_cache');
cacheFilePath = '${tempDir.path}/test_tags.json';
cacheService = TagCacheService(customPath: cacheFilePath);
await cacheService.init();
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('TagSuggestion toJson and fromJson work correctly', () {
const item = TagSuggestion(
name: 'hatsune_miku',
category: TagCategory.character,
postCount: 154200,
providerId: 'danbooru',
);
final json = item.toJson();
final restored = TagSuggestion.fromJson(json);
expect(restored.name, 'hatsune_miku');
expect(restored.category, TagCategory.character);
expect(restored.postCount, 154200);
expect(restored.providerId, 'danbooru');
});
test('findLocal returns matching tags instantaneously', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 1000,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'caterpillar',
category: TagCategory.general,
postCount: 200,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'dog_tail',
category: TagCategory.general,
postCount: 800,
providerId: 'gelbooru',
),
]);
final matches = cacheService.findLocal('cat');
expect(matches.length, 2);
expect(matches.first.name, 'cat_ears');
expect(matches[1].name, 'caterpillar');
final dogMatches = cacheService.findLocal('dog');
expect(dogMatches.length, 1);
expect(dogMatches.first.name, 'dog_tail');
});
test('findLocal sanitizes parentheses in prefix', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 1000,
providerId: 'gelbooru',
),
]);
final matches = cacheService.findLocal('(cat');
expect(matches.length, 1);
expect(matches.first.name, 'cat_ears');
});
test('findLocal respects priority tags from search history', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_girl',
category: TagCategory.general,
postCount: 5000,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'cat_costume',
category: TagCategory.general,
postCount: 50,
providerId: 'gelbooru',
),
]);
// Priority tag 'cat_costume' should appear first despite lower postCount
final matches = cacheService.findLocal(
'cat',
priorityTags: ['cat_costume'],
);
expect(matches.first.name, 'cat_costume');
expect(matches[1].name, 'cat_girl');
});
test('flush saves tags to file and reloading restores them', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'genshin_impact',
category: TagCategory.copyright,
postCount: 300000,
providerId: 'danbooru',
),
]);
await cacheService.flush();
final reloadedService = TagCacheService(customPath: cacheFilePath);
await reloadedService.init();
final results = reloadedService.findLocal('genshin');
expect(results.length, 1);
expect(results.first.name, 'genshin_impact');
expect(results.first.category, TagCategory.copyright);
});
test('clear removes tags from memory and disk', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'raiden_shogun',
category: TagCategory.character,
postCount: 45000,
providerId: 'danbooru',
),
]);
await cacheService.flush();
expect(File(cacheFilePath).existsSync(), isTrue);
await cacheService.clear();
expect(cacheService.tagCount, 0);
expect(File(cacheFilePath).existsSync(), isFalse);
});
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/providers/provider_manager.dart';
void main() {
group('Tag groups splitting: space groups together, and separates streams', () {
test('single tag returns one group', () {
final groups = ProviderManager.splitTagGroups(['cat']);
expect(groups, equals([['cat']]));
});
test('tags separated by space are grouped together in one query', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'miku']);
expect(groups, equals([
['raiden', 'miku'],
]));
});
test('tags separated by and are split into independent query streams', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'and', 'miku']);
expect(groups, equals([
['raiden'],
['miku'],
]));
});
test('cat and dog anthro applies trailing anthro tag to both branches', () {
final groups = ProviderManager.splitTagGroups(['cat', 'and', 'dog', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple trailing modifiers apply to all and-branches', () {
final groups = ProviderManager.splitTagGroups([
'cat',
'and',
'dog',
'and',
'fox',
'anthro',
'solo',
]);
expect(groups, equals([
['cat', 'anthro', 'solo'],
['dog', 'anthro', 'solo'],
['fox', 'anthro', 'solo'],
]));
});
test('supports optional parentheses around and branches', () {
final groups = ProviderManager.splitTagGroups(['(cat', 'and', 'dog)', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple multi-tag groups separated by and', () {
final groups = ProviderManager.splitTagGroups([
'genshin',
'raiden',
'and',
'vocaloid',
'miku',
]);
expect(groups, equals([
['genshin', 'raiden'],
['vocaloid', 'miku'],
]));
});
test('case-insensitive AND separator', () {
final groups = ProviderManager.splitTagGroups(['cat', 'AND', 'dog']);
expect(groups, equals([
['cat'],
['dog'],
]));
});
test('empty tags return empty group list', () {
final groups = ProviderManager.splitTagGroups([]);
expect(groups, equals([const <String>[]]));
});
});
}
@@ -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,
),
),
),
),
);
// Verify header and counts
expect(find.text('Облачные диски и зеркала'), findsOneWidget);
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);
});
});
}
+72
View File
@@ -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);
}
}
+290
View File
@@ -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,57 @@ 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);
});
}
class _Harness extends StatelessWidget {