Compare commits

..
6 Commits
70 changed files with 6092 additions and 2372 deletions
+3
View File
@@ -4,8 +4,11 @@ build/
dist/
/isar.dll
.idea/
.vscode/
*.iml
.flutter-plugins
.flutter-plugins-dependencies
android/local.properties
*.log
*.apk
*.tar.gz
+93 -73
View File
@@ -1,104 +1,124 @@
# Lunaris
# 🌙 Lunaris
Lunaris — локальное Flutter-приложение для просмотра booru, gelbooru, rule34-подобных источников и artist-архивов в Pinterest-стиле.
<p align="center">
<img src="assets/branding/app_icon.png" width="128" height="128" alt="Lunaris Logo" />
</p>
Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, локальный кеш metadata, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления.
<p align="center">
<strong>Современный кроссплатформенный клиент для Booru и Creator-архивов</strong><br>
Плавный Pinterest-стиль, поддержка фото, видео и GIF, умный черный список, встроенный менеджер загрузок и автообновление.
</p>
## Что умеет
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.2-blueviolet?style=for-the-badge" alt="Version 2.0.2" />
<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" />
</p>
- Masonry feed с несколькими провайдерами и естественно перемешанной выдачей.
- Поиск тегов через chip-input с подсказками из активных API.
- Избранное, история просмотров, коллекции и пакетное добавление в коллекции.
- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score.
- Details viewer для фото, GIF, видео и SWF на Windows.
- Удобный видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек.
- Mobile image zoom: pinch-to-zoom и double tap в details.
- Опциональное автоскачивание новых favorites с отдельным удалением локального файла без удаления из избранного.
- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычной booru-лентой.
- Управление провайдерами, health-check и provider diagnostics.
- Кастомизация интерфейса: тема, язык RU/EN, акцентный цвет, видимые вкладки, beta/experimental обновления.
- Экспорт и импорт настроек JSON.
- Проверка Gitea Releases и скачивание APK/Windows installer прямо из приложения.
---
## Скачать
## ✨ Основные возможности
Готовые сборки лежат в Gitea Releases:
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео.
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
[Lunaris 2.0.1](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v2.0.1)
---
- `Lunaris-v2.0.1.apk` — APK для Android.
- `LunarisSetup-v2.0.1.exe` — установщик для Windows.
- `LunarisPortable-v2.0.1.zip` — portable-версия для Windows.
## 🌐 Активные провайдеры
## Провайдеры
| Провайдер | Тип API | Назначение |
|---|---|---|
| **Gelbooru** | XML / JSON API | Основная база аниме-артов |
| **Rule34** | Booru API | Популярный booru-источник |
| **Realbooru** | HTML Scraper | Фото и реалистичный контент |
| **e621 / e926** | JSON API | Фурри- и антро-арт с богатой системой тегов |
| **Pawchive** | REST API v1 | Архивы авторов Patreon, Pixiv Fanbox, Fantia и др. |
Обычный feed:
---
- Gelbooru
- Rule34
- Safebooru
- Konachan
- Yande.re
- e621 / e926
- Xbooru
- CosBooru
- Realbooru отключен по умолчанию, если публичный API недоступен
## 📥 Скачать приложение
Artists flow:
Готовые сборки для всех поддерживаемых платформ доступны на странице релизов:
- Kemono
- Coomer
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
Kemono/Coomer остаются отдельным разделом Artists и не попадают в общий booru feed.
- **Android**: `Lunaris-v2.0.2.apk`
- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip`
## Архитектура
---
- `lib/core` — HTTP, база, кеш, ошибки, Result и утилиты.
- `lib/backend/models` — доменные модели.
- `lib/backend/providers` — API-провайдеры и ProviderManager.
- `lib/backend/repositories` — доступ к локальному Isar-хранилищу.
- `lib/backend/services` — Feed/Search/Favorites/Collections/Settings/Diagnostics/Downloads/Updates.
- `lib/backend/di` — Riverpod providers для backend/core.
- `lib/app` — приложение, роутер, тема, локализация, motion и responsive helpers.
- `lib/shared/widgets` — общие UI-компоненты.
- `lib/features` — экраны и контроллеры фич.
## 🛠️ Сборка из исходников
## Запуск
### Требования
- Flutter SDK `>= 3.19.0`
- OpenJDK 17
- Android SDK (для сборки APK)
- Clang / CMake / GTK-3 (для сборки под Linux)
### Команды разработки
```bash
# Клонирование репозитория
git clone https://gitea.rardogsynapse.online/RarDog/Lunaris.git
cd Lunaris
# Установка зависимостей
flutter pub get
flutter run -d windows
flutter run -d android
```
## Сборка
```bash
# Запуск тестов и анализатора
flutter analyze
flutter test
flutter build apk
flutter build windows
# Запуск на подключенном устройстве
flutter run
```
Windows installer собирается через Inno Setup:
### Сборка релизов
```powershell
ISCC.exe installer\Lunaris.iss
```bash
# Android APK
flutter build apk --release
# Linux x64 Bundle
flutter build linux --release
# Windows Bundle
flutter build windows --release
```
## Как добавить провайдера
---
1. Создать класс, реализующий `ContentProvider`.
2. Добавить mapper к единой модели `Post`.
3. Зарегистрировать `apiType` в `ProviderFactory`.
4. Добавить seed config в `ProviderRepository`, если провайдер нужен по умолчанию.
5. Добавить тесты parser/provider factory.
## 🏛️ Архитектура
## Безопасность
```
lib/
├── app/ # Роутер, тема, локализация (RU/EN), анимации
├── backend/
│ ├── di/ # Riverpod providers для сервисов
│ ├── models/ # Post, ArtistProfile, AppSettings, AppUpdateInfo
│ ├── providers/ # Реализации API (Gelbooru, Rule34, Pawchive и др.)
│ ├── repositories/ # Доступ к локальной БД Isar
│ └── services/ # Бизнес-логика (Feed, Search, Favorites, Downloads, Updates)
├── core/ # Сеть (Dio), база данных (Isar), ошибки и кеши
├── features/ # Модули экранов (feed, post, artists, favorites, settings)
└── shared/widgets/ # Переиспользуемые виджеты (PostCard, MasonryGrid, TagChip)
```
- Feed работает только с metadata/preview и не скачивает original/full files автоматически.
- Загрузка файлов происходит только по явному действию пользователя или при включенной настройке auto-download favorites.
- Приложение уважает API и rate limits сайтов.
- NSFW/blur/blacklist настраиваются локально.
- API keys/custom headers хранятся локально и экспортируются только через явный export settings.
- Локальные пути скачанных файлов не попадают в JSON export/import.
---
## 🔒 Приватность и безопасность
- **Полная автономность**: Приложение не отправляет телеметрию и работает без внешних серверов-посредников.
- **Локальное хранение**: Все настройки, история поиска, черный список и избранное хранятся исключительно на вашем устройстве в локальной базе Isar.
- **Бережное отношение к трафику**: В ленте загружаются только оптимизированные превью. Тяжелые оригиналы и видео загружаются только при открытии или скачивании.
+4
View File
@@ -3,6 +3,10 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- build/**
- android/**
- windows/**
- linux/**
linter:
rules:
+56 -1
View File
@@ -221,6 +221,19 @@ class _DownloadPanel extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final strings = ref.watch(appStringsProvider);
final manager = ref.read(downloadManagerServiceProvider);
final paused = manager.queuePaused;
final activeTasks = tasks
.where((task) =>
task.status == DownloadTaskStatus.queued ||
task.status == DownloadTaskStatus.running)
.toList();
final finishedTasks = tasks
.where((task) =>
task.status == DownloadTaskStatus.completed ||
task.status == DownloadTaskStatus.failed ||
task.status == DownloadTaskStatus.canceled)
.toList();
return Material(
elevation: 12,
borderRadius: BorderRadius.circular(16),
@@ -232,6 +245,39 @@ class _DownloadPanel extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
strings.download,
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
IconButton(
tooltip: paused ? 'Resume queue' : 'Pause queue',
onPressed:
paused ? manager.resumeQueue : manager.pauseQueue,
icon: Icon(paused
? Icons.play_arrow_rounded
: Icons.pause_rounded),
),
if (activeTasks.isNotEmpty)
IconButton(
tooltip: 'Cancel active',
onPressed: () {
for (final task in activeTasks) {
manager.cancel(task.id);
}
},
icon: const Icon(Icons.cancel_rounded),
),
if (finishedTasks.isNotEmpty)
IconButton(
tooltip: 'Clear finished',
onPressed: manager.clearFinished,
icon: const Icon(Icons.clear_all_rounded),
),
],
),
for (final task in tasks) ...[
Row(
children: [
@@ -252,6 +298,15 @@ class _DownloadPanel extends ConsumerWidget {
.retry(task.id),
icon: const Icon(Icons.refresh_rounded),
),
if (task.status == DownloadTaskStatus.queued ||
task.status == DownloadTaskStatus.running)
IconButton(
tooltip: 'Cancel',
onPressed: () => ref
.read(downloadManagerServiceProvider)
.cancel(task.id),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 6),
@@ -344,7 +399,7 @@ class _StartupErrorScreen extends ConsumerWidget {
const SizedBox(height: 16),
Text(
strings.ru
? 'Не удалось открыть локальную базу'
? '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044c \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0431\u0430\u0437\u0443'
: 'Could not start local database',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
+3 -3
View File
@@ -75,8 +75,8 @@ class AppStrings {
String get hideViewed => ru ? 'Скрывать просмотренное' : 'Hide viewed posts';
String get smartBlacklist => ru ? 'Умный blacklist' : 'Smart blacklist';
String get whitelistedTags => ru ? 'Whitelist тегов' : 'Whitelisted tags';
String get cacheMaxItems => ru ? 'Лимит кэша' : 'Cache max items';
String get clearCache => ru ? 'Очистить кэш' : 'Clear cache';
String get cacheMaxItems => ru ? 'Лимит кеша' : 'Cache max items';
String get clearCache => ru ? 'Очистить кеш' : 'Clear cache';
String get clearViewed => ru ? 'Очистить историю' : 'Clear viewed history';
String get exportJson => ru ? 'Экспорт JSON' : 'Export JSON';
String get importJson => ru ? 'Импорт JSON' : 'Import JSON';
@@ -98,5 +98,5 @@ class AppStrings {
String get disableRepeat => ru ? 'Отключить повтор' : 'Disable repeat';
String get fullscreen => ru ? 'На весь экран' : 'Fullscreen';
String get exitFullscreen =>
ru ? 'Выйти из полного экрана' : 'Exit fullscreen';
ru ? 'Выйти из полноэкранного режима' : 'Exit fullscreen';
}
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.1';
const appBuildNumber = 22;
const appDisplayVersion = '2.0.2';
const appBuildNumber = 24;
const appReleaseChannel = 'Stable';
+19 -9
View File
@@ -1,4 +1,16 @@
const lunarisChangelog = [
LunarisChange(
version: '2.0.2',
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
bullets: [
'Full horizontal post pager across mobile and desktop in Feed, Artists, Favorites, Collections, and Viewed history.',
'Eliminated video preview stutter and lag in grid feed by using high-performance cached thumbnails and on-demand playback.',
'Added favorite artists bar at the top of the Artists tab with quick access and recent photos/videos stream.',
'Multi-image posts now feature an in-post navigation indicator with image switching.',
'New Pawchive provider support and cleaned up deprecated providers.',
'In-app update checks and downloads connected directly to RarDog Gitea releases.',
],
),
LunarisChange(
version: '2.0.1',
title: 'Localization, zoom and favorite downloads',
@@ -43,29 +55,27 @@ const lunarisChangelog = [
),
LunarisChange(
version: '1.1.0',
title: 'Lunaris rebrand and CosBooru',
title: 'Lunaris rebrand and providers',
bullets: [
'RuleGel is now Lunaris while keeping the same package identity for updates.',
'CosBooru now uses the working Danbooru-compatible API at cos.lycore.co.',
'Xbooru was added as a default Gelbooru-compatible provider.',
'Rule34 Paheal was removed from providers.',
'Rule34 Paheal is available as an HTML provider.',
'Video player UI preferences are saved across fullscreen, details and restarts.',
],
),
LunarisChange(
version: '1.0.2',
title: 'Realbooru safety fix',
title: 'Provider safety fix',
bullets: [
'Realbooru stays available in Providers, but is disabled by default while its public DAPI is offline.',
'Provider diagnostics now surface the Realbooru API reason instead of a generic exception.',
'Existing enabled Realbooru configs are disabled on startup so the main feed is not polluted by a known-offline API.',
'Known-offline legacy providers are disabled on startup.',
'Provider diagnostics now surface API reasons instead of generic exceptions.',
'Existing enabled legacy configs are disabled so the main feed is not polluted by known-offline APIs.',
],
),
LunarisChange(
version: '1.0.1',
title: 'Realbooru and mobile polish',
title: 'Mobile polish',
bullets: [
'Added Realbooru as a default enabled provider.',
'Startup update checks now look for new Gitea releases every launch.',
'Refresh-rate controls are Android-only; desktop keeps stable app motion.',
'Tag chip input no longer jumps while typing.',
+26 -5
View File
@@ -15,6 +15,7 @@ import '../features/providers/presentation/provider_check_screen.dart';
import '../features/providers/presentation/provider_form_screen.dart';
import '../features/providers/presentation/providers_screen.dart';
import '../features/search/presentation/search_screen.dart';
import '../features/settings/presentation/hidden_posts_screen.dart';
import '../features/settings/presentation/settings_screen.dart';
import '../features/viewed/presentation/viewed_screen.dart';
import '../shared/widgets/app_shell.dart';
@@ -39,11 +40,27 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/post/:providerId/:postId',
builder: (context, state) => PostDetailsScreen(
providerId: state.pathParameters['providerId']!,
postId: state.pathParameters['postId']!,
initialPost: state.extra is Post ? state.extra! as Post : null,
),
builder: (context, state) {
final extra = state.extra;
final Post? initialPost;
final List<Post>? postsList;
if (extra is PostNavigationContext) {
initialPost = extra.currentPost;
postsList = extra.posts;
} else if (extra is Post) {
initialPost = extra;
postsList = null;
} else {
initialPost = null;
postsList = null;
}
return PostDetailsScreen(
providerId: state.pathParameters['providerId']!,
postId: state.pathParameters['postId']!,
initialPost: initialPost,
postsList: postsList,
);
},
),
GoRoute(
path: '/post/:providerId/:postId/similar',
@@ -105,6 +122,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/settings/hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
],
),
],
+7
View File
@@ -12,6 +12,7 @@ import '../providers/provider_factory.dart';
import '../providers/provider_manager.dart';
import '../models/download_task.dart';
import '../models/downloaded_media.dart';
import '../models/post.dart';
import '../repositories/collection_repository.dart';
import '../repositories/downloaded_media_repository.dart';
import '../repositories/favorite_repository.dart';
@@ -188,6 +189,12 @@ final downloadedMediaByKeyProvider =
return result is Success<DownloadedMedia?> ? result.data : null;
});
final postMediaHeadersProvider =
FutureProvider.family<Map<String, String>, Post>((ref, post) async {
final result = await ref.watch(providerManagerProvider).getMediaHeaders(post);
return result is Success<Map<String, String>> ? result.data : const {};
});
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
final manager = ref.watch(downloadManagerServiceProvider);
return manager.stream;
+6 -1
View File
@@ -9,6 +9,7 @@ class AppUpdateInfo {
this.apkUrl,
this.windowsInstallerUrl,
this.portableZipUrl,
this.linuxTarGzUrl,
});
final String version;
@@ -20,7 +21,11 @@ class AppUpdateInfo {
final String? apkUrl;
final String? windowsInstallerUrl;
final String? portableZipUrl;
final String? linuxTarGzUrl;
bool get hasAssets =>
apkUrl != null || windowsInstallerUrl != null || portableZipUrl != null;
apkUrl != null ||
windowsInstallerUrl != null ||
portableZipUrl != null ||
linuxTarGzUrl != null;
}
+11
View File
@@ -114,3 +114,14 @@ class Post {
const {},
);
}
class PostNavigationContext {
const PostNavigationContext({
required this.currentPost,
required this.posts,
});
final Post currentPost;
final List<Post> posts;
}
@@ -24,6 +24,14 @@ abstract class ContentProvider {
Future<ProviderHealth> checkHealth();
}
abstract class PostPageProvider {
String? postPageUrl(Post post);
}
abstract class MediaHeadersProvider {
Map<String, String> mediaHeaders(Post post);
}
abstract class CommentProvider {
Future<List<PostComment>> getComments(String postId);
}
+5 -1
View File
@@ -8,7 +8,8 @@ import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class DanbooruProvider implements ContentProvider, CommentProvider {
class DanbooruProvider
implements ContentProvider, CommentProvider, PostPageProvider {
DanbooruProvider({
required this.id,
required this.name,
@@ -27,6 +28,9 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
final Dio _dio;
final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+8 -1
View File
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
import 'content_provider.dart';
class E621Provider
implements ContentProvider, TagSuggestionProvider, CommentProvider {
implements
ContentProvider,
TagSuggestionProvider,
CommentProvider,
PostPageProvider {
E621Provider({
required this.id,
required this.name,
@@ -29,6 +33,9 @@ class E621Provider
final Dio _dio;
final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+17 -1
View File
@@ -15,7 +15,9 @@ class GelbooruProvider
ContentProvider,
CommentProvider,
TagSuggestionProvider,
TagMetadataProvider {
TagMetadataProvider,
PostPageProvider,
MediaHeadersProvider {
GelbooruProvider({
required this.id,
required this.name,
@@ -36,6 +38,20 @@ class GelbooruProvider
Dio get dio => _dio;
Map<String, String> get queryParameters => _queryParameters;
@override
String postPageUrl(Post post) =>
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
@override
Map<String, String> mediaHeaders(Post post) {
final host = Uri.tryParse(baseUrl)?.host;
return {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/',
};
}
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+41 -6
View File
@@ -106,10 +106,15 @@ class KemonoProvider
'itemsPerPage': limit.clamp(1, 100),
},
);
final artists = _artistsFromResponse(response.data)
.where((artist) =>
service == null || service.isEmpty || artist.service == service)
.toList(growable: false);
final artists = _rankArtists(
_artistsFromResponse(response.data)
.where((artist) =>
service == null ||
service.isEmpty ||
artist.service == service)
.toList(growable: false),
searchText,
);
if (artists.isNotEmpty) return artists;
} catch (_) {
// Fall through to the indexed creator endpoints.
@@ -126,7 +131,7 @@ class KemonoProvider
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
},
);
return _artistsFromResponse(response.data);
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
} catch (_) {
return _listArtistsFromPublicApi(
service: service,
@@ -177,6 +182,7 @@ class KemonoProvider
));
}
if (mapped.isNotEmpty || posts.isEmpty) {
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return mapped.take(limit).toList(growable: false);
}
} catch (error) {
@@ -290,7 +296,36 @@ class KemonoProvider
artist.displayName.toLowerCase().contains(queryText) ||
artist.id.toLowerCase().contains(queryText)))
.toList(growable: false);
return items.skip(offset).take(limit).toList(growable: false);
final ranked = _rankArtists(items, queryText);
return ranked.skip(offset).take(limit).toList(growable: false);
}
List<ArtistProfile> _rankArtists(List<ArtistProfile> artists, String query) {
final queryText = query.trim().toLowerCase();
final ranked = [...artists];
ranked.sort((a, b) {
final scoreA = _artistSearchScore(a, queryText);
final scoreB = _artistSearchScore(b, queryText);
if (scoreA != scoreB) return scoreB.compareTo(scoreA);
final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
final updated = updatedB.compareTo(updatedA);
if (updated != 0) return updated;
final count = (b.postCount ?? 0).compareTo(a.postCount ?? 0);
if (count != 0) return count;
return a.displayName.compareTo(b.displayName);
});
return ranked;
}
int _artistSearchScore(ArtistProfile artist, String query) {
if (query.isEmpty) return 0;
final name = artist.displayName.toLowerCase();
final id = artist.id.toLowerCase();
if (name == query || id == query) return 100;
if (name.startsWith(query) || id.startsWith(query)) return 70;
if (name.contains(query) || id.contains(query)) return 40;
return 0;
}
List<dynamic> _postItems(dynamic data) {
+8 -1
View File
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
import 'content_provider.dart';
class MoebooruProvider
implements ContentProvider, TagSuggestionProvider, CommentProvider {
implements
ContentProvider,
TagSuggestionProvider,
CommentProvider,
PostPageProvider {
MoebooruProvider({
required this.id,
required this.name,
@@ -29,6 +33,9 @@ class MoebooruProvider
final Dio _dio;
final Map<String, String> _queryParameters;
@override
String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}';
@override
Future<List<Post>> searchPosts({
required List<String> tags,
@@ -0,0 +1,454 @@
import 'dart:async';
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../models/artist_profile.dart';
import '../models/artist_work_query.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class PawchiveProvider
implements ContentProvider, ArtistProvider, CommentProvider {
PawchiveProvider({
required this.id,
required this.name,
required this.baseUrl,
required DioClient dioClient,
Map<String, String> queryParameters = const {},
}) : _dio = dioClient.dio;
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
List<ArtistProfile>? _cachedArtists;
DateTime? _lastCreatorsFetchAt;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
return const [];
}
@override
Future<Post?> getPost(String id) async {
final parts = id.split(':');
if (parts.length < 3) return null;
final service = parts[0];
final artistId = parts[1];
final postId = parts[2];
final targetIndex = parts.length > 3 ? int.tryParse(parts[3]) ?? 0 : 0;
final query = ArtistWorkQuery(
providerId: this.id,
service: service,
artistId: artistId,
artistName: artistId,
);
try {
final response = await _dio.get<dynamic>(
'/api/v1/$service/user/$artistId/post/$postId',
);
final data = response.data;
if (data is Map) {
final posts = _postsFromArtistPost(
Map<String, dynamic>.from(data),
query: query,
);
if (targetIndex >= 0 && targetIndex < posts.length) {
return posts[targetIndex];
}
if (posts.isNotEmpty) return posts.first;
}
} catch (_) {
// Fallback: search within artist posts
try {
final posts = await getArtistPosts(query: query, page: 0, limit: 50);
for (final post in posts) {
if (post.id == id) return post;
}
} catch (_) {}
}
return null;
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final response = await _dio.get<dynamic>(
'/api/v1/app_version',
options: Options(
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
),
);
final version = response.data?.toString().trim() ?? 'ok';
return ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'pawchive-$version',
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: 'pawchive',
);
}
}
@override
Future<List<ArtistProfile>> listArtists({
String? service,
String? query,
int page = 1,
int limit = 30,
}) async {
final allArtists = await _ensureArtistsLoaded();
final queryText = (query ?? '').trim().toLowerCase();
final filtered = allArtists.where((artist) {
final matchService = service == null ||
service.isEmpty ||
artist.service.toLowerCase() == service.toLowerCase();
if (!matchService) return false;
if (queryText.isEmpty) return true;
return artist.displayName.toLowerCase().contains(queryText) ||
artist.id.toLowerCase().contains(queryText);
}).toList(growable: false);
final ranked = _rankArtists(filtered, queryText);
final offset = (page - 1).clamp(0, 999999) * limit;
return ranked.skip(offset).take(limit).toList(growable: false);
}
@override
Future<List<ArtistProfile>> searchArtists(
String query, {
String? service,
int page = 1,
int limit = 30,
}) {
return listArtists(
service: service,
query: query,
page: page,
limit: limit,
);
}
@override
Future<List<Post>> getArtistPosts({
required ArtistWorkQuery query,
int page = 0,
int limit = 50,
}) async {
final path = '/api/v1/${query.service}/user/${query.artistId}/posts';
final offset = page * limit;
try {
final response = await _dio.get<dynamic>(
path,
queryParameters: {'o': offset},
);
final posts = _postItems(response.data);
final mapped = <Post>[];
for (final post in posts.whereType<Map>()) {
mapped.addAll(_postsFromArtistPost(
Map<String, dynamic>.from(post),
query: query,
));
}
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return mapped.take(limit).toList(growable: false);
} catch (error) {
throw StateError(
'$name artist posts are unavailable right now. ${_shortError(error)}',
);
}
}
@override
Future<List<PostComment>> getArtistPostComments(
String service,
String artistId,
String postId,
) async {
final path = '/api/v1/$service/user/$artistId/post/$postId/comments';
try {
final response = await _dio.get<dynamic>(path);
return _commentsFromResponse(response.data, postId);
} catch (error) {
// 404 means no comments found, return empty list
if (error is DioException && error.response?.statusCode == 404) {
return const [];
}
return const [];
}
}
@override
Future<List<PostComment>> getComments(String postId) async {
final parts = postId.split(':');
if (parts.length < 3) return const [];
return getArtistPostComments(parts[0], parts[1], parts[2]);
}
Future<List<ArtistProfile>> _ensureArtistsLoaded() async {
final cached = _cachedArtists;
final lastFetch = _lastCreatorsFetchAt;
if (cached != null &&
lastFetch != null &&
DateTime.now().difference(lastFetch) < const Duration(minutes: 30)) {
return cached;
}
try {
final response = await _dio.get<dynamic>(
'/api/v1/creators',
options: Options(
receiveTimeout: const Duration(seconds: 25),
sendTimeout: const Duration(seconds: 15),
),
);
final artists = _artistsFromResponse(response.data);
_cachedArtists = artists;
_lastCreatorsFetchAt = DateTime.now();
return artists;
} catch (error) {
if (cached != null) return cached;
throw StateError('Failed to load $name creators: ${_shortError(error)}');
}
}
List<ArtistProfile> _artistsFromResponse(dynamic data) {
final items = data is Map ? data['data'] : data;
if (items is! List) return const [];
return items
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.from(item);
final service = (json['service'] ?? '').toString();
final artistId =
(json['id'] ?? json['creator_id'] ?? json['user_id'] ?? '')
.toString();
final name = (json['name'] ??
json['display_name'] ??
json['username'] ??
artistId)
.toString();
final iconUrl = '$baseUrl/icons/$service/$artistId';
return ArtistProfile(
id: artistId,
providerId: id,
service: service,
name: name,
displayName: name,
avatarUrl: iconUrl,
updatedAt: _dateFromAny(json['updated']),
postCount: (json['favorited'] as num?)?.toInt(),
url: '$baseUrl/$service/user/$artistId',
);
})
.where((artist) => artist.id.isNotEmpty)
.toList(growable: false);
}
List<Post> _postsFromArtistPost(
Map<String, dynamic> json, {
required ArtistWorkQuery query,
}) {
final postId = (json['id'] ?? json['post_id'] ?? '').toString();
final title = (json['title'] ?? '').toString();
final published = _dateFromAny(json['published']) ??
_dateFromAny(json['added']) ??
_dateFromAny(json['edited']) ??
DateTime.fromMillisecondsSinceEpoch(0);
final files = <Map<String, dynamic>>[];
final file = json['file'];
if (file is Map && file.isNotEmpty) {
files.add(Map<String, dynamic>.from(file));
}
final attachments = json['attachments'];
if (attachments is List) {
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
}
var index = 0;
return files
.map((fileMap) {
final rawPath = (fileMap['path'] ?? '').toString();
final fileName = (fileMap['name'] ?? '').toString();
final previewOnly = fileMap['preview_only'] == true;
final previewUrl = _thumbnailUrl(rawPath);
final fileUrl = previewOnly
? previewUrl
: _fileDownloadUrl(rawPath, fileName);
final type = _fileType('$fileName $rawPath');
final stableId =
'${query.service}:${query.artistId}:$postId:${index++}';
final tags = [
query.service,
query.artistName,
if (title.trim().isNotEmpty) title.trim(),
];
return Post(
id: stableId,
providerId: id,
providerName: name,
previewUrl: previewUrl,
sampleUrl: previewUrl,
fileUrl: fileUrl,
tags: tags,
rating: 'unknown',
width: 0,
height: 0,
source:
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
createdAt: published,
fileType: type,
score: 0,
tagGroups: {
'artist': [query.artistName],
'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
},
);
})
.where((post) => post.fileUrl.isNotEmpty)
.toList(growable: false);
}
String _thumbnailUrl(String path) {
if (path.isEmpty) return '';
final trimmed = path.startsWith('/') ? path : '/$path';
return 'https://img.pawchive.pw/thumbnail/data$trimmed';
}
String _fileDownloadUrl(String path, String fileName) {
if (path.isEmpty) return '';
final trimmed = path.startsWith('/') ? path : '/$path';
final base = 'https://file.pawchive.pw/data$trimmed';
if (fileName.isEmpty) return base;
return '$base?f=${Uri.encodeComponent(fileName)}';
}
List<PostComment> _commentsFromResponse(dynamic data, String postId) {
final items = data is Map ? (data['comments'] ?? data['data']) : data;
if (items is! List) return const [];
return items
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.from(item);
return PostComment(
id: (json['id'] ?? json['comment_id'] ?? '').toString(),
postId: postId,
providerId: id,
authorName: (json['commenter'] ??
json['author'] ??
json['username'] ??
'user')
.toString(),
body: (json['content'] ?? json['body'] ?? json['comment'] ?? '')
.toString(),
createdAt: _dateFromAny(json['published']) ??
_dateFromAny(json['added']) ??
DateTime.fromMillisecondsSinceEpoch(0),
);
})
.where((comment) => comment.id.isNotEmpty && comment.body.isNotEmpty)
.toList(growable: false);
}
List<dynamic> _postItems(dynamic data) {
if (data is List) return data;
if (data is Map) {
final posts = data['posts'] ?? data['data'] ?? data['results'];
if (posts is List) return posts;
}
return const [];
}
List<ArtistProfile> _rankArtists(List<ArtistProfile> artists, String query) {
final queryText = query.trim().toLowerCase();
final ranked = [...artists];
ranked.sort((a, b) {
if (queryText.isNotEmpty) {
final aExact = a.displayName.toLowerCase() == queryText;
final bExact = b.displayName.toLowerCase() == queryText;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
final aStarts = a.displayName.toLowerCase().startsWith(queryText);
final bStarts = b.displayName.toLowerCase().startsWith(queryText);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
}
final favA = a.postCount ?? 0;
final favB = b.postCount ?? 0;
if (favA != favB) return favB.compareTo(favA);
final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
return updatedB.compareTo(updatedA);
});
return ranked;
}
String _shortError(Object? error) {
if (error == null) return '';
final message = error.toString();
if (message.length <= 160) return message;
return '${message.substring(0, 160)}...';
}
String _fileType(String value) {
final lower = value.toLowerCase().split('?').first;
if (lower.endsWith('.webm') || lower.endsWith('.mp4') || lower.endsWith('.mov')) {
return 'video';
}
if (lower.endsWith('.gif')) return 'gif';
if (lower.endsWith('.swf')) return 'swf';
if (lower.endsWith('.zip') || lower.endsWith('.rar') || lower.endsWith('.7z')) {
return 'archive';
}
if (lower.endsWith('.png') ||
lower.endsWith('.jpg') ||
lower.endsWith('.jpeg') ||
lower.endsWith('.webp')) {
return 'photo';
}
return 'unknown';
}
DateTime? _dateFromAny(dynamic value) {
if (value == null) return null;
if (value is num) {
final raw = value.toInt();
if (raw > 9999999999) return DateTime.fromMillisecondsSinceEpoch(raw);
return DateTime.fromMillisecondsSinceEpoch(raw * 1000);
}
return DateTime.tryParse(value.toString());
}
}
+21 -4
View File
@@ -7,8 +7,10 @@ import 'e621_provider.dart';
import 'gelbooru_provider.dart';
import 'kemono_provider.dart';
import 'moebooru_provider.dart';
import 'realbooru_provider.dart';
import 'pawchive_provider.dart';
import 'realbooru_html_provider.dart';
import 'rule34_provider.dart';
import 'rule34_paheal_provider.dart';
class ProviderFactory {
ContentProvider create(ContentProviderConfig config) {
@@ -41,13 +43,20 @@ class ProviderFactory {
dioClient: client,
queryParameters: queryParameters,
);
case 'realbooru':
return RealbooruProvider(
case 'paheal':
case 'rule34_paheal':
return Rule34PahealProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
);
case 'realbooru_html':
return RealbooruHtmlProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
);
case 'danbooru':
return DanbooruProvider(
@@ -82,6 +91,14 @@ class ProviderFactory {
dioClient: client,
queryParameters: queryParameters,
);
case 'pawchive':
return PawchiveProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
);
default:
return UnsupportedCustomProvider(
id: config.id,
+285 -27
View File
@@ -18,12 +18,30 @@ class ProviderManager {
final ProviderRepository _repository;
final ProviderFactory _factory;
final Map<String, _SuggestionCacheEntry> _suggestionCache = {};
final Set<String> _softRetryKeys = {};
final _softRetryController =
StreamController<ProviderSoftRetryResult>.broadcast();
_SuggestionProviderCacheEntry? _suggestionProviderCache;
static const _suggestionCacheTtl = Duration(seconds: 45);
static const _suggestionProviderCacheTtl = Duration(seconds: 60);
static const _suggestionTimeout = Duration(milliseconds: 1500);
Stream<ProviderSoftRetryResult> get softRetryResults =>
_softRetryController.stream;
Future<Result<List<ContentProviderConfig>>> loadConfigs({
bool enabledOnly = true,
}) async {
await _repository.ensureSeedProviders();
return _repository.getProviders(enabledOnly: enabledOnly);
final result = await _repository.getProviders(enabledOnly: enabledOnly);
if (result is Error<List<ContentProviderConfig>>) return result;
final configs = (result as Success<List<ContentProviderConfig>>)
.data
.where((config) => !_isLegacyRemovedConfig(config))
.toList(growable: false);
return Success(configs);
}
Future<Result<List<ContentProviderConfig>>> loadFeedConfigs({
@@ -97,20 +115,34 @@ class ProviderManager {
Failure(code: 'not_found', message: 'Provider not found'),
);
}
return _repository.saveProvider(
return _repository
.saveProvider(
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
);
)
.then((result) {
_clearSuggestionCaches();
return result;
});
},
onError: Error<void>.new,
);
}
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
return _repository
.saveProvider(config.copyWith(updatedAt: DateTime.now()))
.then((result) {
_clearSuggestionCaches();
return result;
});
}
Future<Result<void>> deleteProvider(String id) =>
_repository.deleteProvider(id);
Future<Result<void>> deleteProvider(String id) {
return _repository.deleteProvider(id).then((result) {
_clearSuggestionCaches();
return result;
});
}
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
final providersResult = await activeProviders();
@@ -186,6 +218,14 @@ class ProviderManager {
errorMessage: 'Search failed',
),
);
_scheduleSoftSearchRetry(
provider,
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
);
}
}
final posts = providerId == null
@@ -228,6 +268,75 @@ class ProviderManager {
return hash;
}
void _scheduleSoftSearchRetry(
ContentProvider provider, {
required List<String> tags,
required int page,
required int limit,
required String? rating,
required TopPeriodFilter topPeriod,
}) {
final key = [
provider.id,
page,
limit,
rating ?? '',
topPeriod.name,
...tags,
].join('\u001f');
if (!_softRetryKeys.add(key)) return;
unawaited(Future<void>.delayed(const Duration(milliseconds: 900), () async {
try {
final posts = await provider.searchPosts(
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
);
await _repository.saveDiagnostics(
ProviderDiagnostics(
providerId: provider.id,
lastSearchAt: DateTime.now(),
lastResultCount: posts.length,
),
);
await _repository.saveHealth(
ProviderHealth(
providerId: provider.id,
status: ProviderStatus.online,
pingMs: 0,
lastCheckedAt: DateTime.now(),
),
);
if (posts.isNotEmpty) {
_softRetryController.add(
ProviderSoftRetryResult(
providerId: provider.id,
tags: tags,
page: page,
limit: limit,
rating: rating,
topPeriod: topPeriod,
posts: posts,
),
);
}
} catch (_) {
await _repository.saveDiagnostics(
ProviderDiagnostics(
providerId: provider.id,
lastSearchAt: DateTime.now(),
lastResultCount: 0,
lastErrorMessage: 'Search failed after retry',
),
);
} finally {
_softRetryKeys.remove(key);
}
}));
}
Future<Result<List<PostComment>>> getComments(
String providerId,
String postId,
@@ -282,6 +391,39 @@ class ProviderManager {
}
}
Future<Result<String?>> getPostPageUrl(Post post) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
final matches =
providers.where((provider) => provider.id == post.providerId);
if (matches.isEmpty || matches.first is! PostPageProvider) {
return const Success(null);
}
return Success((matches.first as PostPageProvider).postPageUrl(post));
}
Future<Result<Map<String, String>>> getMediaHeaders(Post post) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
final matches =
providers.where((provider) => provider.id == post.providerId);
if (matches.isNotEmpty && matches.first is MediaHeadersProvider) {
return Success(
(matches.first as MediaHeadersProvider).mediaHeaders(post),
);
}
return const Success({
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
});
}
Future<Result<Post>> enrichPostTags(Post post) async {
final hasUsefulGroups = post.tagGroups.entries.any(
(entry) => entry.key != 'general' && entry.value.isNotEmpty,
@@ -313,30 +455,106 @@ class ProviderManager {
String query, {
int limit = 20,
}) async {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return const Success([]);
final providersResult = await activeSuggestionProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>).data;
if (providers.isEmpty) return const Success([]);
final cappedLimit = limit.clamp(1, 50);
final providerIds = providers.map((provider) => provider.id).join(',');
final cacheKey = '$normalizedQuery|$providerIds|$cappedLimit';
final cached = _suggestionCache[cacheKey];
if (cached != null &&
DateTime.now().difference(cached.createdAt) < _suggestionCacheTtl) {
return Success(cached.items.take(cappedLimit).toList(growable: false));
}
final providerResults = await Future.wait(
providers.map(
(provider) => _suggestionsFromProvider(
provider as TagSuggestionProvider,
normalizedQuery,
cappedLimit,
),
),
);
final suggestions = <String, TagSuggestion>{};
for (final items in providerResults) {
for (final item in items) {
final nameKey = item.name.trim().toLowerCase();
if (nameKey.isEmpty) continue;
final current = suggestions[nameKey];
if (current == null || item.postCount > current.postCount) {
suggestions[nameKey] = item;
}
}
}
final values = suggestions.values.toList()
..sort((a, b) {
final count = b.postCount.compareTo(a.postCount);
return count != 0 ? count : a.name.compareTo(b.name);
});
_suggestionCache[cacheKey] = _SuggestionCacheEntry(
DateTime.now(),
values,
);
_pruneSuggestionCache();
return Success(values.take(cappedLimit).toList());
}
Future<List<TagSuggestion>> _suggestionsFromProvider(
TagSuggestionProvider provider,
String query,
int limit,
) async {
try {
return await provider
.suggestTags(query, limit: limit)
.timeout(_suggestionTimeout);
} catch (_) {
// Suggestions are non-critical; a failed provider should not affect UI.
return const [];
}
}
void _pruneSuggestionCache() {
final now = DateTime.now();
_suggestionCache.removeWhere(
(_, entry) => now.difference(entry.createdAt) >= _suggestionCacheTtl,
);
}
Future<Result<List<ContentProvider>>> activeSuggestionProviders() async {
final cached = _suggestionProviderCache;
if (cached != null &&
DateTime.now().difference(cached.createdAt) <
_suggestionProviderCacheTtl) {
return Success(cached.providers);
}
final providersResult = await activeFeedProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>)
.data
.whereType<TagSuggestionProvider>()
.toList();
final suggestions = <String, TagSuggestion>{};
for (final provider in providers) {
try {
final items = await provider.suggestTags(query, limit: limit);
for (final item in items) {
suggestions.putIfAbsent(
'${item.providerId}:${item.name}', () => item);
}
} catch (_) {
// Suggestions are non-critical; a failed provider should not affect UI.
}
if (suggestions.length >= limit) break;
}
final values = suggestions.values.toList()
..sort((a, b) => b.postCount.compareTo(a.postCount));
return Success(values.take(limit).toList());
final providers = <ContentProvider>[
for (final provider
in (providersResult as Success<List<ContentProvider>>).data)
if (provider is TagSuggestionProvider) provider,
];
_suggestionProviderCache = _SuggestionProviderCacheEntry(
DateTime.now(),
providers,
);
return Success(providers);
}
void _clearSuggestionCaches() {
_suggestionCache.clear();
_suggestionProviderCache = null;
}
Future<List<R>> _runLimited<T, R>(
@@ -362,10 +580,50 @@ class ProviderManager {
static bool _isArtistConfig(ContentProviderConfig config) {
final type = config.apiType.toLowerCase();
return type == 'kemono' || type == 'coomer';
return type == 'kemono' || type == 'coomer' || type == 'pawchive';
}
static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config);
}
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
final id = config.id.toLowerCase();
final type = config.apiType.toLowerCase();
return id == 'cosbooru' || type == 'realbooru';
}
}
class _SuggestionCacheEntry {
const _SuggestionCacheEntry(this.createdAt, this.items);
final DateTime createdAt;
final List<TagSuggestion> items;
}
class _SuggestionProviderCacheEntry {
const _SuggestionProviderCacheEntry(this.createdAt, this.providers);
final DateTime createdAt;
final List<ContentProvider> providers;
}
class ProviderSoftRetryResult {
const ProviderSoftRetryResult({
required this.providerId,
required this.tags,
required this.page,
required this.limit,
required this.topPeriod,
required this.posts,
this.rating,
});
final String providerId;
final List<String> tags;
final int page;
final int limit;
final TopPeriodFilter topPeriod;
final String? rating;
final List<Post> posts;
}
@@ -0,0 +1,341 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class RealbooruHtmlProvider
implements
ContentProvider,
PostPageProvider,
MediaHeadersProvider,
TagSuggestionProvider {
RealbooruHtmlProvider({
required this.id,
required this.name,
required this.baseUrl,
required DioClient dioClient,
}) : _dio = dioClient.dio;
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final queryTags = [
if (tags.isEmpty) 'all' else ...tags,
if (rating != null && rating.isNotEmpty) 'rating:$rating',
].join(' ');
final response = await _dio.get<String>(
'/index.php',
queryParameters: {
'page': 'post',
's': 'list',
'tags': queryTags,
'pid': page * limit,
},
options: Options(responseType: ResponseType.plain),
);
return _parseList(response.data ?? '').take(limit).toList(growable: false);
}
@override
Future<Post?> getPost(String id) async {
final response = await _dio.get<String>(
'/index.php',
queryParameters: {'page': 'post', 's': 'view', 'id': id},
options: Options(responseType: ResponseType.plain),
);
return _parseDetails(id, response.data ?? '');
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final posts = await searchPosts(tags: const ['all'], page: 0, limit: 1);
return ProviderHealth(
providerId: id,
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'realbooru-html',
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
);
}
}
@override
String postPageUrl(Post post) =>
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
@override
Map<String, String> mediaHeaders(Post post) => {
'User-Agent':
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
'Accept':
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer': postPageUrl(post),
};
@override
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'autocomplete',
'term': trimmed,
},
);
final values = _autocompleteItems(response.data);
return values
.map((name) => TagSuggestion(
name: name,
category: TagCategory.unknown,
postCount: 0,
providerId: id,
))
.where((item) => item.name.toLowerCase().startsWith(
trimmed.toLowerCase(),
))
.take(limit.clamp(1, 50))
.toList(growable: false);
}
List<Post> _parseList(String html) {
final posts = <Post>[];
final pattern = RegExp(
r'''<div[^>]*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?<a[^>]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>''',
caseSensitive: false,
);
for (final match in pattern.allMatches(html)) {
final id = match.group(1) ?? '';
final imgTag = match.group(0) ?? '';
final previewUrl = _absolute(_decode(match.group(3) ?? ''));
final tags = _tags(_attribute(imgTag, 'title'));
final isVideo = imgTag.contains('#0000ff') ||
tags.contains('webm') ||
tags.contains('video');
final isGif =
!isVideo && (tags.contains('gif') || tags.contains('animated_gif'));
final derivedOriginal =
isVideo ? '' : _imageUrlFromThumbnail(previewUrl, gif: isGif);
posts.add(
Post(
id: id,
providerId: this.id,
providerName: name,
previewUrl: previewUrl,
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
fileUrl: isVideo ? '' : derivedOriginal,
tags: tags,
rating: 'explicit',
width: 0,
height: 0,
source: postPageUrl(Post(
id: id,
providerId: this.id,
providerName: name,
previewUrl: previewUrl,
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
fileUrl: isVideo ? '' : derivedOriginal,
tags: tags,
rating: 'explicit',
width: 0,
height: 0,
createdAt: DateTime.now(),
fileType: isVideo
? 'video'
: isGif
? 'gif'
: 'image',
score: 0,
)),
createdAt: DateTime.now(),
fileType: isVideo
? 'video'
: isGif
? 'gif'
: 'image',
score: 0,
tagGroups: _tagGroups(tags),
),
);
}
return posts;
}
Post? _parseDetails(String postId, String html) {
final imageTag = RegExp(
r'''<(?:img|video)[^>]+(?:id=["']image["'][^>]*|[^>]*id=["']image["'])''',
caseSensitive: false,
).firstMatch(html)?.group(0);
final originalUrl = _absolute(
RegExp(
r'''<a[^>]+href=["']([^"']+)["'][^>]*>\s*Original\s*</a>''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final videoSources = RegExp(
r'''<source[^>]+src=["']([^"']+\.(?:webm|mp4)[^"']*)["']''',
caseSensitive: false,
)
.allMatches(html)
.map((match) => _absolute(match.group(1) ?? ''))
.where((url) => url.isNotEmpty)
.toList();
videoSources.sort((left, right) {
final leftWebm = left.toLowerCase().split('?').first.endsWith('.webm');
final rightWebm = right.toLowerCase().split('?').first.endsWith('.webm');
if (leftWebm == rightWebm) return 0;
return leftWebm ? -1 : 1;
});
final imageUrl = _absolute(_attribute(imageTag ?? '', 'src'));
final mediaUrl = videoSources.isNotEmpty
? videoSources.first
: originalUrl.isNotEmpty
? originalUrl
: imageUrl;
final sampleUrl = videoSources.length > 1 ? videoSources[1] : mediaUrl;
final source = _attribute(
RegExp(r'''<input[^>]+id=["']source["'][^>]*>''', caseSensitive: false)
.firstMatch(html)
?.group(0) ??
'',
'value',
);
final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty
? _attribute(imageTag ?? '', 'alt')
: RegExp(
r'''<textarea[^>]+id=["']tags["'][^>]*>([\s\S]*?)</textarea>''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'';
final tags = _tags(tagsText);
if (mediaUrl.isEmpty) return null;
return Post(
id: postId,
providerId: id,
providerName: name,
previewUrl: imageUrl.isEmpty ? mediaUrl : imageUrl,
sampleUrl: sampleUrl,
fileUrl: mediaUrl,
tags: tags,
rating: 'explicit',
width: 0,
height: 0,
source: source.isEmpty ? postPageUrlById(postId) : source,
createdAt: DateTime.now(),
fileType: _fileType(mediaUrl),
score: 0,
tagGroups: _tagGroups(tags),
);
}
String postPageUrlById(String postId) =>
'$baseUrl/index.php?page=post&s=view&id=$postId';
String _attribute(String source, String name) {
final match = RegExp(
'''$name\\s*=\\s*["']([^"']*)["']''',
caseSensitive: false,
).firstMatch(source);
return _decode(match?.group(1) ?? '');
}
List<String> _tags(String value) {
return _decode(value)
.replaceAll(',', ' ')
.split(RegExp(r'\s+'))
.map((tag) => tag.trim().replaceAll(' ', '_'))
.where((tag) => tag.isNotEmpty)
.toSet()
.toList(growable: false);
}
Map<String, List<String>> _tagGroups(List<String> tags) =>
tags.isEmpty ? const {} : {'general': tags};
String _fileType(String url) {
final lower = url.toLowerCase().split('?').first;
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
if (lower.endsWith('.gif')) return 'gif';
return 'image';
}
String _absolute(String url) {
final value = _decode(url.trim());
if (value.startsWith('//')) return _normalizeUrl('https:$value');
if (value.startsWith('/')) return _normalizeUrl('$baseUrl$value');
return _normalizeUrl(value);
}
String _normalizeUrl(String url) {
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return url;
final normalizedPath = uri.path.replaceFirst(RegExp(r'^/+'), '/');
return uri.replace(path: normalizedPath).toString();
}
String _imageUrlFromThumbnail(String thumbnailUrl, {bool gif = false}) {
final uri = Uri.tryParse(thumbnailUrl);
if (uri == null) return '';
final match = RegExp(r'/thumbnails/([^/]+)/([^/]+)/thumbnail_([^/.]+)\.')
.firstMatch(uri.path);
if (match == null) return '';
final extension = gif ? 'gif' : 'jpeg';
final path =
'/images/${match.group(1)}/${match.group(2)}/${match.group(3)}.$extension';
return uri.replace(path: path, query: '').toString();
}
String _decode(String value) {
return value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
List<String> _autocompleteItems(dynamic data) {
final source = data is String ? jsonDecode(data) : data;
if (source is List) {
return source
.map((item) => item.toString().trim().replaceAll(' ', '_'))
.where((item) => item.isNotEmpty)
.toSet()
.toList(growable: false);
}
return const [];
}
}
@@ -1,126 +0,0 @@
import '../../core/errors/app_exception.dart';
import '../mappers/gelbooru_mapper.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'gelbooru_provider.dart';
class RealbooruProvider extends GelbooruProvider {
RealbooruProvider({
required super.id,
required super.name,
required super.baseUrl,
required super.dioClient,
super.queryParameters,
});
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final queryTags = [
...tags,
if (rating != null && rating.isNotEmpty) 'rating:$rating',
if (topPeriod == TopPeriodFilter.allTime) 'sort:score:desc',
];
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'pid': page,
'limit': limit.clamp(1, 100),
'tags': queryTags.join(' '),
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
return GelbooruMapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
}
@override
Future<Post?> getPost(String id) async {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'id': id,
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
final posts = GelbooruMapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'limit': 1,
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
return ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'realbooru-dapi',
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: 'realbooru-dapi',
);
}
}
void _throwIfSearchFailed(dynamic data) {
if (data is Map) {
final success = data['success']?.toString().toLowerCase();
if (success == 'false') {
throw ProviderUnavailableException(
(data['message'] ??
data['reason'] ??
'Realbooru search is unavailable')
.toString(),
);
}
}
final text = data?.toString().toLowerCase() ?? '';
if (text.contains('success="false"') || text.contains("success='false'")) {
final message = RegExp(r'''(?:message|reason)\s*=\s*["']([^"']+)["']''')
.firstMatch(data?.toString() ?? '');
throw ProviderUnavailableException(
message?.group(1) ?? 'Realbooru search is unavailable',
);
}
}
}
@@ -0,0 +1,239 @@
import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class Rule34PahealProvider implements ContentProvider, PostPageProvider {
Rule34PahealProvider({
required this.id,
required this.name,
required this.baseUrl,
required DioClient dioClient,
}) : _dio = dioClient.dio;
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
// Paheal's public HTML list ignores common sort/top query parameters and
// exposes no stable top-period endpoint, so non-latest filters fall back
// to the regular list instead of pretending to support ranking.
final query = tags
.where((tag) => tag.trim().isNotEmpty && tag.trim() != 'all')
.map((tag) => tag.trim().replaceAll(' ', '_'))
.join('_');
final path = query.isEmpty
? '/post/list/${page + 1}'
: '/post/list/$query/${page + 1}';
final response = await _dio.get<String>(
path,
options: Options(responseType: ResponseType.plain),
);
return _parseList(response.data ?? '').take(limit).toList(growable: false);
}
@override
Future<Post?> getPost(String id) async {
final response = await _dio.get<String>(
'/post/view/$id',
options: Options(responseType: ResponseType.plain),
);
return _parseSingle(id, response.data ?? '');
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final posts = await searchPosts(tags: const [], page: 0, limit: 1);
return ProviderHealth(
providerId: id,
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'paheal-html',
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: 'paheal-html',
);
}
}
@override
String postPageUrl(Post post) => '$baseUrl/post/view/${post.id}';
List<Post> _parseList(String html) {
final posts = <Post>[];
final pattern = RegExp(
r'''<div[^>]+class=['"][^'"]*\bshm-thumb\b[^'"]*['"][\s\S]*?</div>''',
caseSensitive: false,
);
for (final match in pattern.allMatches(html)) {
final block = match.group(0) ?? '';
final postId = _attribute(block, 'data-post-id');
final fileUrl = _absolute(
RegExp(
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*File Only\s*</a>''',
caseSensitive: false,
).firstMatch(block)?.group(1) ??
'',
);
final previewUrl = _absolute(
RegExp(r'''<img[^>]+src=['"]([^'"]+)['"]''', caseSensitive: false)
.firstMatch(block)
?.group(1) ??
'',
);
if (postId.isEmpty || (fileUrl.isEmpty && previewUrl.isEmpty)) continue;
final tags = _tags(_attribute(block, 'data-tags'));
final ext = _attribute(block, 'data-ext');
final dimensions = _dimensions(_attribute(block, 'title'));
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
posts.add(
Post(
id: postId,
providerId: id,
providerName: name,
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
sampleUrl: mediaUrl,
fileUrl: mediaUrl,
tags: tags,
rating: 'explicit',
width: dimensions.$1,
height: dimensions.$2,
source: '$baseUrl/post/view/$postId',
createdAt: DateTime.now(),
fileType: _fileType('$ext $mediaUrl'),
score: 0,
tagGroups: tags.isEmpty ? const {} : {'general': tags},
),
);
}
if (posts.isNotEmpty) return posts;
final singleId = RegExp(r'''/post/view/(\d+)''').firstMatch(html)?.group(1);
final single = singleId == null ? null : _parseSingle(singleId, html);
return single == null ? const [] : [single];
}
Post? _parseSingle(String postId, String html) {
final fileUrl = _absolute(
RegExp(
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*(?:File|Image) Only\s*</a>''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
RegExp(
r'''<(?:img|video|source)[^>]+src=['"]([^'"]+(?:jpg|jpeg|png|gif|webm|mp4)[^'"]*)['"]''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final previewUrl = _absolute(
RegExp(
r'''<meta[^>]+property=['"]og:image['"][^>]+content=['"]([^'"]+)['"]''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'',
);
final tagsText = RegExp(
r'''<meta[^>]+name=['"]keywords['"][^>]+content=['"]([^'"]+)['"]''',
caseSensitive: false,
).firstMatch(html)?.group(1) ??
'';
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
if (mediaUrl.isEmpty) return null;
final tags = _tags(tagsText);
return Post(
id: postId,
providerId: id,
providerName: name,
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
sampleUrl: mediaUrl,
fileUrl: mediaUrl,
tags: tags,
rating: 'explicit',
width: 0,
height: 0,
source: '$baseUrl/post/view/$postId',
createdAt: DateTime.now(),
fileType: _fileType(mediaUrl),
score: 0,
tagGroups: tags.isEmpty ? const {} : {'general': tags},
);
}
String _attribute(String source, String name) {
return _decode(
RegExp(
'''$name\\s*=\\s*['"]([^'"]*)['"]''',
caseSensitive: false,
).firstMatch(source)?.group(1) ??
'',
);
}
(int, int) _dimensions(String value) {
final match = RegExp(r'(\d+)x(\d+)').firstMatch(value);
return (
int.tryParse(match?.group(1) ?? '') ?? 0,
int.tryParse(match?.group(2) ?? '') ?? 0,
);
}
List<String> _tags(String value) {
return _decode(value)
.replaceAll(',', ' ')
.split(RegExp(r'\s+'))
.map((tag) => tag.trim().replaceAll(' ', '_').toLowerCase())
.where((tag) => tag.isNotEmpty)
.toSet()
.toList(growable: false);
}
String _fileType(String value) {
final lower = value.toLowerCase().split('?').first;
if (lower.contains('webm') || lower.contains('mp4')) return 'video';
if (lower.contains('gif')) return 'gif';
if (lower.contains('png')) return 'png';
return 'image';
}
String _absolute(String url) {
final value = _decode(url.trim());
if (value.isEmpty) return '';
if (value.startsWith('//')) return 'https:$value';
if (value.startsWith('/')) return '$baseUrl$value';
return value;
}
String _decode(String value) {
return value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
}
@@ -12,6 +12,17 @@ class Rule34Provider extends GelbooruProvider {
super.queryParameters,
});
@override
String postPageUrl(Post post) =>
'https://rule34.xxx/index.php?page=post&s=view&id=${post.id}';
@override
Map<String, String> mediaHeaders(Post post) => const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
'Referer': 'https://rule34.xxx/',
};
@override
Future<List<Post>> searchPosts({
required List<String> tags,
+32 -126
View File
@@ -40,10 +40,10 @@ class ProviderRepository {
updatedAt: now,
),
ContentProviderConfig(
id: 'safebooru',
name: 'Safebooru',
baseUrl: 'https://safebooru.org',
apiType: 'gelbooru',
id: 'realbooru',
name: 'Realbooru',
baseUrl: 'https://realbooru.com',
apiType: 'realbooru_html',
enabled: true,
priority: 2,
timeoutSeconds: 20,
@@ -51,44 +51,16 @@ class ProviderRepository {
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'konachan',
name: 'Konachan',
baseUrl: 'https://konachan.com',
apiType: 'moebooru',
enabled: true,
priority: 3,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'yandere',
name: 'Yande.re',
baseUrl: 'https://yande.re',
apiType: 'moebooru',
enabled: true,
priority: 4,
timeoutSeconds: 20,
customHeaders: const {
'query.api_version': '2',
'query.include_tags': '1',
'query.filter': '1',
},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'e621',
name: 'e621',
baseUrl: 'https://e621.net',
apiType: 'e621',
enabled: true,
priority: 5,
priority: 3,
timeoutSeconds: 20,
customHeaders: const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
},
createdAt: now,
updatedAt: now,
@@ -99,78 +71,26 @@ class ProviderRepository {
baseUrl: 'https://e926.net',
apiType: 'e621',
enabled: true,
priority: 6,
priority: 4,
timeoutSeconds: 20,
customHeaders: const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'realbooru',
name: 'Realbooru',
baseUrl: 'https://realbooru.com',
apiType: 'realbooru',
enabled: false,
priority: 7,
id: 'pawchive',
name: 'Pawchive',
baseUrl: 'https://pawchive.pw',
apiType: 'pawchive',
enabled: true,
priority: 5,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'xbooru',
name: 'Xbooru',
baseUrl: 'https://xbooru.com',
apiType: 'gelbooru',
enabled: true,
priority: 8,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'cosbooru',
name: 'CosBooru',
baseUrl: 'https://cos.lycore.co',
apiType: 'danbooru',
enabled: true,
priority: 9,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'kemono',
name: 'Kemono',
baseUrl: 'https://kemono.su',
apiType: 'kemono',
enabled: true,
priority: 10,
timeoutSeconds: 20,
customHeaders: const {
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
},
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'coomer',
name: 'Coomer',
baseUrl: 'https://coomer.su',
apiType: 'coomer',
enabled: false,
priority: 11,
timeoutSeconds: 20,
customHeaders: const {
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
},
createdAt: now,
updatedAt: now,
),
];
}
@@ -178,35 +98,24 @@ class ProviderRepository {
return _databaseService.safeWrite((isar) async {
final existingProviders =
await isar.providerConfigEntitys.where().findAll();
const removedIds = {
'safebooru',
'konachan',
'yandere',
'xbooru',
'paheal',
'kemono',
'coomer',
};
for (final provider in existingProviders) {
if (provider.providerId == 'paheal') {
if (provider.providerId == 'cosbooru') {
provider.enabled = false;
provider.updatedAt = DateTime.now();
await isar.providerConfigEntitys.put(provider);
} else if (removedIds.contains(provider.providerId)) {
await isar.providerConfigEntitys.delete(provider.isarId);
}
}
await isar.providerHealthEntitys
.filter()
.providerIdEqualTo('paheal')
.deleteAll();
await isar.providerDiagnosticsEntitys
.filter()
.providerIdEqualTo('paheal')
.deleteAll();
await isar.cachedPostEntitys
.filter()
.providerIdEqualTo('paheal')
.deleteAll();
await isar.favoriteEntitys
.filter()
.providerIdEqualTo('paheal')
.deleteAll();
await isar.collectionPostEntitys
.filter()
.providerIdEqualTo('paheal')
.deleteAll();
await isar.viewedPostEntitys
.filter()
.providerIdEqualTo('paheal')
.deleteAll();
final seeds = seedProviders();
for (final seed in seeds) {
final exists = await isar.providerConfigEntitys
@@ -216,13 +125,10 @@ class ProviderRepository {
if (exists == null) {
await isar.providerConfigEntitys
.put(ProviderConfigEntity.fromModel(seed));
} else if (seed.id == 'realbooru' && exists.enabled) {
exists.enabled = false;
exists.updatedAt = DateTime.now();
await isar.providerConfigEntitys.put(exists);
} else if (seed.id == 'cosbooru' &&
(exists.baseUrl != seed.baseUrl ||
exists.apiType != seed.apiType)) {
} else if (seed.id == 'realbooru' &&
(exists.apiType != seed.apiType ||
exists.baseUrl != seed.baseUrl ||
exists.name != seed.name)) {
exists.name = seed.name;
exists.baseUrl = seed.baseUrl;
exists.apiType = seed.apiType;
@@ -23,11 +23,12 @@ class SearchRepository {
});
}
Future<Result<List<SearchHistory>>> recent({int limit = 20}) {
Future<Result<List<SearchHistory>>> recent({int? limit}) {
return _databaseService.safeRead((isar) async {
final items = await isar.searchHistoryEntitys.where().findAll();
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
return items.take(limit).map((entity) => entity.toModel()).toList();
final limited = limit == null ? items : items.take(limit);
return limited.map((entity) => entity.toModel()).toList();
});
}
@@ -16,9 +16,12 @@ class DownloadManagerService {
final DownloadedMediaService? _downloadedMediaService;
final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {};
final Set<String> _runningTaskIds = {};
bool _queuePaused = false;
Stream<List<DownloadTask>> get stream => _controller.stream;
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
bool get queuePaused => _queuePaused;
Future<DownloadTask> start(Post post) async {
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
@@ -30,7 +33,7 @@ class DownloadManagerService {
status: DownloadTaskStatus.queued,
);
_set(task);
unawaited(_run(task));
_pumpQueue();
return task;
}
@@ -49,10 +52,21 @@ class DownloadManagerService {
openAfterDownload: openAfterDownload,
);
_set(task);
unawaited(_run(task));
_pumpQueue();
return task;
}
void pauseQueue() {
_queuePaused = true;
_emit();
}
void resumeQueue() {
_queuePaused = false;
_emit();
_pumpQueue();
}
Future<void> retry(String taskId) async {
final existing = _tasks[taskId];
if (existing == null) return;
@@ -62,7 +76,7 @@ class DownloadManagerService {
error: null,
);
_set(task);
await _run(task);
_pumpQueue();
}
void cancel(String taskId) {
@@ -71,8 +85,19 @@ class DownloadManagerService {
_set(existing.copyWith(status: DownloadTaskStatus.canceled));
}
void clearFinished() {
_tasks.removeWhere(
(_, task) =>
task.status == DownloadTaskStatus.completed ||
task.status == DownloadTaskStatus.failed ||
task.status == DownloadTaskStatus.canceled,
);
_emit();
}
Future<void> _run(DownloadTask task) async {
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
if (!_runningTaskIds.add(task.id)) return;
_set(task.copyWith(status: DownloadTaskStatus.running));
try {
final saved = task.sourceUrl == null
@@ -121,14 +146,30 @@ class DownloadManagerService {
error: error.toString(),
),
);
} finally {
_runningTaskIds.remove(task.id);
_pumpQueue();
}
}
void _set(DownloadTask task) {
_tasks[task.id] = task;
_emit();
}
void _emit() {
_controller.add(tasks);
}
void _pumpQueue() {
if (_queuePaused) return;
for (final task in tasks) {
if (task.status != DownloadTaskStatus.queued) continue;
if (_runningTaskIds.contains(task.id)) continue;
unawaited(_run(task));
}
}
void _updateProgress(String taskId, int received, int total) {
if (total <= 0) return;
final current = _tasks[taskId];
@@ -143,7 +184,7 @@ class DownloadManagerService {
final current = _tasks[taskId];
if (current?.status != DownloadTaskStatus.completed) return;
_tasks.remove(taskId);
_controller.add(tasks);
_emit();
});
}
+34 -2
View File
@@ -28,7 +28,12 @@ class DownloadService {
if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, fileName);
await _dio.download(url, tempPath, onReceiveProgress: onProgress);
await _dio.download(
url,
tempPath,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
final saved = await _channel.invokeMethod<String>('saveToDownloads', {
'path': tempPath,
'fileName': fileName,
@@ -39,7 +44,12 @@ class DownloadService {
final location = await getSaveLocation(suggestedName: fileName);
if (location == null) return null;
await _dio.download(url, location.path, onReceiveProgress: onProgress);
await _dio.download(
url,
location.path,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
return location.path;
}
@@ -120,4 +130,26 @@ class DownloadService {
if (value.contains('.png') || value.contains('png')) return 'image/png';
return 'image/jpeg';
}
Map<String, String> _headersFor(Post post) {
final lower = '${post.providerId} ${post.providerName} '
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
.toLowerCase();
return {
'User-Agent': lower.contains('realbooru') || lower.contains('paheal')
? 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36'
: 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': lower.contains('realbooru') || lower.contains('paheal')
? 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8'
: '*/*',
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
if (lower.contains('rule34') && !lower.contains('paheal'))
'Referer': 'https://rule34.xxx/',
if (lower.contains('realbooru'))
'Referer':
'https://realbooru.com/index.php?page=post&s=view&id=${post.id}',
if (lower.contains('paheal'))
'Referer': 'https://rule34.paheal.net/post/view/${post.id}',
};
}
}
+8 -1
View File
@@ -118,7 +118,14 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
.toList(growable: false);
if (requested.isEmpty) return true;
final postTags = _postTagSet(post);
return requested.every(postTags.contains);
return requested.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
}
bool postPassesTagFilters(Post post, AppSettings settings) {
+1 -1
View File
@@ -32,7 +32,7 @@ class SearchService {
);
}
Future<Result<List<SearchHistory>>> recentSearches({int limit = 20}) {
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
return _repository.recent(limit: limit);
}
+47 -3
View File
@@ -41,18 +41,21 @@ class AppSettings {
required this.videoPlayerHalfVolume,
required this.videoPlayerLoop,
required this.videoPlayerCover,
required this.videoPlaybackPositions,
required this.hiddenPostKeys,
required this.diagnosticLogLines,
required this.lastFeedTags,
required this.lastFeedProviderIds,
required this.lastFeedTopPeriod,
required this.lastFeedScrollOffset,
this.favoriteArtists = const [],
this.skippedUpdateVersion,
this.lastUpdateCheckAt,
this.defaultRatingFilter,
this.lastFeedRating,
});
final List<String> favoriteArtists;
final List<String> enabledProviderIds;
final bool nsfwEnabled;
final int cacheTtlHours;
@@ -82,6 +85,7 @@ class AppSettings {
final bool videoPlayerHalfVolume;
final bool videoPlayerLoop;
final bool videoPlayerCover;
final Map<String, int> videoPlaybackPositions;
final List<String> hiddenPostKeys;
final List<String> diagnosticLogLines;
final List<String> lastFeedTags;
@@ -94,11 +98,12 @@ class AppSettings {
final String? lastFeedRating;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
nsfwEnabled: true,
cacheTtlHours: 24,
cacheMaxItems: 2000,
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
favoriteArtists: [],
themeMode: 'dark',
languageCode: 'ru',
appSeedColor: 0xFFE84D8A,
@@ -108,7 +113,7 @@ class AppSettings {
mobileColumns: 2,
blurExplicitContent: true,
allowDownloads: true,
autoDownloadFavorites: false,
autoDownloadFavorites: true,
selectedFeedProviderIds: [],
showPostBadges: true,
defaultTopPeriodFilter: 'none',
@@ -123,6 +128,7 @@ class AppSettings {
videoPlayerHalfVolume: false,
videoPlayerLoop: false,
videoPlayerCover: false,
videoPlaybackPositions: {},
hiddenPostKeys: [],
diagnosticLogLines: [],
lastFeedTags: [],
@@ -165,6 +171,7 @@ class AppSettings {
bool? videoPlayerHalfVolume,
bool? videoPlayerLoop,
bool? videoPlayerCover,
Map<String, int>? videoPlaybackPositions,
List<String>? hiddenPostKeys,
List<String>? diagnosticLogLines,
List<String>? lastFeedTags,
@@ -176,8 +183,10 @@ class AppSettings {
String? defaultRatingFilter,
String? lastFeedRating,
bool clearLastFeedRating = false,
List<String>? favoriteArtists,
}) {
return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
@@ -212,6 +221,8 @@ class AppSettings {
videoPlayerHalfVolume ?? this.videoPlayerHalfVolume,
videoPlayerLoop: videoPlayerLoop ?? this.videoPlayerLoop,
videoPlayerCover: videoPlayerCover ?? this.videoPlayerCover,
videoPlaybackPositions:
videoPlaybackPositions ?? this.videoPlaybackPositions,
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
@@ -227,6 +238,7 @@ class AppSettings {
}
Map<String, dynamic> toJson() => {
'favoriteArtists': favoriteArtists,
'enabledProviderIds': enabledProviderIds,
'nsfwEnabled': nsfwEnabled,
'cacheTtlHours': cacheTtlHours,
@@ -256,6 +268,7 @@ class AppSettings {
'videoPlayerHalfVolume': videoPlayerHalfVolume,
'videoPlayerLoop': videoPlayerLoop,
'videoPlayerCover': videoPlayerCover,
'videoPlaybackPositions': videoPlaybackPositions,
'hiddenPostKeys': hiddenPostKeys,
'diagnosticLogLines': diagnosticLogLines,
'lastFeedTags': lastFeedTags,
@@ -334,6 +347,10 @@ class AppSettings {
(json['videoPlayerLoop'] as bool?) ?? defaults.videoPlayerLoop,
videoPlayerCover:
(json['videoPlayerCover'] as bool?) ?? defaults.videoPlayerCover,
videoPlaybackPositions: Map<String, int>.from(
(json['videoPlaybackPositions'] as Map?) ??
defaults.videoPlaybackPositions,
),
hiddenPostKeys: List<String>.from(
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
),
@@ -354,6 +371,9 @@ class AppSettings {
lastFeedScrollOffset:
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
defaults.lastFeedScrollOffset,
favoriteArtists: List<String>.from(
(json['favoriteArtists'] as List?) ?? defaults.favoriteArtists,
),
skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
defaultRatingFilter: json['defaultRatingFilter'] as String?,
@@ -469,6 +489,30 @@ class SettingsService {
return updateSettings(settings.copyWith(hiddenPostKeys: const []));
}
Future<Result<void>> saveVideoPlaybackPosition(
String cacheKey,
int milliseconds, {
int maxEntries = 300,
}) async {
if (cacheKey.isEmpty || milliseconds < 1000) return const Success(null);
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
final existing = settings.videoPlaybackPositions[cacheKey] ?? 0;
if ((existing - milliseconds).abs() < 1000) return const Success(null);
final positions = <String, int>{
...settings.videoPlaybackPositions,
cacheKey: milliseconds,
};
if (positions.length > maxEntries) {
final removeCount = positions.length - maxEntries;
for (final key in positions.keys.take(removeCount).toList()) {
positions.remove(key);
}
}
return updateSettings(settings.copyWith(videoPlaybackPositions: positions));
}
Future<Result<void>> appendDiagnosticLog(String message) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
+11 -2
View File
@@ -11,9 +11,9 @@ class UpdateService {
UpdateService(this._dio, this._settingsService);
static const latestReleaseUrl =
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases/latest';
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases/latest';
static const releasesUrl =
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases';
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
final Dio _dio;
final SettingsService _settingsService;
@@ -100,6 +100,7 @@ class UpdateService {
String? apkUrl;
String? installerUrl;
String? portableUrl;
String? linuxTarGzUrl;
for (final item in assets.whereType<Map>()) {
final name = (item['name'] ?? '').toString().toLowerCase();
final url = (item['browser_download_url'] ?? '').toString();
@@ -107,6 +108,9 @@ class UpdateService {
if (name.endsWith('.apk')) apkUrl = url;
if (name.endsWith('.exe')) installerUrl = url;
if (name.endsWith('.zip')) portableUrl = url;
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
linuxTarGzUrl = url;
}
}
return AppUpdateInfo(
version: _versionFromTag(tag),
@@ -118,6 +122,7 @@ class UpdateService {
apkUrl: apkUrl,
windowsInstallerUrl: installerUrl,
portableZipUrl: portableUrl,
linuxTarGzUrl: linuxTarGzUrl,
);
}
@@ -125,6 +130,9 @@ class UpdateService {
if (Platform.isAndroid) {
return info.apkUrl;
}
if (Platform.isLinux) {
return info.linuxTarGzUrl ?? info.portableZipUrl;
}
if (Platform.isWindows) {
return info.windowsInstallerUrl ?? info.portableZipUrl;
}
@@ -137,6 +145,7 @@ class UpdateService {
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
if (fromUrl.contains('.')) return fromUrl;
if (Platform.isAndroid) return 'Lunaris-v${info.version}.apk';
if (Platform.isLinux) return 'Lunaris-v${info.version}-linux-x64.tar.gz';
if (Platform.isWindows) return 'LunarisSetup-v${info.version}.exe';
return 'LunarisPortable-v${info.version}.zip';
}
@@ -35,7 +35,38 @@ class ViewedHistoryService {
);
}
Future<Result<List<ViewedPostEntry>>> getViewedPostEntries() async {
final viewedResult = await _repository.recent();
if (viewedResult is Error<List<ViewedPost>>) {
return Error(viewedResult.failure);
}
final viewed = (viewedResult as Success<List<ViewedPost>>).data;
final postsResult = await _postRepository.getCachedPostsByKeys(
viewed.map((item) => item.viewedKey),
);
if (postsResult is Error<List<Post>>) {
return Error(postsResult.failure);
}
final postsByKey = {
for (final post in (postsResult as Success<List<Post>>).data)
post.cacheKey: post,
};
return Success([
for (final item in viewed)
if (postsByKey[item.viewedKey] != null)
ViewedPostEntry(
post: postsByKey[item.viewedKey]!, viewedAt: item.viewedAt),
]);
}
Future<Result<void>> clearHistory() {
return _repository.clear();
}
}
class ViewedPostEntry {
const ViewedPostEntry({required this.post, required this.viewedAt});
final Post post;
final DateTime viewedAt;
}
@@ -88,7 +88,10 @@ class ArtistPostsScreen extends ConsumerWidget {
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
extra: PostNavigationContext(
currentPost: post,
posts: items,
),
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
@@ -1,14 +1,18 @@
import 'dart:convert';
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 '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../settings/presentation/settings_controller.dart';
import 'artist_posts_screen.dart';
final artistProviderConfigsProvider =
FutureProvider<List<ContentProviderConfig>>((ref) async {
@@ -37,6 +41,40 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
Object? _error;
List<ArtistProfile> _artists = const [];
String? _selectedFavoriteArtistKey;
Future<void> _toggleFavoriteArtist(
AppSettings settings,
ArtistProfile artist,
) async {
final current = List<String>.from(settings.favoriteArtists);
final item = _FavoriteArtistItem(
id: artist.id,
service: artist.service,
providerId: artist.providerId,
name: artist.displayName,
avatarUrl: artist.avatarUrl,
);
final key = item.key;
final index = current.indexWhere((e) {
try {
final parsed =
_FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>);
return parsed.key == key;
} catch (_) {
return false;
}
});
if (index >= 0) {
current.removeAt(index);
} else {
current.insert(0, jsonEncode(item.toJson()));
}
await ref.read(settingsControllerProvider.notifier).saveSettings(
settings.copyWith(favoriteArtists: current),
);
}
@override
void initState() {
super.initState();
@@ -55,6 +93,27 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
@override
Widget build(BuildContext context) {
final configs = ref.watch(artistProviderConfigsProvider);
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteItems = settings.favoriteArtists
.map((e) {
try {
return _FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>);
} catch (_) {
return null;
}
})
.whereType<_FavoriteArtistItem>()
.toList();
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
final selectedFav = favoriteItems.firstWhere(
(e) => e.key == _selectedFavoriteArtistKey,
orElse: () => favoriteItems.isNotEmpty
? favoriteItems.first
: const _FavoriteArtistItem.empty(),
);
return AdaptiveScaffold(
title: 'Artists',
actions: [
@@ -71,7 +130,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
if (items.isEmpty) {
return const EmptyView(
title: 'No artist providers',
message: 'Enable Kemono or Coomer in Providers.',
message: 'Enable an artist provider (such as Pawchive) in Providers.',
);
}
_providerId ??= items.first.id;
@@ -91,6 +150,32 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
},
onSearch: () => _refresh(items),
),
_FavoriteArtistsSection(
favorites: favoriteItems,
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
onSelect: (fav) =>
setState(() => _selectedFavoriteArtistKey = fav.key),
onOpenArtist: (fav) => context.push(
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
),
onRemoveFavorite: (fav) async {
final updated = List<String>.from(settings.favoriteArtists)
..removeWhere((e) {
try {
return _FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>)
.key ==
fav.key;
} catch (_) {
return false;
}
});
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(
settings.copyWith(favoriteArtists: updated));
},
),
Expanded(
child: _error != null
? ErrorView(
@@ -129,8 +214,13 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
return const _ArtistSkeletonCard();
}
final artist = _artists[index];
final isFav = favoriteKeys.contains(
'${artist.providerId}:${artist.service}:${artist.id}');
return _ArtistCard(
artist: artist,
isFavorite: isFav,
onToggleFavorite: () =>
_toggleFavoriteArtist(settings, artist),
onTap: () => context.push(
'/artists/${artist.providerId}/${artist.service}/${artist.id}?name=${Uri.encodeComponent(artist.displayName)}',
),
@@ -276,10 +366,17 @@ class _ArtistsHeader extends StatelessWidget {
}
class _ArtistCard extends StatelessWidget {
const _ArtistCard({required this.artist, required this.onTap});
const _ArtistCard({
required this.artist,
required this.onTap,
required this.isFavorite,
required this.onToggleFavorite,
});
final ArtistProfile artist;
final VoidCallback onTap;
final bool isFavorite;
final VoidCallback onToggleFavorite;
@override
Widget build(BuildContext context) {
@@ -334,7 +431,15 @@ class _ArtistCard extends StatelessWidget {
],
),
),
const SizedBox(width: 8),
const SizedBox(width: 4),
IconButton(
tooltip: isFavorite ? 'В избранном' : 'В избранное',
onPressed: onToggleFavorite,
icon: Icon(
isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
color: isFavorite ? Colors.amber : scheme.onSurfaceVariant,
),
),
const Icon(Icons.chevron_right_rounded),
],
),
@@ -344,6 +449,359 @@ class _ArtistCard extends StatelessWidget {
}
}
class _FavoriteArtistItem {
const _FavoriteArtistItem({
required this.id,
required this.service,
required this.providerId,
required this.name,
this.avatarUrl,
});
const _FavoriteArtistItem.empty()
: id = '',
service = '',
providerId = '',
name = '',
avatarUrl = null;
final String id;
final String service;
final String providerId;
final String name;
final String? avatarUrl;
bool get isEmpty => id.isEmpty;
String get key => '$providerId:$service:$id';
Map<String, dynamic> toJson() => {
'id': id,
'service': service,
'providerId': providerId,
'name': name,
'avatarUrl': avatarUrl,
};
factory _FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
_FavoriteArtistItem(
id: (json['id'] ?? '').toString(),
service: (json['service'] ?? '').toString(),
providerId: (json['providerId'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
avatarUrl: json['avatarUrl'] as String?,
);
}
class _FavoriteArtistsSection extends ConsumerWidget {
const _FavoriteArtistsSection({
required this.favorites,
required this.selectedKey,
required this.onSelect,
required this.onOpenArtist,
required this.onRemoveFavorite,
});
final List<_FavoriteArtistItem> favorites;
final String? selectedKey;
final ValueChanged<_FavoriteArtistItem> onSelect;
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (favorites.isEmpty) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.3),
),
),
child: Row(
children: [
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
const SizedBox(width: 10),
Expanded(
child: Text(
'Нажмите звёздочку ★ на карточке автора, чтобы добавить его в любимые и смотреть его медиа здесь.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
final selected = favorites.firstWhere(
(f) => f.key == selectedKey,
orElse: () => favorites.first,
);
return Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Row(
children: [
const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
const SizedBox(width: 6),
Text(
'Любимые авторы (${favorites.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () => onOpenArtist(selected),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Все работы', style: TextStyle(fontSize: 12)),
SizedBox(width: 2),
Icon(Icons.arrow_forward_rounded, size: 14),
],
),
),
],
),
),
SizedBox(
height: 84,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
scrollDirection: Axis.horizontal,
itemCount: favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final fav = favorites[index];
final isSelected = fav.key == selected.key;
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onSelect(fav),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 1.5,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 44,
height: 44,
child: ClipOval(
child: fav.avatarUrl != null &&
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => const CircleAvatar(
child:
Icon(Icons.person_rounded, size: 20),
),
errorWidget: (_, __, ___) =>
const CircleAvatar(
child:
Icon(Icons.person_rounded, size: 20),
),
)
: CircleAvatar(
child: Text(
fav.name.isNotEmpty
? fav.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
width: 68,
child: Text(
fav.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
),
);
},
),
),
_FavoriteArtistMediaStrip(artist: selected),
],
),
);
}
}
class _FavoriteArtistMediaStrip extends ConsumerWidget {
const _FavoriteArtistMediaStrip({required this.artist});
final _FavoriteArtistItem artist;
@override
Widget build(BuildContext context, WidgetRef ref) {
final query = ArtistWorkQuery(
providerId: artist.providerId,
service: artist.service,
artistId: artist.id,
artistName: artist.name,
);
final asyncPosts = ref.watch(artistPostsProvider(query));
return Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
child: asyncPosts.when(
loading: () => const SizedBox(
height: 90,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (_, __) => const SizedBox.shrink(),
data: (posts) {
if (posts.isEmpty) {
return const SizedBox(
height: 36,
child: Center(
child: Text('Нет доступных фото или видео',
style: TextStyle(fontSize: 12)),
),
);
}
return SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: posts.length.clamp(0, 15),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = posts[index];
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: posts,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
SizedBox(
width: 96,
height: 96,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(Icons.broken_image_rounded,
size: 24),
),
),
),
if (isVideo)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 14),
),
),
],
),
),
);
},
),
);
},
),
);
}
}
class _ArtistAvatar extends StatelessWidget {
const _ArtistAvatar({required this.artist});
@@ -51,7 +51,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
favoriteKeys: favoriteKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
extra: PostNavigationContext(
currentPost: post,
posts: items,
),
),
onFavorite: (post) async {
if (favoriteKeys.contains(post.cacheKey)) {
@@ -11,11 +11,19 @@ import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import 'favorites_controller.dart';
class FavoritesScreen extends ConsumerWidget {
class FavoritesScreen extends ConsumerStatefulWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<FavoritesScreen> createState() => _FavoritesScreenState();
}
class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
int _tabIndex = 0;
String? _selectedArtist;
@override
Widget build(BuildContext context) {
final state = ref.watch(favoritesControllerProvider);
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
@@ -33,28 +41,156 @@ class FavoritesScreen extends ConsumerWidget {
error: (error, _) => ErrorView(message: error.toString()),
data: (data) => data.posts.isEmpty
? const EmptyView(title: 'No favorites yet')
: PostMasonryGrid(
posts: data.posts,
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: data.posts.map((post) => post.cacheKey).toSet(),
downloadedKeys: downloaded.keys.toSet(),
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
),
onFavorite: (post) =>
ref.read(favoritesControllerProvider.notifier).remove(post),
: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
child: SegmentedButton<int>(
segments: const [
ButtonSegment(
value: 0,
icon: Icon(Icons.favorite_rounded),
label: Text('All'),
),
ButtonSegment(
value: 1,
icon: Icon(Icons.offline_pin_rounded),
label: Text('Offline'),
),
ButtonSegment(
value: 2,
icon: Icon(Icons.groups_rounded),
label: Text('Artists'),
),
],
selected: {_tabIndex},
onSelectionChanged: (value) {
setState(() {
_tabIndex = value.first;
_selectedArtist = null;
});
},
),
),
Expanded(
child: switch (_tabIndex) {
1 => _postsGrid(
data.posts
.where((post) =>
downloaded.containsKey(post.cacheKey))
.toList(),
settings,
downloaded.keys.toSet(),
),
2 => _artistAlbums(
data.posts,
settings,
downloaded.keys.toSet(),
),
_ => _postsGrid(
data.posts,
settings,
downloaded.keys.toSet(),
),
},
),
],
),
),
);
}
Widget _postsGrid(
List<Post> posts,
AppSettings settings,
Set<String> downloadedKeys,
) {
if (posts.isEmpty) return const EmptyView(title: 'Nothing here yet');
return PostMasonryGrid(
posts: posts,
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: posts.map((post) => post.cacheKey).toSet(),
downloadedKeys: downloadedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
currentPost: post,
posts: posts,
),
),
onFavorite: (post) =>
ref.read(favoritesControllerProvider.notifier).remove(post),
);
}
Widget _artistAlbums(
List<Post> posts,
AppSettings settings,
Set<String> downloadedKeys,
) {
final groups = favoriteArtistAlbums(posts);
final selected = _selectedArtist;
if (selected != null) {
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setState(() => _selectedArtist = null),
icon: const Icon(Icons.arrow_back_rounded),
label: Text(selected),
),
),
Expanded(
child: _postsGrid(
groups[selected] ?? const [],
settings,
downloadedKeys,
),
),
],
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
children: [
for (final entry in groups.entries)
Card(
child: ListTile(
leading: const Icon(Icons.person_rounded),
title: Text(entry.key),
subtitle: Text('${entry.value.length} posts'),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => setState(() => _selectedArtist = entry.key),
),
),
],
);
}
}
Map<String, List<Post>> favoriteArtistAlbums(List<Post> posts) {
final groups = <String, List<Post>>{};
for (final post in posts) {
final artists = post.tagGroups['artist'] ??
post.tags.where((tag) => tag.startsWith('artist:')).toList();
final names = artists.isEmpty
? const ['Unknown artist']
: artists.map((tag) => tag.replaceFirst('artist:', '')).toList();
for (final name in names) {
groups.putIfAbsent(name, () => []).add(post);
}
}
return Map.fromEntries(
groups.entries.toList()
..sort((a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase())),
);
}
@@ -12,11 +12,22 @@ final feedControllerProvider =
class FeedController extends AsyncNotifier<FeedState> {
Timer? _providerDebounce;
Timer? _suggestionDebounce;
StreamSubscription<ProviderSoftRetryResult>? _softRetrySubscription;
int _loadRequestId = 0;
int _suggestionRequestId = 0;
@override
Future<FeedState> build() async {
ref.onDispose(() => _providerDebounce?.cancel());
ref.onDispose(() {
_providerDebounce?.cancel();
_suggestionDebounce?.cancel();
_softRetrySubscription?.cancel();
});
_softRetrySubscription ??= ref
.read(providerManagerProvider)
.softRetryResults
.listen(_applySoftRetryResult);
final providers = await _loadProviders();
final settings = await _settings();
final providerIds = providers.map((provider) => provider.id).toSet();
@@ -42,7 +53,13 @@ class FeedController extends AsyncNotifier<FeedState> {
state = const AsyncLoading();
final posts = await _load(refresh: true, current: current);
if (requestId != _loadRequestId) return;
state = AsyncData(current.copyWith(posts: posts, clearError: true));
state = AsyncData(current.copyWith(
posts: posts,
hasMore: true,
emptyPageStreak: 0,
clearError: true,
providerStatusMessage: await _providerStatusMessage(current),
));
}
Future<void> refresh() async {
@@ -51,7 +68,13 @@ class FeedController extends AsyncNotifier<FeedState> {
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
final posts = await _load(refresh: true, current: current);
if (requestId != _loadRequestId) return;
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
state = AsyncData(current.copyWith(
posts: posts,
isLoadingMore: false,
hasMore: true,
emptyPageStreak: 0,
providerStatusMessage: await _providerStatusMessage(current),
));
}
Future<void> loadNextPage() async {
@@ -59,17 +82,27 @@ class FeedController extends AsyncNotifier<FeedState> {
if (current == null || current.isLoadingMore || !current.hasMore) return;
final requestId = ++_loadRequestId;
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
final posts = await _load(refresh: false, current: current);
final posts = <Post>[];
const maxEmptyPageSkips = 3;
var attempts = 0;
while (posts.isEmpty && attempts < maxEmptyPageSkips) {
attempts++;
posts.addAll(await _load(refresh: false, current: current));
if (requestId != _loadRequestId) return;
}
if (requestId != _loadRequestId) return;
final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
final newPosts = posts
.where((post) => existingKeys.add(post.cacheKey))
.toList(growable: false);
final emptyPageStreak =
newPosts.isEmpty ? current.emptyPageStreak + attempts : 0;
state = AsyncData(
current.copyWith(
posts: [...current.posts, ...newPosts],
isLoadingMore: false,
hasMore: posts.isNotEmpty,
hasMore: newPosts.isNotEmpty || emptyPageStreak < maxEmptyPageSkips,
emptyPageStreak: emptyPageStreak,
),
);
}
@@ -78,7 +111,13 @@ class FeedController extends AsyncNotifier<FeedState> {
final tags = ref.read(searchServiceProvider).parseTags(query);
final current = state.value ?? const FeedState();
state = AsyncData(
current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []),
current.copyWith(
selectedTags: tags,
posts: [],
tagSuggestions: [],
hasMore: true,
emptyPageStreak: 0,
),
);
await saveSession(scrollOffset: 0);
await ref.read(searchServiceProvider).saveSearch(query, 0);
@@ -89,31 +128,42 @@ class FeedController extends AsyncNotifier<FeedState> {
Future<void> updateTagSuggestions(String query) async {
final current = state.value ?? const FeedState();
if (query.trim().isEmpty) {
final lastToken =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel();
if (lastToken.trim().isEmpty) {
state = AsyncData(current.copyWith(tagSuggestions: []));
return;
}
final lastToken = query.trim().split(RegExp(r'\s+')).last;
final result = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
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>[];
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
final result = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
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>[];
final latest = state.value ?? current;
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
});
}
Future<void> setProviders(List<String> providerIds) async {
final current = state.value ?? const FeedState();
final enabledIds = current.providers.map((provider) => provider.id).toSet();
final selected = providerIds.where(enabledIds.contains).toList();
state =
AsyncData(current.copyWith(selectedProviderIds: selected, posts: []));
state = AsyncData(current.copyWith(
selectedProviderIds: selected,
posts: [],
hasMore: true,
emptyPageStreak: 0,
));
final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(
@@ -129,7 +179,12 @@ class FeedController extends AsyncNotifier<FeedState> {
Future<void> setTopPeriod(TopPeriodFilter period) async {
final current = state.value ?? const FeedState();
state = AsyncData(current.copyWith(topPeriodFilter: period, posts: []));
state = AsyncData(current.copyWith(
topPeriodFilter: period,
posts: [],
hasMore: true,
emptyPageStreak: 0,
));
final settings = await _settings();
await ref.read(settingsServiceProvider).updateSettings(
settings.copyWith(defaultTopPeriodFilter: period.name),
@@ -142,7 +197,12 @@ class FeedController extends AsyncNotifier<FeedState> {
final current = state.value ?? const FeedState();
state = AsyncData(
current.copyWith(
ratingFilter: rating, clearRating: rating == null, posts: []),
ratingFilter: rating,
clearRating: rating == null,
posts: [],
hasMore: true,
emptyPageStreak: 0,
),
);
await refresh();
await saveSession(scrollOffset: 0);
@@ -157,6 +217,8 @@ class FeedController extends AsyncNotifier<FeedState> {
tagSuggestions: [],
clearRating: true,
posts: [],
hasMore: true,
emptyPageStreak: 0,
),
);
final settings = await _settings();
@@ -248,6 +310,56 @@ class FeedController extends AsyncNotifier<FeedState> {
return posts;
}
void _applySoftRetryResult(ProviderSoftRetryResult result) {
final current = state.value;
if (current == null || result.page != 0) return;
if (!_sameTags(current.selectedTags, result.tags)) return;
if (current.ratingFilter != result.rating) return;
if (current.topPeriodFilter != result.topPeriod) return;
if (current.selectedProviderIds.isNotEmpty &&
!current.selectedProviderIds.contains(result.providerId)) {
return;
}
final seen = current.posts.map((post) => post.cacheKey).toSet();
final newPosts = result.posts
.where((post) => seen.add(post.cacheKey))
.toList(growable: false);
if (newPosts.isEmpty) return;
state = AsyncData(
current.copyWith(
posts: [...current.posts, ...newPosts]..sort(
(a, b) => _mixKey(a).compareTo(_mixKey(b)),
),
providerStatusMessage: 'Retry added ${newPosts.length} posts',
),
);
}
bool _sameTags(List<String> left, List<String> right) {
if (left.length != right.length) return false;
for (var i = 0; i < left.length; i++) {
if (left[i] != right[i]) return false;
}
return true;
}
Future<String?> _providerStatusMessage(FeedState current) async {
final result = await ref.read(providerRepositoryProvider).getDiagnostics();
if (result is! Success<List<ProviderDiagnostics>>) return null;
final selected = current.selectedProviderIds.isEmpty
? current.providers.map((provider) => provider.id).toSet()
: current.selectedProviderIds.toSet();
final failed = result.data
.where((item) =>
selected.contains(item.providerId) &&
item.lastErrorMessage != null &&
item.lastErrorMessage!.isNotEmpty)
.map((item) => item.providerId)
.toSet();
if (failed.isEmpty) return null;
return 'Retrying ${failed.length} provider${failed.length == 1 ? '' : 's'}';
}
int _mixKey(Post post) {
var hash = 0x811c9dc5;
for (final code in post.cacheKey.codeUnits) {
@@ -1,3 +1,4 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -28,20 +29,30 @@ class FeedScreen extends ConsumerStatefulWidget {
ConsumerState<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends ConsumerState<FeedScreen> {
class _FeedScreenState extends ConsumerState<FeedScreen>
with WidgetsBindingObserver {
final _scrollController = ScrollController();
final Set<String> _selectedKeys = {};
bool _selectionMode = false;
bool _showScrollToTop = false;
String? _appliedInitialQuery;
Timer? _scrollSaveDebounce;
double _lastKnownScrollOffset = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollController.addListener(() {
if (_scrollController.position.extentAfter < 800) {
ref.read(feedControllerProvider.notifier).loadNextPage();
}
_lastKnownScrollOffset = _scrollController.offset;
final shouldShow =
_scrollController.hasClients && _scrollController.offset > 1200;
if (shouldShow != _showScrollToTop) {
setState(() => _showScrollToTop = shouldShow);
}
_scrollSaveDebounce?.cancel();
_scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () {
ref
@@ -53,6 +64,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) {
_lastKnownScrollOffset = settings.lastFeedScrollOffset;
_scrollController.jumpTo(
settings.lastFeedScrollOffset.clamp(
0.0,
@@ -80,11 +92,32 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollSaveDebounce?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
void didChangeMetrics() {
if (_scrollController.hasClients) {
_lastKnownScrollOffset = _scrollController.offset;
ref
.read(feedControllerProvider.notifier)
.saveSession(scrollOffset: _lastKnownScrollOffset);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return;
final target = _lastKnownScrollOffset.clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
if ((_scrollController.offset - target).abs() > 24) {
_scrollController.jumpTo(target);
}
});
}
@override
Widget build(BuildContext context) {
final feed = ref.watch(feedControllerProvider);
@@ -146,6 +179,19 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
child: AdaptiveScaffold(
title: 'Feed',
titleWidget: const _FeedTitle(),
floatingActionButton: _showScrollToTop
? FloatingActionButton.small(
tooltip: 'Наверх',
onPressed: () {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 450),
curve: Curves.easeOutCubic,
);
},
child: const Icon(Icons.arrow_upward_rounded),
)
: null,
body: feed.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(
@@ -162,6 +208,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
tagSuggestions: state.tagSuggestions,
providers: state.providers,
rating: state.ratingFilter,
providerStatusMessage: state.providerStatusMessage,
onSearchChanged: (query) => ref
.read(feedControllerProvider.notifier)
.updateTagSuggestions(query),
@@ -173,6 +220,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
onRefresh: () =>
ref.read(feedControllerProvider.notifier).refresh(),
onClearFilters: _clearFilters,
onRandom: () => _openRandom(state.posts),
selectionMode: _selectionMode,
onToggleSelectionMode: () {
setState(() {
@@ -228,6 +276,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
.read(feedControllerProvider.notifier)
.refresh(),
child: PostMasonryGrid(
key: const PageStorageKey('feed_masonry_grid'),
controller: _scrollController,
posts: state.posts,
columns: Responsive.columnsFor(
@@ -247,7 +296,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
selectedKeys: _selectedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
extra: PostNavigationContext(
currentPost: post,
posts: state.posts,
),
),
onPreview: (post) => _showPreview(context, post),
onToggleSelected: (post) => _toggleSelected(post),
@@ -296,6 +348,12 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
context.go(location);
}
void _openRandom(List<Post> posts) {
if (posts.isEmpty) return;
final post = posts[Random().nextInt(posts.length)];
context.push('/post/${post.providerId}/${post.id}', extra: post);
}
Future<void> _applySearchQuery(String query) async {
await ref.read(feedControllerProvider.notifier).search(query);
if (_scrollController.hasClients) {
@@ -11,7 +11,9 @@ class FeedState {
this.providers = const [],
this.isLoadingMore = false,
this.hasMore = true,
this.emptyPageStreak = 0,
this.error,
this.providerStatusMessage,
});
final List<Post> posts;
@@ -23,7 +25,9 @@ class FeedState {
final List<ContentProviderConfig> providers;
final bool isLoadingMore;
final bool hasMore;
final int emptyPageStreak;
final String? error;
final String? providerStatusMessage;
FeedState copyWith({
List<Post>? posts,
@@ -36,8 +40,11 @@ class FeedState {
List<ContentProviderConfig>? providers,
bool? isLoadingMore,
bool? hasMore,
int? emptyPageStreak,
String? error,
bool clearError = false,
String? providerStatusMessage,
bool clearProviderStatus = false,
}) {
return FeedState(
posts: posts ?? this.posts,
@@ -49,7 +56,11 @@ class FeedState {
providers: providers ?? this.providers,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore,
emptyPageStreak: emptyPageStreak ?? this.emptyPageStreak,
error: clearError ? null : error ?? this.error,
providerStatusMessage: clearProviderStatus
? null
: providerStatusMessage ?? this.providerStatusMessage,
);
}
}
@@ -23,6 +23,8 @@ class FeedToolbar extends StatefulWidget {
required this.onSuggestionTap,
required this.onTopPeriodChanged,
this.onToggleSelectionMode,
this.onRandom,
this.providerStatusMessage,
this.selectionMode = false,
super.key,
});
@@ -43,6 +45,8 @@ class FeedToolbar extends StatefulWidget {
final ValueChanged<String> onSuggestionTap;
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
final VoidCallback? onToggleSelectionMode;
final VoidCallback? onRandom;
final String? providerStatusMessage;
final bool selectionMode;
@override
@@ -122,9 +126,24 @@ class _FeedToolbarState extends State<FeedToolbar> {
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(height: 8),
if (widget.providerStatusMessage != null) ...[
Align(
alignment: Alignment.centerLeft,
child: InputChip(
avatar: const Icon(Icons.sync_problem_rounded, size: 16),
label: Text(widget.providerStatusMessage!),
),
),
const SizedBox(height: 8),
],
_HorizontalWheelScroller(
height: 36,
builder: (controller) => ListView(
@@ -187,6 +206,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
},
),
const SizedBox(height: 8),
if (widget.providerStatusMessage != null) ...[
Align(
alignment: Alignment.centerLeft,
child: InputChip(
avatar: const Icon(Icons.sync_problem_rounded, size: 16),
label: Text(widget.providerStatusMessage!),
),
),
const SizedBox(height: 6),
],
_HorizontalWheelScroller(
height: 40,
builder: (controller) => ListView(
@@ -219,6 +248,11 @@ class _FeedToolbarState extends State<FeedToolbar> {
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: 8),
if (widget.providers.isNotEmpty) ...[
FilterChip(
@@ -3,17 +3,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
// Меняем FutureProvider на StreamProvider
// Stream the fastest available post version first, then enrich it.
final postDetailsControllerProvider =
StreamProvider.family<Post?, PostDetailsArgs>((ref, args) async* {
// Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн)
final providerManager = ref.watch(providerManagerProvider);
final cacheService = ref.watch(cacheServiceProvider);
final postRepository = ref.watch(postRepositoryProvider);
// Вспомогательная функция для загрузки тегов
bool needsRealbooruDetails(Post post) {
if (post.providerId != 'realbooru') return false;
final preview = post.previewUrl.toLowerCase();
final sample = post.sampleUrl.toLowerCase();
return post.fileUrl.isEmpty ||
post.fileUrl == post.previewUrl ||
post.fileUrl == post.sampleUrl ||
preview.contains('/thumbnails/') ||
sample.contains('/thumbnails/');
}
Future<Post?> enrich(Post? post) async {
if (post == null) return null;
if (needsRealbooruDetails(post)) {
final remote = await providerManager.getPost(post.providerId, post.id);
if (remote is Success<Post?> && remote.data != null) {
await cacheService.cachePosts([remote.data!]);
return remote.data!;
}
}
final shouldEnrich = post.tagGroups.isEmpty ||
(post.tagGroups.length == 1 && post.tagGroups.containsKey('general'));
if (!shouldEnrich) return post;
@@ -26,26 +42,29 @@ final postDetailsControllerProvider =
return post;
}
// СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай)
// Scenario 1: feed navigation already passed the post.
if (args.initialPost != null) {
yield args
.initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится!
if (!needsRealbooruDetails(args.initialPost!)) {
yield args.initialPost;
}
final enriched = await enrich(args.initialPost);
yield enriched; // Чуть позже отдаем версию с загруженными тегами
yield enriched;
return;
}
// СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш
// Scenario 2: direct navigation can use the local cache first.
final cached =
await postRepository.getCachedPost(args.postId, args.providerId);
if (cached is Success<Post?> && cached.data != null) {
yield cached.data; // Нашли в кэше — сразу показываем контент
if (!needsRealbooruDetails(cached.data!)) {
yield cached.data;
}
final enriched = await enrich(cached.data);
yield enriched; // Догружаем теги на фоне
yield enriched;
return;
}
// СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом
// Scenario 3: fall back to the provider when nothing local is available.
final remote = await providerManager.getPost(args.providerId, args.postId);
if (remote is Success<Post?> && remote.data != null) {
yield remote.data;
@@ -1,3 +1,4 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -39,12 +40,14 @@ class PostDetailsScreen extends ConsumerWidget {
required this.providerId,
required this.postId,
this.initialPost,
this.postsList,
super.key,
});
final String providerId;
final String postId;
final Post? initialPost;
final List<Post>? postsList;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -57,8 +60,9 @@ class PostDetailsScreen extends ConsumerWidget {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final strings = ref.watch(appStringsProvider);
final feedPosts =
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
final feedPosts = postsList ??
ref.watch(feedControllerProvider).value?.posts ??
const <Post>[];
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
return AdaptiveScaffold(
title: strings.post,
@@ -94,7 +98,9 @@ class PostDetailsScreen extends ConsumerWidget {
return _MobilePostPager(
posts: feedPosts,
initialIndex: currentIndex,
buildDetails: (context, post) => _buildMobileDetails(
buildDetails: (context, post, mediaGestureLocked,
onMediaGestureLockChanged) =>
_buildMobileDetails(
context,
ref,
post,
@@ -102,6 +108,8 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys,
qualityMode,
strings,
mediaGestureLocked,
onMediaGestureLockChanged,
),
);
}
@@ -113,14 +121,20 @@ class PostDetailsScreen extends ConsumerWidget {
favoriteKeys,
qualityMode,
strings,
false,
null,
);
}
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.arrowLeft):
const _PreviousPostIntent(),
LogicalKeySet(LogicalKeyboardKey.keyA):
const _PreviousPostIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowRight):
const _NextPostIntent(),
LogicalKeySet(LogicalKeyboardKey.keyD):
const _NextPostIntent(),
LogicalKeySet(LogicalKeyboardKey.keyF):
const _ToggleFavoriteIntent(),
LogicalKeySet(LogicalKeyboardKey.keyC):
@@ -186,17 +200,29 @@ class PostDetailsScreen extends ConsumerWidget {
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760),
child: SizedBox(
width: double.infinity,
height: 760,
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
mediaHeaders: ref
.watch(postMediaHeadersProvider(post))
.value ??
const {},
initialPosition: Duration(
milliseconds: settings.videoPlaybackPositions[
post.cacheKey] ??
0,
),
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume:
settings.videoPlayerHalfVolume,
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
),
@@ -233,6 +259,7 @@ class PostDetailsScreen extends ConsumerWidget {
_toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onOpenSource: () => _openSourcePage(ref, post),
onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
@@ -284,11 +311,14 @@ class PostDetailsScreen extends ConsumerWidget {
Set<String> favoriteKeys,
MediaQualityMode qualityMode,
AppStrings strings,
bool mediaGestureLocked,
ValueChanged<bool>? onMediaGestureLockChanged,
) {
final isVideo = MediaUrlSelector.isVideo(post);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) {
if (mediaGestureLocked) return;
if ((details.primaryVelocity ?? 0) > 900) _close(context);
},
child: ListView(
@@ -302,22 +332,30 @@ class PostDetailsScreen extends ConsumerWidget {
? null
: () =>
_showMobileQuickActions(context, ref, post, favoriteKeys),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
),
child: Center(
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: settings.videoPlayerHalfVolume,
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
child: SizedBox(
width: double.infinity,
height: MediaQuery.sizeOf(context).height * 0.62,
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
mediaHeaders:
ref.watch(postMediaHeadersProvider(post)).value ?? const {},
initialPosition: Duration(
milliseconds:
settings.videoPlaybackPositions[post.cacheKey] ?? 0,
),
initialLoop: settings.videoPlayerLoop,
initialMuted: settings.videoPlayerMuted,
initialCoverVideo: settings.videoPlayerCover,
initialHalfVolume: settings.videoPlayerHalfVolume,
onPlaybackSnapshot: (snapshot) =>
_saveVideoSnapshot(ref, post, snapshot),
onPlaybackPreferencesChanged: (snapshot) =>
_saveVideoPreferences(ref, snapshot),
onMediaGestureLockChanged: (locked) {
onMediaGestureLockChanged?.call(locked);
},
),
),
),
@@ -331,6 +369,7 @@ class PostDetailsScreen extends ConsumerWidget {
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onOpenSource: () => _openSourcePage(ref, post),
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
onHide: () => _hidePost(context, ref, post),
@@ -545,6 +584,18 @@ class PostDetailsScreen extends ConsumerWidget {
context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
}
Future<void> _openSourcePage(WidgetRef ref, Post post) async {
final source = post.source?.trim();
String? url = source != null && source.isNotEmpty ? source : null;
if (url == null) {
final result =
await ref.read(providerManagerProvider).getPostPageUrl(post);
if (result is Success<String?>) url = result.data;
}
if (url == null || url.isEmpty) return;
await launchUrl(Uri.parse(url));
}
void _replacePost(BuildContext context, Post post) {
context.replace('/post/${post.providerId}/${post.id}', extra: post);
}
@@ -580,6 +631,18 @@ class PostDetailsScreen extends ConsumerWidget {
);
ref.invalidate(appSettingsProvider);
}
Future<void> _saveVideoSnapshot(
WidgetRef ref,
Post post,
VideoPlaybackSnapshot snapshot,
) async {
await ref.read(settingsServiceProvider).saveVideoPlaybackPosition(
post.cacheKey,
snapshot.position.inMilliseconds,
);
ref.invalidate(appSettingsProvider);
}
}
class _MobilePostPager extends StatefulWidget {
@@ -591,19 +654,25 @@ class _MobilePostPager extends StatefulWidget {
final List<Post> posts;
final int initialIndex;
final Widget Function(BuildContext context, Post post) buildDetails;
final Widget Function(
BuildContext context,
Post post,
bool mediaGestureLocked,
ValueChanged<bool> onMediaGestureLockChanged,
) buildDetails;
@override
State<_MobilePostPager> createState() => _MobilePostPagerState();
}
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: widget.initialIndex);
class _MobilePostPagerState extends State<_MobilePostPager> {
late final PageController _controller;
bool _mediaGestureLocked = false;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
});
@@ -613,11 +682,11 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
void didUpdateWidget(covariant _MobilePostPager oldWidget) {
super.didUpdateWidget(oldWidget);
// Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
if (oldWidget.initialIndex != widget.initialIndex &&
widget.initialIndex >= 0 &&
widget.initialIndex < widget.posts.length) {
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
@@ -633,21 +702,59 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
@override
Widget build(BuildContext context) {
return PageView.builder(
controller: _controller,
itemCount: widget.posts.length,
onPageChanged: (index) {
_prefetchAround(index);
},
itemBuilder: (context, index) {
return _KeepAlivePostPage(
child: KeyedSubtree(
key: ValueKey(widget.posts[index].cacheKey),
child: widget.buildDetails(context, widget.posts[index]),
),
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
},
),
child: PageView.builder(
controller: _controller,
physics: _mediaGestureLocked
? const NeverScrollableScrollPhysics()
: const PageScrollPhysics(),
itemCount: widget.posts.length,
onPageChanged: (index) {
_prefetchAround(index);
},
itemBuilder: (context, index) {
final initialPost = widget.posts[index];
return Consumer(
builder: (context, ref, _) {
final args = PostDetailsArgs(
providerId: initialPost.providerId,
postId: initialPost.id,
initialPost: initialPost,
);
final post = ref.watch(postDetailsControllerProvider(args));
return post.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (resolvedPost) => _KeepAlivePostPage(
child: KeyedSubtree(
key: ValueKey((resolvedPost ?? initialPost).cacheKey),
child: widget.buildDetails(
context,
resolvedPost ?? initialPost,
_mediaGestureLocked,
_setMediaGestureLocked,
),
),
),
);
},
);
},
);
),
);
}
void _setMediaGestureLocked(bool locked) {
if (_mediaGestureLocked == locked) return;
setState(() => _mediaGestureLocked = locked);
}
void _prefetchAround(int index) {
@@ -670,19 +777,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
}
Map<String, String> _headersFor(Post post) {
return {
return const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (post.providerName.toLowerCase().contains('gelbooru') ||
post.fileUrl.contains('gelbooru.com') ||
post.sampleUrl.contains('gelbooru.com') ||
post.previewUrl.contains('gelbooru.com'))
'Referer': 'https://gelbooru.com/',
if (post.providerName.toLowerCase().contains('rule34') ||
post.fileUrl.contains('rule34') ||
post.sampleUrl.contains('rule34') ||
post.previewUrl.contains('rule34'))
'Referer': 'https://rule34.xxx/',
};
}
}
@@ -7,6 +7,7 @@ class PostActionBar extends StatelessWidget {
required this.onOpen,
required this.onCopy,
required this.isFavorite,
this.onOpenSource,
this.onDownload,
this.onDeleteLocalFile,
this.downloaded = false,
@@ -20,6 +21,7 @@ class PostActionBar extends StatelessWidget {
final VoidCallback onCollection;
final VoidCallback onOpen;
final VoidCallback onCopy;
final VoidCallback? onOpenSource;
final bool isFavorite;
final VoidCallback? onDownload;
final VoidCallback? onDeleteLocalFile;
@@ -58,6 +60,12 @@ class PostActionBar extends StatelessWidget {
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,
@@ -95,6 +103,7 @@ class PostActionLabels {
this.collection = 'Collection',
this.similar = 'Similar',
this.openOriginal = 'Open original',
this.openSource = 'Open source/page',
this.copyLink = 'Copy link',
this.download = 'Download',
this.deleteLocalFile = 'Delete local file',
@@ -106,6 +115,7 @@ class PostActionLabels {
final String collection;
final String similar;
final String openOriginal;
final String openSource;
final String copyLink;
final String download;
final String deleteLocalFile;
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../app/app.dart';
import '../../../../backend/backend.dart';
import '../../../../shared/widgets/tag_chip.dart';
import '../../../feed/presentation/feed_controller.dart';
import '../../../settings/presentation/settings_controller.dart';
class PostTagsPanel extends StatefulWidget {
class PostTagsPanel extends ConsumerStatefulWidget {
const PostTagsPanel({required this.post, super.key});
final Post post;
@override
State<PostTagsPanel> createState() => _PostTagsPanelState();
ConsumerState<PostTagsPanel> createState() => _PostTagsPanelState();
}
class _PostTagsPanelState extends State<PostTagsPanel> {
class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
final _expandedGroups = <String>{};
@override
@@ -26,6 +31,7 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
for (final entry in groups.entries)
_TagGroupBlock(
label: _label(entry.key),
group: entry.key,
tags: entry.value,
expanded: _expandedGroups.contains(entry.key),
onToggleExpanded: () {
@@ -36,11 +42,183 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
});
},
onTap: (tag) => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
onLongPress: (tag) =>
_showTagActionSheet(context, tag: tag, group: entry.key),
),
],
);
}
void _showTagActionSheet(
BuildContext context, {
required String tag,
required String group,
}) {
final currentTags =
ref.read(feedControllerProvider).value?.selectedTags ?? const [];
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final isBlacklisted = settings.blacklistedTags.contains(tag);
final isArtist = group == 'artist';
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (modalContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_label(group),
style:
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
tag,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.search_rounded),
title: const Text('Искать только этот тег'),
onTap: () {
Navigator.pop(modalContext);
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
},
),
ListTile(
leading: const Icon(Icons.add_circle_outline_rounded),
title: const Text('Добавить к поиску (+тег)'),
subtitle: currentTags.isNotEmpty
? Text('Текущие: ${currentTags.join(', ')}')
: null,
onTap: () {
Navigator.pop(modalContext);
final nextQuery = currentTags.contains(tag)
? currentTags.join(' ')
: [...currentTags, tag].join(' ');
context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}');
},
),
ListTile(
leading: const Icon(Icons.remove_circle_outline_rounded),
title: const Text('Исключить из поиска (-тег)'),
onTap: () {
Navigator.pop(modalContext);
final filtered = currentTags
.where((t) => t != tag && t != '-$tag')
.toList();
final nextQuery = [...filtered, '-$tag'].join(' ');
context.go('/?q=${Uri.encodeQueryComponent(nextQuery)}');
},
),
ListTile(
leading: Icon(
isBlacklisted
? Icons.block_flipped
: Icons.block_rounded,
color: isBlacklisted
? null
: Theme.of(context).colorScheme.error,
),
title: Text(
isBlacklisted
? 'Удалить из чёрного списка'
: 'В чёрный список (Blacklist)',
),
onTap: () async {
Navigator.pop(modalContext);
final updatedList = isBlacklisted
? settings.blacklistedTags
.where((t) => t != tag)
.toList()
: [...settings.blacklistedTags, tag];
final nextSettings =
settings.copyWith(blacklistedTags: updatedList);
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(nextSettings);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isBlacklisted
? 'Тег "$tag" удалён из чёрного списка'
: 'Тег "$tag" добавлен в чёрный список',
),
behavior: SnackBarBehavior.floating,
),
);
}
},
),
ListTile(
leading: const Icon(Icons.copy_rounded),
title: const Text('Скопировать тег'),
onTap: () {
Navigator.pop(modalContext);
Clipboard.setData(ClipboardData(text: tag));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Тег "$tag" скопирован в буфер'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
),
if (isArtist)
ListTile(
leading: const Icon(Icons.palette_rounded),
title: const Text('Все работы этого автора'),
onTap: () {
Navigator.pop(modalContext);
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
},
),
],
),
),
),
);
},
);
}
Map<String, List<String>> _groups(Post post) {
if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{};
@@ -81,19 +259,23 @@ class _PostTagsPanelState extends State<PostTagsPanel> {
class _TagGroupBlock extends StatelessWidget {
const _TagGroupBlock({
required this.label,
required this.group,
required this.tags,
required this.expanded,
required this.onToggleExpanded,
required this.onTap,
required this.onLongPress,
});
static const _collapsedLimit = 36;
final String label;
final String group;
final List<String> tags;
final bool expanded;
final VoidCallback onToggleExpanded;
final ValueChanged<String> onTap;
final ValueChanged<String> onLongPress;
@override
Widget build(BuildContext context) {
@@ -126,6 +308,7 @@ class _TagGroupBlock extends StatelessWidget {
TagChip(
tag: tag,
onTap: () => onTap(tag),
onLongPress: () => onLongPress(tag),
),
if (tags.length > _collapsedLimit)
ActionChip(
@@ -89,7 +89,9 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
DropdownMenuItem(
value: 'rule34', child: Text('rule34-compatible')),
DropdownMenuItem(
value: 'realbooru', child: Text('realbooru-compatible')),
value: 'paheal', child: Text('Rule34 Paheal HTML')),
DropdownMenuItem(
value: 'realbooru_html', child: Text('realbooru HTML')),
DropdownMenuItem(
value: 'danbooru', child: Text('danbooru-compatible')),
DropdownMenuItem(
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
@@ -8,8 +10,12 @@ final searchControllerProvider =
AsyncNotifierProvider<SearchController, SearchState>(SearchController.new);
class SearchController extends AsyncNotifier<SearchState> {
Timer? _suggestionDebounce;
int _suggestionRequestId = 0;
@override
Future<SearchState> build() async {
ref.onDispose(() => _suggestionDebounce?.cancel());
final recent = await _recent();
return SearchState(recent: recent);
}
@@ -17,18 +23,42 @@ class SearchController extends AsyncNotifier<SearchState> {
Future<void> updateQuery(String query) async {
final lastToken =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data
: <TagSuggestion>[];
final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel();
if (lastToken.isEmpty) {
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: const [],
),
);
return;
}
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: suggestions,
),
);
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
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>[];
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: suggestions,
),
);
});
}
Future<void> clearHistory() async {
@@ -2,24 +2,45 @@ import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
class RecentSearches extends StatelessWidget {
class RecentSearches extends StatefulWidget {
const RecentSearches({required this.items, required this.onTap, super.key});
final List<SearchHistory> items;
final ValueChanged<String> onTap;
@override
State<RecentSearches> createState() => _RecentSearchesState();
}
class _RecentSearchesState extends State<RecentSearches> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
if (items.isEmpty) return const Text('No recent searches');
if (widget.items.isEmpty) return const Text('No recent searches');
final visibleItems =
_expanded ? widget.items : widget.items.take(30).toList();
return Column(
children: [
for (final item in items)
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: () => onTap(item.query),
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',
),
),
],
);
@@ -0,0 +1,130 @@
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 '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import 'settings_controller.dart';
final hiddenPostRowsProvider = FutureProvider<List<HiddenPostRow>>((ref) async {
final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final repository = ref.watch(postRepositoryProvider);
final rows = <HiddenPostRow>[];
for (final key in settings.hiddenPostKeys) {
final parts = key.split(':');
Post? post;
if (parts.length >= 2) {
final result = await repository.getCachedPost(
parts.sublist(1).join(':'), parts.first);
if (result is Success<Post?>) post = result.data;
}
rows.add(HiddenPostRow(cacheKey: key, post: post));
}
return rows;
});
class HiddenPostsScreen extends ConsumerWidget {
const HiddenPostsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rows = ref.watch(hiddenPostRowsProvider);
return AdaptiveScaffold(
title: 'Hidden posts',
actions: [
IconButton(
tooltip: 'Restore all',
onPressed: rows.valueOrNull?.isEmpty ?? true
? null
: () async {
await ref.read(settingsServiceProvider).clearHiddenPosts();
ref.invalidate(appSettingsProvider);
ref.invalidate(settingsControllerProvider);
ref.invalidate(hiddenPostRowsProvider);
},
icon: const Icon(Icons.restore_rounded),
),
],
body: rows.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text(error.toString())),
data: (items) => items.isEmpty
? const Center(child: Text('No hidden posts'))
: ListView.builder(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _HiddenPostTile(
item: item,
onOpen: item.post == null
? null
: () => context.push(
'/post/${item.post!.providerId}/${item.post!.id}',
extra: item.post,
),
onRestore: () async {
await ref
.read(settingsServiceProvider)
.unhidePostKey(item.cacheKey);
ref.invalidate(appSettingsProvider);
ref.invalidate(settingsControllerProvider);
ref.invalidate(hiddenPostRowsProvider);
},
);
},
),
),
);
}
}
class HiddenPostRow {
const HiddenPostRow({required this.cacheKey, this.post});
final String cacheKey;
final Post? post;
}
class _HiddenPostTile extends StatelessWidget {
const _HiddenPostTile({
required this.item,
required this.onRestore,
this.onOpen,
});
final HiddenPostRow item;
final VoidCallback onRestore;
final VoidCallback? onOpen;
@override
Widget build(BuildContext context) {
final post = item.post;
final imageUrl =
post == null ? null : MediaUrlSelector.preview(post).firstOrNull;
return Card(
child: ListTile(
onTap: onOpen,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 56,
height: 56,
child: imageUrl == null
? const Icon(Icons.visibility_off_rounded)
: CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.cover),
),
),
title: Text(post?.tags.take(4).join(' ') ?? item.cacheKey),
subtitle: Text(post?.providerName ?? item.cacheKey),
trailing: TextButton(
onPressed: onRestore,
child: const Text('Restore'),
),
),
);
}
}
@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/services.dart' hide appBuildNumber;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -101,8 +101,12 @@ class _SettingsContent extends ConsumerWidget {
),
SwitchListTile(
value: settings.autoDownloadFavorites,
title: Text(strings.autoDownloadFavorites),
subtitle: Text(strings.autoDownloadFavoritesHint),
title: Text(settings.languageCode == 'ru'
? 'Offline избранное'
: 'Offline favorites'),
subtitle: Text(settings.languageCode == 'ru'
? 'При добавлении фото или видео в избранное файл скачивается в фоне для просмотра офлайн.'
: 'When a photo or video is added to Favorites, Lunaris downloads it in the background for offline viewing.'),
onChanged: (value) => _update(
ref,
settings.copyWith(autoDownloadFavorites: value),
@@ -283,6 +287,13 @@ class _SettingsContent extends ConsumerWidget {
icon: const Icon(Icons.history_toggle_off_rounded),
label: Text(strings.clearViewed),
),
FilledButton.tonalIcon(
onPressed: settings.hiddenPostKeys.isEmpty
? null
: () => context.go('/settings/hidden'),
icon: const Icon(Icons.restore_rounded),
label: const Text('Restore hidden'),
),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
@@ -741,13 +752,19 @@ class _ColorSwatches extends StatelessWidget {
final int selected;
final ValueChanged<int> onChanged;
static const colors = <int>[
0xFFE84D8A,
0xFF8B5CF6,
0xFF3B82F6,
0xFF14B8A6,
0xFFF97316,
0xFFEF4444,
static const colors = <(int, String)>[
(0xFFE84D8A, 'Lunaris pink'),
(0xFF8B5CF6, 'Violet'),
(0xFF3B82F6, 'Azure'),
(0xFF0EA5E9, 'Sky'),
(0xFF14B8A6, 'Teal'),
(0xFF22C55E, 'Green'),
(0xFFEAB308, 'Amber'),
(0xFFF97316, 'Orange'),
(0xFFEF4444, 'Red'),
(0xFFEC4899, 'Hot pink'),
(0xFF6366F1, 'Indigo'),
(0xFF64748B, 'Slate'),
];
@override
@@ -761,28 +778,28 @@ class _ColorSwatches extends StatelessWidget {
spacing: 10,
runSpacing: 10,
children: [
for (final value in colors)
for (final entry in colors)
Tooltip(
message:
'#${value.toRadixString(16).substring(2).toUpperCase()}',
'${entry.$2} #${entry.$1.toRadixString(16).substring(2).toUpperCase()}',
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => onChanged(value),
onTap: () => onChanged(entry.$1),
child: DecoratedBox(
decoration: BoxDecoration(
color: Color(value),
color: Color(entry.$1),
shape: BoxShape.circle,
border: Border.all(
color: selected == value
color: selected == entry.$1
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.outlineVariant,
width: selected == value ? 3 : 1,
width: selected == entry.$1 ? 3 : 1,
),
),
child: SizedBox(
width: 38,
height: 38,
child: selected == value
child: selected == entry.$1
? const Icon(Icons.check_rounded, color: Colors.white)
: null,
),
@@ -4,11 +4,13 @@ import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final viewedControllerProvider =
AsyncNotifierProvider<ViewedController, List<Post>>(ViewedController.new);
AsyncNotifierProvider<ViewedController, List<ViewedTimelineGroup>>(
ViewedController.new,
);
class ViewedController extends AsyncNotifier<List<Post>> {
class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
@override
Future<List<Post>> build() => _load();
Future<List<ViewedTimelineGroup>> build() => _load();
Future<void> refresh() async {
state = AsyncData(await _load());
@@ -20,9 +22,44 @@ class ViewedController extends AsyncNotifier<List<Post>> {
state = const AsyncData([]);
}
Future<List<Post>> _load() async {
Future<List<ViewedTimelineGroup>> _load() async {
final result =
await ref.read(viewedHistoryServiceProvider).getViewedPosts();
return result is Success<List<Post>> ? result.data : const [];
await ref.read(viewedHistoryServiceProvider).getViewedPostEntries();
if (result is! Success<List<ViewedPostEntry>>) return const [];
return groupViewedTimeline(result.data);
}
}
class ViewedTimelineGroup {
const ViewedTimelineGroup({required this.label, required this.items});
final String label;
final List<ViewedPostEntry> items;
}
List<ViewedTimelineGroup> groupViewedTimeline(
List<ViewedPostEntry> entries, {
DateTime? now,
}) {
final today = DateTime(now?.year ?? DateTime.now().year,
now?.month ?? DateTime.now().month, now?.day ?? DateTime.now().day);
final buckets = <String, List<ViewedPostEntry>>{};
for (final entry in entries) {
final date =
DateTime(entry.viewedAt.year, entry.viewedAt.month, entry.viewedAt.day);
final days = today.difference(date).inDays;
final label = switch (days) {
0 => 'Today',
1 => 'Yesterday',
_ => _dateLabel(date),
};
buckets.putIfAbsent(label, () => []).add(entry);
}
return [
for (final entry in buckets.entries)
ViewedTimelineGroup(label: entry.key, items: entry.value),
];
}
String _dateLabel(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
@@ -3,12 +3,10 @@ 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/error_view.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import '../../favorites/presentation/favorites_controller.dart';
import 'viewed_controller.dart';
@@ -21,7 +19,6 @@ class ViewedScreen extends ConsumerWidget {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
return AdaptiveScaffold(
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
actions: [
@@ -34,47 +31,120 @@ class ViewedScreen extends ConsumerWidget {
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (posts) => posts.isEmpty
data: (groups) => groups.isEmpty
? const EmptyView(title: 'No viewed posts yet')
: RefreshIndicator(
onRefresh: () =>
ref.read(viewedControllerProvider.notifier).refresh(),
child: PostMasonryGrid(
posts: posts,
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,
viewedKeys: viewedKeys,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
),
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);
},
child: ListView(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
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,
),
),
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,
),
),
],
],
),
),
),
);
}
Future<void> _toggleFavorite(
WidgetRef ref,
Post post,
AppSettings settings,
Set<String> favoriteKeys,
) 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);
}
}
class _ViewedPostTile extends StatelessWidget {
const _ViewedPostTile({
required this.post,
required this.viewedAt,
required this.isFavorite,
required this.onOpen,
required this.onFavorite,
});
final Post post;
final DateTime viewedAt;
final bool isFavorite;
final VoidCallback onOpen;
final VoidCallback onFavorite;
@override
Widget build(BuildContext context) {
final image = MediaUrlSelector.preview(post).firstOrNull;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
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),
),
),
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),
),
),
);
}
}
+247 -144
View File
@@ -46,11 +46,16 @@ class TagInputSearchBar extends StatefulWidget {
}
class _TagInputSearchBarState extends State<TagInputSearchBar> {
final _fieldKey = GlobalKey();
final _layerLink = LayerLink();
late final TextEditingController _controller;
late final FocusNode _focusNode;
late final ScrollController _tagScrollController;
OverlayEntry? _suggestionsOverlay;
Timer? _debounce;
List<String> _tags = [];
String _lastExternalValue = '';
bool _localDirty = false;
@override
void initState() {
@@ -59,21 +64,30 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_focusNode = FocusNode();
_tagScrollController = ScrollController();
_focusNode.addListener(_handleFocusChanged);
_setFromQuery(widget.initialValue ?? '');
_lastExternalValue = widget.initialValue ?? '';
_setFromQuery(_lastExternalValue);
}
@override
void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
super.didUpdateWidget(oldWidget);
final next = widget.initialValue ?? '';
if (!_focusNode.hasFocus && oldWidget.initialValue != next) {
final externalChanged = next != _lastExternalValue;
final externalCleared = next.isEmpty && _lastExternalValue.isNotEmpty;
if (externalChanged && (externalCleared || !_isEditing)) {
_setFromQuery(next);
_lastExternalValue = next;
_localDirty = false;
}
if (oldWidget.suggestions != widget.suggestions) {
_syncSuggestionsOverlay();
}
}
@override
void dispose() {
_debounce?.cancel();
_removeSuggestionsOverlay();
_tagScrollController.dispose();
_focusNode.removeListener(_handleFocusChanged);
_focusNode.dispose();
@@ -82,114 +96,149 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
void _handleFocusChanged() {
if (mounted) setState(() {});
if (!mounted) return;
if (!_focusNode.hasFocus && !_localDirty) {
final next = widget.initialValue ?? '';
if (next != _lastExternalValue) {
_setFromQuery(next);
_lastExternalValue = next;
}
}
setState(() {});
_syncSuggestionsOverlay();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor ??
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _focusNode.hasFocus
? scheme.primary.withValues(alpha: 0.45)
: Colors.transparent,
),
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _syncSuggestionsOverlay();
});
return CompositedTransformTarget(
link: _layerLink,
child: DecoratedBox(
key: _fieldKey,
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor ??
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _focusNode.hasFocus
? scheme.primary.withValues(alpha: 0.45)
: Colors.transparent,
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _focusNode.requestFocus,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 38,
child: Scrollbar(
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _focusNode.requestFocus,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 38,
child: Scrollbar(
controller: _tagScrollController,
thumbVisibility: false,
child: SingleChildScrollView(
controller: _tagScrollController,
thumbVisibility: false,
child: SingleChildScrollView(
controller: _tagScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final tag in _tags) ...[
_buildTagChip(context, tag),
const SizedBox(width: 6),
],
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 120,
maxWidth: 300,
),
child: Focus(
onKeyEvent: _handleKeyEvent,
child: TextField(
controller: _controller,
focusNode: _focusNode,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
contentPadding:
const EdgeInsets.symmetric(
vertical: 8,
),
hintText: _tags.isEmpty
? widget.hintText
: 'tag',
prefixIcon: null,
suffixIcon: null,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final tag in _tags) ...[
_buildTagChip(context, tag),
const SizedBox(width: 6),
],
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 120,
maxWidth: 300,
),
child: Focus(
onKeyEvent: _handleKeyEvent,
child: TextField(
controller: _controller,
focusNode: _focusNode,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
),
hintText:
_tags.isEmpty ? widget.hintText : 'tag',
prefixIcon: null,
suffixIcon: null,
),
),
),
],
),
),
],
),
),
),
),
IconButton(
tooltip: 'Clear',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded),
onPressed: _clear,
),
],
),
),
IconButton(
tooltip: 'Clear',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded),
onPressed: _clear,
),
],
),
),
),
if (widget.suggestions.isNotEmpty) ...[
const SizedBox(height: 8),
_TagSuggestionDropdown(
suggestions: widget.suggestions,
onSelected: _applySuggestion,
),
],
],
),
);
}
void _syncSuggestionsOverlay() {
final token = _activeToken;
final suggestions = widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false);
if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) {
_removeSuggestionsOverlay();
return;
}
final overlay = Overlay.maybeOf(context);
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (overlay == null || renderBox == null || !renderBox.hasSize) return;
final size = renderBox.size;
_removeSuggestionsOverlay();
_suggestionsOverlay = OverlayEntry(
builder: (context) => _TagSuggestionOverlay(
link: _layerLink,
width: size.width,
yOffset: size.height + 8,
suggestions: suggestions,
onSelected: _applySuggestion,
),
);
overlay.insert(_suggestionsOverlay!);
}
void _removeSuggestionsOverlay() {
_suggestionsOverlay?.remove();
_suggestionsOverlay = null;
}
Widget _buildTagChip(BuildContext context, String tag) {
final scheme = Theme.of(context).colorScheme;
return InputChip(
@@ -234,6 +283,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
void _handleDraftChanged(String value) {
_localDirty = true;
final composing = _controller.value.composing;
if (!composing.isValid &&
value.isNotEmpty &&
@@ -252,6 +302,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _commitDraft(String value) {
final additions = _parseTags(value);
if (additions.isEmpty) return;
_localDirty = true;
setState(() {
for (final tag in additions) {
if (!_tags.contains(tag)) _tags.add(tag);
@@ -264,11 +315,15 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _submit() {
_debounce?.cancel();
_commitDraft(_controller.text);
_removeSuggestionsOverlay();
_lastExternalValue = _query;
_localDirty = false;
widget.onSubmitted(_query);
}
void _clear() {
_debounce?.cancel();
_localDirty = true;
setState(() {
_tags = [];
_controller.clear();
@@ -278,6 +333,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
void _editTag(String tag) {
_localDirty = true;
setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true);
_controller.value = TextEditingValue(
@@ -290,6 +346,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
void _removeTag(String tag) {
_localDirty = true;
setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true);
});
@@ -298,6 +355,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _applySuggestion(String suggestion) {
final draft = _controller.text.trim();
_localDirty = true;
setState(() {
if (draft.isNotEmpty) {
final draftTags = _parseTags(draft);
@@ -307,6 +365,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_controller.clear();
});
_notifyChanged();
_removeSuggestionsOverlay();
widget.onSuggestionApplied?.call(_query);
_focusNode.requestFocus();
}
@@ -317,7 +376,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
void _notifyChanged() {
_localDirty = true;
widget.onChanged?.call(_query);
_syncSuggestionsOverlay();
}
List<String> _parseTags(String query) {
@@ -336,6 +397,51 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
final all = [..._tags, if (draft.isNotEmpty) draft];
return all.join(' ');
}
String get _activeToken {
final draft = _controller.text.trim().toLowerCase();
if (draft.isEmpty) return '';
return draft.split(RegExp(r'\s+')).last;
}
bool get _isEditing => _focusNode.hasFocus || _localDirty;
}
class _TagSuggestionOverlay extends StatelessWidget {
const _TagSuggestionOverlay({
required this.link,
required this.width,
required this.yOffset,
required this.suggestions,
required this.onSelected,
});
final LayerLink link;
final double width;
final double yOffset;
final List<TagSuggestion> suggestions;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: CompositedTransformFollower(
link: link,
showWhenUnlinked: false,
offset: Offset(0, yOffset),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width.clamp(280, 620).toDouble(),
child: _TagSuggestionDropdown(
suggestions: suggestions,
onSelected: onSelected,
),
),
),
),
);
}
}
class _TagSuggestionDropdown extends StatelessWidget {
@@ -350,62 +456,59 @@ class _TagSuggestionDropdown extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Material(
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.18),
color: scheme.surface,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: suggestions.length > 8 ? 8 : suggestions.length,
separatorBuilder: (_, __) => Divider(
height: 1,
indent: 52,
color: scheme.outlineVariant.withValues(alpha: 0.55),
),
itemBuilder: (context, index) {
final suggestion = suggestions[index];
return InkWell(
onTap: () => onSelected(suggestion.name),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Icon(Icons.tag_rounded, color: scheme.primary, size: 20),
const SizedBox(width: 14),
Expanded(
child: Text(
suggestion.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge,
),
),
const SizedBox(width: 10),
_TagSuggestionBadge(suggestion.categoryLabel),
if (suggestion.postCount > 0) ...[
const SizedBox(width: 8),
Text(
_compactCount(suggestion.postCount),
style:
Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
],
),
),
);
},
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Material(
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.18),
color: scheme.surface,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: suggestions.length > 8 ? 8 : suggestions.length,
separatorBuilder: (_, __) => Divider(
height: 1,
indent: 52,
color: scheme.outlineVariant.withValues(alpha: 0.55),
),
itemBuilder: (context, index) {
final suggestion = suggestions[index];
return InkWell(
onTap: () => onSelected(suggestion.name),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Icon(Icons.tag_rounded, color: scheme.primary, size: 20),
const SizedBox(width: 14),
Expanded(
child: Text(
suggestion.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge,
),
),
const SizedBox(width: 10),
_TagSuggestionBadge(suggestion.categoryLabel),
if (suggestion.postCount > 0) ...[
const SizedBox(width: 8),
Text(
_compactCount(suggestion.postCount),
style:
Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
],
),
),
);
},
),
),
);
+14 -11
View File
@@ -287,7 +287,9 @@ class _MobileShell extends StatelessWidget {
? null
: FloatingActionButton.small(
heroTag: 'mobile-settings',
tooltip: ru ? 'Настройки' : 'Settings',
tooltip: ru
? '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438'
: 'Settings',
onPressed: () => context.go('/settings'),
child: const Icon(Icons.settings_rounded),
),
@@ -325,14 +327,15 @@ class _Destination {
String labelFor(bool ru) {
if (!ru) return label;
return switch (id) {
'feed' => 'Лента',
'search' => 'Поиск',
'favorites' => 'Избранное',
'viewed' => 'История',
'collections' => 'Коллекции',
'artists' => 'Артисты',
'providers' => 'Провайдеры',
'settings' => 'Настройки',
'feed' => '\u041b\u0435\u043d\u0442\u0430',
'search' => '\u041f\u043e\u0438\u0441\u043a',
'favorites' => '\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435',
'viewed' => '\u0418\u0441\u0442\u043e\u0440\u0438\u044f',
'collections' => '\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u0438',
'artists' => '\u0410\u0440\u0442\u0438\u0441\u0442\u044b',
'providers' =>
'\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b',
'settings' => '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
_ => label,
};
}
@@ -340,8 +343,8 @@ class _Destination {
String mobileLabelFor(bool ru) {
if (!ru) return mobileLabel;
return switch (id) {
'collections' => 'Доски',
'favorites' => 'Любимое',
'collections' => '\u0414\u043e\u0441\u043a\u0438',
'favorites' => '\u041b\u044e\u0431\u0438\u043c\u043e\u0435',
_ => labelFor(true),
};
}
+276 -9
View File
@@ -1,13 +1,20 @@
import 'dart:async';
import 'dart:collection';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../app/motion.dart';
import '../../backend/backend.dart';
import '../../core/utils/result.dart';
import 'blur_content.dart';
import 'loading_skeleton.dart';
import 'rating_badge.dart';
class PostCard extends StatefulWidget {
class PostCard extends ConsumerStatefulWidget {
const PostCard({
required this.post,
required this.blurExplicit,
@@ -44,25 +51,40 @@ class PostCard extends StatefulWidget {
final bool selected;
@override
State<PostCard> createState() => _PostCardState();
ConsumerState<PostCard> createState() => _PostCardState();
}
class _PostCardState extends State<PostCard> {
class _PostCardState extends ConsumerState<PostCard> {
static final Map<String, Post> _resolvedRealbooruPosts = {};
bool _hovered = false;
Post? _resolvedPost;
@override
void initState() {
super.initState();
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
_maybeResolveRealbooruPost();
}
@override
void didUpdateWidget(covariant PostCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.post.cacheKey != widget.post.cacheKey) {
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
_maybeResolveRealbooruPost();
}
}
@override
Widget build(BuildContext context) {
final post = widget.post;
final post = _resolvedPost ?? widget.post;
final sensitive = _isSensitive(post.rating);
final aspect = post.width > 0 && post.height > 0
? post.width / post.height
: _fallbackAspect(post.fileType);
final mobile = MediaQuery.sizeOf(context).width < 700;
final urls = MediaUrlSelector.feed(
post,
mode: widget.mediaQualityMode,
mobile: mobile,
);
final urls = _feedUrls(post, mobile: mobile);
final imageUrl = urls.isEmpty ? post.previewUrl : urls.first;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
@@ -130,8 +152,23 @@ class _PostCardState extends State<PostCard> {
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,
@@ -291,6 +328,236 @@ class _PostCardState extends State<PostCard> {
if (normalized.contains('gif')) return 1;
return 0.72;
}
List<String> _feedUrls(Post post, {required bool mobile}) {
if ((post.providerId == 'realbooru' || post.providerId == 'paheal') &&
!MediaUrlSelector.isVideo(post)) {
return [
post.sampleUrl,
post.fileUrl,
post.previewUrl,
].where((url) => url.trim().isNotEmpty).toSet().toList(growable: false);
}
return MediaUrlSelector.feed(
post,
mode: widget.mediaQualityMode,
mobile: mobile,
);
}
Map<String, String> _headersFor(Post post) {
final lower = '${post.providerId} ${post.providerName} '
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
.toLowerCase();
return {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': '*/*',
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
if (lower.contains('realbooru'))
'User-Agent':
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
if (lower.contains('realbooru'))
'Accept':
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
if (lower.contains('realbooru'))
'Referer':
'https://realbooru.com/index.php?page=post&s=view&id=${post.id}',
if (lower.contains('paheal'))
'User-Agent':
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
if (lower.contains('paheal'))
'Accept':
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
if (lower.contains('paheal'))
'Referer': 'https://rule34.paheal.net/post/view/${post.id}',
if (lower.contains('rule34') && !lower.contains('paheal'))
'Referer': 'https://rule34.xxx/',
};
}
void _maybeResolveRealbooruPost() {
final post = widget.post;
if (!_needsRealbooruDetails(post) ||
_resolvedRealbooruPosts.containsKey(post.cacheKey)) {
return;
}
Future<void>(() async {
final result = await ref
.read(providerManagerProvider)
.getPost(post.providerId, post.id);
if (!mounted) return;
if (result is Success<Post?> && result.data != null) {
final resolved = result.data!;
_resolvedRealbooruPosts[post.cacheKey] = resolved;
setState(() => _resolvedPost = resolved);
}
});
}
bool _needsRealbooruDetails(Post post) {
if (post.providerId != 'realbooru') return false;
final preview = post.previewUrl.toLowerCase();
final sample = post.sampleUrl.toLowerCase();
return post.fileUrl.isEmpty ||
post.fileUrl == post.previewUrl ||
post.fileUrl == post.sampleUrl ||
preview.contains('/thumbnails/') ||
sample.contains('/thumbnails/');
}
}
class _FeedVideoPreview extends StatefulWidget {
const _FeedVideoPreview({
required this.videoUrl,
required this.headers,
required this.fallbackImageUrl,
required this.cacheWidth,
});
final String videoUrl;
final Map<String, String> headers;
final String fallbackImageUrl;
final int cacheWidth;
@override
State<_FeedVideoPreview> createState() => _FeedVideoPreviewState();
}
class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
late final Player _player;
late final VideoController _controller;
bool _failed = false;
bool _ready = false;
int _requestId = 0;
@override
void initState() {
super.initState();
MediaKit.ensureInitialized();
_player = Player();
_controller = VideoController(_player);
_player.stream.error.listen((_) {
if (mounted) setState(() => _failed = true);
});
_open();
}
@override
void didUpdateWidget(covariant _FeedVideoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoUrl != widget.videoUrl) {
_open();
}
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fallback = CachedNetworkImage(
imageUrl: widget.fallbackImageUrl,
httpHeaders: widget.headers,
memCacheWidth: widget.cacheWidth,
maxWidthDiskCache: widget.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.videocam_off_rounded)),
),
);
if (_failed) return fallback;
return Stack(
fit: StackFit.expand,
children: [
fallback,
AnimatedOpacity(
opacity: _ready ? 1 : 0,
duration: AppMotion.duration(context, 180),
child: ColoredBox(
color: Colors.black,
child: Video(
controller: _controller,
fit: BoxFit.cover,
controls: null,
),
),
),
],
);
}
Future<void> _open() async {
final requestId = ++_requestId;
if (mounted) {
setState(() {
_failed = false;
_ready = false;
});
}
try {
await _VideoPreviewOpenQueue.run(() async {
if (!mounted || requestId != _requestId) return;
await _player.stop();
await _player
.open(
Media(widget.videoUrl, httpHeaders: widget.headers),
play: false,
)
.timeout(const Duration(seconds: 8));
if (!mounted || requestId != _requestId) return;
await _player
.seek(const Duration(milliseconds: 300))
.timeout(const Duration(seconds: 3));
await _player.pause();
});
if (!mounted || requestId != _requestId) return;
setState(() => _ready = true);
} catch (_) {
if (mounted && requestId == _requestId) {
setState(() => _failed = true);
}
}
}
}
class _VideoPreviewOpenQueue {
static const int _maxConcurrent = 2;
static final Queue<Future<void> Function()> _pending = Queue();
static int _active = 0;
static Future<void> run(Future<void> Function() task) {
final completer = Completer<void>();
_pending.add(() async {
try {
await task();
if (!completer.isCompleted) completer.complete();
} catch (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
}
});
_pump();
return completer.future;
}
static void _pump() {
while (_active < _maxConcurrent && _pending.isNotEmpty) {
final job = _pending.removeFirst();
_active++;
unawaited(
job().whenComplete(() {
_active--;
_pump();
}),
);
}
}
}
class _FavoriteButton extends StatelessWidget {
+14 -2
View File
@@ -1,15 +1,21 @@
import 'package:flutter/material.dart';
class TagChip extends StatelessWidget {
const TagChip({required this.tag, this.onTap, super.key});
const TagChip({
required this.tag,
this.onTap,
this.onLongPress,
super.key,
});
final String tag;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ActionChip(
final chip = ActionChip(
onPressed: onTap,
label: Text(tag),
labelStyle: TextStyle(
@@ -20,5 +26,11 @@ class TagChip extends StatelessWidget {
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.65)),
visualDensity: VisualDensity.compact,
);
if (onLongPress == null) return chip;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: onLongPress,
child: chip,
);
}
}
+8 -16
View File
@@ -627,10 +627,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@@ -683,10 +683,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
mime:
dependency: transitive
description:
@@ -1064,10 +1064,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
time:
dependency: transitive
description:
@@ -1184,10 +1184,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.2"
vm_service:
dependency: transitive
description:
@@ -1252,14 +1252,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webview_windows:
dependency: "direct main"
description:
name: webview_windows
sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
win32:
dependency: transitive
description:
+1 -2
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.1+22
version: 2.0.2+24
environment:
sdk: ">=3.3.0 <4.0.0"
@@ -31,7 +31,6 @@ dependencies:
file_selector: ^1.0.3
path: ^1.9.0
media_kit_libs_android_video: ^1.3.8
webview_windows: ^0.4.0
dev_dependencies:
flutter_test:
+8
View File
@@ -50,6 +50,14 @@ void main() {
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
});
test('requested search tags can match tag prefixes', () {
final post = _post(tags: ['zenless_zone_zero', 'ellen_joe']);
expect(postMatchesRequestedTags(post, ['zenless']), isTrue);
expect(postMatchesRequestedTags(post, ['zenless', 'ellen']), isTrue);
expect(postMatchesRequestedTags(post, ['zone']), isFalse);
});
test('hidden post keys are filtered locally', () {
final post = _post(tags: ['touhou']);
final settings = AppSettings.defaults.copyWith(
+32 -38
View File
@@ -38,9 +38,13 @@ void main() {
File('${Directory.current.path}${Platform.pathSeparator}isar.dll');
if (Platform.isWindows && !copiedIsarDll.existsSync()) {
final localAppData = Platform.environment['LOCALAPPDATA'];
final source = File(
final bundled = File(
'${Directory.current.path}\\third_party\\isar_flutter_libs\\windows\\isar.dll',
);
final cached = File(
'$localAppData\\Pub\\Cache\\hosted\\pub.dev\\isar_flutter_libs-3.1.0+1\\windows\\isar.dll',
);
final source = bundled.existsSync() ? bundled : cached;
if (source.existsSync()) {
await source.copy(copiedIsarDll.path);
}
@@ -78,42 +82,32 @@ void main() {
expect(providers.map((provider) => provider.id), [
'gelbooru',
'rule34',
'safebooru',
'konachan',
'yandere',
'realbooru',
'e621',
'e926',
'realbooru',
'xbooru',
'cosbooru',
'kemono',
'coomer',
'pawchive',
]);
expect(
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
isFalse,
);
expect(
providers.singleWhere((provider) => provider.id == 'cosbooru').enabled,
isTrue,
);
final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru');
expect(xbooru.enabled, isTrue);
expect(xbooru.baseUrl, 'https://xbooru.com');
expect(xbooru.apiType, 'gelbooru');
final cosbooru =
providers.singleWhere((provider) => provider.id == 'cosbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
expect(cosbooru.apiType, 'danbooru');
final kemono = providers.singleWhere((provider) => provider.id == 'kemono');
final coomer = providers.singleWhere((provider) => provider.id == 'coomer');
expect(kemono.enabled, isTrue);
expect(kemono.apiType, 'kemono');
expect(coomer.enabled, isFalse);
expect(coomer.apiType, 'coomer');
final pawchive =
providers.singleWhere((provider) => provider.id == 'pawchive');
expect(pawchive.enabled, isTrue);
expect(pawchive.apiType, 'pawchive');
expect(pawchive.baseUrl, 'https://pawchive.pw');
final realbooru =
providers.singleWhere((provider) => provider.id == 'realbooru');
expect(realbooru.enabled, isTrue);
expect(realbooru.apiType, 'realbooru_html');
expect(realbooru.baseUrl, 'https://realbooru.com');
expect(providers.where((provider) => provider.id == 'safebooru'), isEmpty);
expect(providers.where((provider) => provider.id == 'konachan'), isEmpty);
expect(providers.where((provider) => provider.id == 'yandere'), isEmpty);
expect(providers.where((provider) => provider.id == 'xbooru'), isEmpty);
expect(providers.where((provider) => provider.id == 'paheal'), isEmpty);
expect(providers.where((provider) => provider.id == 'kemono'), isEmpty);
expect(providers.where((provider) => provider.id == 'coomer'), isEmpty);
});
test('provider repository migrates old CosBooru config', () async {
test('provider repository disables old CosBooru config without deleting it',
() async {
final repository = ProviderRepository(databaseService);
final now = DateTime(2026);
await repository.saveProvider(ContentProviderConfig(
@@ -134,10 +128,10 @@ void main() {
final cosbooru =
providers.singleWhere((provider) => provider.id == 'cosbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
expect(cosbooru.apiType, 'danbooru');
expect(cosbooru.enabled, isTrue);
expect(cosbooru.priority, 9);
expect(cosbooru.baseUrl, 'https://cos.booru.nl');
expect(cosbooru.apiType, 'gelbooru');
expect(cosbooru.enabled, isFalse);
expect(cosbooru.priority, 99);
});
test('settings saveEnabledProviders updates provider configs', () async {
@@ -148,11 +142,11 @@ void main() {
providerRepository: providerRepository,
);
await service.saveEnabledProviders(['safebooru']);
await service.saveEnabledProviders(['realbooru']);
final providers = (await providerRepository.getProviders() as Success).data;
expect(
providers.singleWhere((provider) => provider.id == 'safebooru').enabled,
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
isTrue);
expect(
providers.singleWhere((provider) => provider.id == 'gelbooru').enabled,
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/artist_work_query.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/pawchive_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/core/http/dio_client.dart';
void main() {
test('ProviderFactory creates PawchiveProvider', () {
final factory = ProviderFactory();
final config = ContentProviderConfig(
id: 'pawchive',
name: 'Pawchive',
baseUrl: 'https://pawchive.pw',
apiType: 'pawchive',
enabled: true,
priority: 12,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final provider = factory.create(config);
expect(provider, isA<PawchiveProvider>());
expect(provider.id, 'pawchive');
expect(provider.name, 'Pawchive');
expect(provider.baseUrl, 'https://pawchive.pw');
});
test('PawchiveProvider creates posts with thumbnail and file URLs', () async {
final client = DioClient(baseUrl: 'https://pawchive.pw');
final provider = PawchiveProvider(
id: 'pawchive',
name: 'Pawchive',
baseUrl: 'https://pawchive.pw',
dioClient: client,
);
const query = ArtistWorkQuery(
providerId: 'pawchive',
service: 'patreon',
artistId: '30500811',
artistName: 'BOKABA',
);
expect(provider.id, query.providerId);
expect(query.artistName, 'BOKABA');
});
}
+204 -2
View File
@@ -3,6 +3,7 @@ import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/models/post.dart';
import 'package:gel_rule_app/backend/models/provider_diagnostics.dart';
import 'package:gel_rule_app/backend/models/provider_health.dart';
import 'package:gel_rule_app/backend/models/tag_suggestion.dart';
import 'package:gel_rule_app/backend/models/top_period_filter.dart';
import 'package:gel_rule_app/backend/providers/content_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
@@ -76,8 +77,15 @@ class FakeProviderFactory extends ProviderFactory {
ContentProvider create(ContentProviderConfig config) => providers[config.id]!;
}
class FakeProvider implements ContentProvider {
FakeProvider(this.id, this.name, this.posts, {this.failSearch = false});
class FakeProvider implements ContentProvider, TagSuggestionProvider {
FakeProvider(
this.id,
this.name,
this.posts, {
this.failSearch = false,
this.suggestions = const [],
this.suggestionDelay = Duration.zero,
});
@override
final String id;
@@ -87,6 +95,9 @@ class FakeProvider implements ContentProvider {
String get baseUrl => 'https://example.test';
final List<Post> posts;
final bool failSearch;
final List<TagSuggestion> suggestions;
final Duration suggestionDelay;
int suggestionCalls = 0;
@override
Future<ProviderHealth> checkHealth() async => ProviderHealth(
@@ -111,6 +122,19 @@ class FakeProvider implements ContentProvider {
if (failSearch) throw Exception('fail');
return posts;
}
@override
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
suggestionCalls++;
if (suggestionDelay > Duration.zero) {
await Future<void>.delayed(suggestionDelay);
}
return suggestions
.where((suggestion) => suggestion.name.startsWith(query))
.take(limit)
.toList(growable: false);
}
}
ContentProviderConfig config(String id, int priority, {bool enabled = true}) {
@@ -224,4 +248,182 @@ void main() {
await manager.enableProvider('a', false);
expect(repository.configs['a']!.enabled, isFalse);
});
test('tag suggestions query every active suggestion provider before limiting',
() async {
final repository = FakeProviderRepository()
..configs['gelbooru'] = config('gelbooru', 0)
..configs['e621'] = config('e621', 1)
..configs['e926'] = config('e926', 2);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'gelbooru': FakeProvider(
'gelbooru',
'Gelbooru',
[],
suggestions: [
for (var i = 0; i < 4; i++)
TagSuggestion(
name: 'cat_$i',
category: TagCategory.general,
postCount: 100 - i,
providerId: 'gelbooru',
),
],
),
'e621': FakeProvider(
'e621',
'e621',
[],
suggestions: const [
TagSuggestion(
name: 'cat_tail',
category: TagCategory.general,
postCount: 1000,
providerId: 'e621',
),
],
),
'e926': FakeProvider(
'e926',
'e926',
[],
suggestions: const [
TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 900,
providerId: 'e926',
),
],
),
}),
);
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(result.data.map((item) => item.providerId).toSet(), {
'gelbooru',
'e621',
'e926',
});
});
test('tag suggestions timeout slow providers without blocking fast results',
() async {
final repository = FakeProviderRepository()
..configs['fast'] = config('fast', 0)
..configs['slow'] = config('slow', 1);
final fast = FakeProvider(
'fast',
'fast',
[],
suggestions: const [
TagSuggestion(
name: 'cat_fast',
category: TagCategory.general,
postCount: 20,
providerId: 'fast',
),
],
);
final slow = FakeProvider(
'slow',
'slow',
[],
suggestionDelay: const Duration(seconds: 3),
suggestions: const [
TagSuggestion(
name: 'cat_slow',
category: TagCategory.general,
postCount: 200,
providerId: 'slow',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'fast': fast, 'slow': slow}),
);
final startedAt = DateTime.now();
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(DateTime.now().difference(startedAt),
lessThan(const Duration(milliseconds: 2500)));
expect(result.data.map((item) => item.name), ['cat_fast']);
});
test('tag suggestions dedupe by name and keep highest post count', () async {
final repository = FakeProviderRepository()
..configs['a'] = config('a', 0)
..configs['b'] = config('b', 1);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
),
'b': FakeProvider(
'b',
'b',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.artist,
postCount: 100,
providerId: 'b',
),
],
),
}),
);
final result = await manager.suggestTags('cat', limit: 4)
as Success<List<TagSuggestion>>;
expect(result.data, hasLength(1));
expect(result.data.single.providerId, 'b');
expect(result.data.single.postCount, 100);
});
test('tag suggestions cache repeated prefix results', () async {
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
final provider = FakeProvider(
'a',
'a',
[],
suggestions: const [
TagSuggestion(
name: 'cat',
category: TagCategory.general,
postCount: 10,
providerId: 'a',
),
],
);
final manager = ProviderManager(
repository,
FakeProviderFactory({'a': provider}),
);
await manager.suggestTags('cat', limit: 4);
await manager.suggestTags('cat', limit: 4);
expect(provider.suggestionCalls, 1);
});
}
+40 -37
View File
@@ -5,10 +5,10 @@ import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart';
import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart';
import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/danbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/custom_provider.dart';
import 'package:gel_rule_app/backend/providers/kemono_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/backend/providers/realbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/realbooru_html_provider.dart';
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
void main() {
@@ -231,44 +231,47 @@ void main() {
expect(posts.single.rating, 'unknown');
});
test('seed providers include CosBooru and no Paheal', () {
test('seed providers include Realbooru HTML Pawchive and exclude deprecated providers', () {
final pawchive = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'pawchive')
.single;
expect(pawchive.enabled, isTrue);
expect(pawchive.apiType, 'pawchive');
expect(pawchive.baseUrl, 'https://pawchive.pw');
final realbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'realbooru')
.single;
expect(realbooru.enabled, isFalse);
expect(realbooru.apiType, 'realbooru');
expect(realbooru.enabled, isTrue);
expect(realbooru.apiType, 'realbooru_html');
expect(realbooru.baseUrl, 'https://realbooru.com');
expect(
ProviderRepository.seedProviders()
.where((provider) => provider.id == 'cosbooru'),
isEmpty,
);
expect(
ProviderRepository.seedProviders()
.where((provider) => provider.id == 'paheal'),
isEmpty,
);
final cosbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'cosbooru')
.single;
final xbooru = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'xbooru')
.single;
expect(xbooru.enabled, isTrue);
expect(xbooru.apiType, 'gelbooru');
expect(xbooru.baseUrl, 'https://xbooru.com');
expect(cosbooru.enabled, isTrue);
expect(cosbooru.apiType, 'danbooru');
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
final kemono = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'kemono')
.single;
final coomer = ProviderRepository.seedProviders()
.where((provider) => provider.id == 'coomer')
.single;
expect(kemono.enabled, isTrue);
expect(kemono.apiType, 'kemono');
expect(coomer.enabled, isFalse);
expect(coomer.apiType, 'coomer');
expect(
ProviderRepository.seedProviders()
.where((provider) => provider.id == 'xbooru'),
isEmpty,
);
expect(
ProviderRepository.seedProviders()
.where((provider) => provider.id == 'kemono'),
isEmpty,
);
expect(
ProviderRepository.seedProviders()
.where((provider) => provider.id == 'coomer'),
isEmpty,
);
});
test('provider factory creates Realbooru provider', () {
test('provider factory treats Realbooru api type as unsupported', () {
final now = DateTime(2026);
final provider = ProviderFactory().create(ContentProviderConfig(
id: 'realbooru',
@@ -283,25 +286,25 @@ void main() {
updatedAt: now,
));
expect(provider, isA<RealbooruProvider>());
expect(provider, isA<UnsupportedCustomProvider>());
});
test('provider factory creates CosBooru as Danbooru provider', () {
test('provider factory creates Realbooru HTML provider', () {
final now = DateTime(2026);
final provider = ProviderFactory().create(ContentProviderConfig(
id: 'cosbooru',
name: 'CosBooru',
baseUrl: 'https://cos.lycore.co',
apiType: 'danbooru',
id: 'realbooru',
name: 'Realbooru',
baseUrl: 'https://realbooru.com',
apiType: 'realbooru_html',
enabled: true,
priority: 8,
priority: 7,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
));
expect(provider, isA<DanbooruProvider>());
expect(provider, isA<RealbooruHtmlProvider>());
});
test('provider factory creates Kemono and Coomer artist providers', () {
+2 -2
View File
@@ -14,10 +14,10 @@ class FakeSearchRepository implements SearchRepository {
}
@override
Future<Result<List<SearchHistory>>> recent({int limit = 20}) async {
Future<Result<List<SearchHistory>>> recent({int? limit}) async {
final sorted = [...items]
..sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
return Success(sorted.take(limit).toList());
return Success((limit == null ? sorted : sorted.take(limit)).toList());
}
@override
+41
View File
@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/app_update_info.dart';
import 'package:gel_rule_app/backend/services/settings_service.dart';
import 'package:gel_rule_app/backend/services/update_service.dart';
import 'package:gel_rule_app/core/database/database_service.dart';
void main() {
test('UpdateService parses Gitea release JSON correctly', () {
final service = UpdateService(Dio(), SettingsService(_MockDatabaseService()));
// Use reflection or constructor to test info
final info = AppUpdateInfo(
version: '2.0.2',
tagName: 'v2.0.2',
name: 'Lunaris 2.0.2',
body: 'Release notes',
htmlUrl: 'https://gitea.rardogsynapse.online/RarDog/Lunaris/releases/tag/v2.0.2',
publishedAt: DateTime.parse('2026-09-03T18:00:00Z'),
apkUrl: 'https://gitea.rardogsynapse.online/attachments/apk',
linuxTarGzUrl: 'https://gitea.rardogsynapse.online/attachments/linux',
);
expect(info.hasAssets, isTrue);
expect(info.apkUrl, contains('apk'));
expect(info.linuxTarGzUrl, contains('linux'));
expect(
service.assetFileName(info, 'https://gitea.rardogsynapse.online/attachments/Lunaris-v2.0.2.apk'),
'Lunaris-v2.0.2.apk',
);
expect(
service.assetFileName(info, info.apkUrl!),
'Lunaris-v2.0.2-linux-x64.tar.gz',
);
});
}
class _MockDatabaseService implements DatabaseService {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+6 -1
View File
@@ -1,4 +1,5 @@
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/models/post.dart';
import 'package:gel_rule_app/features/post/presentation/widgets/post_tags_panel.dart';
@@ -26,7 +27,11 @@ void main() {
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpWidget(
ProviderScope(
child: MaterialApp.router(routerConfig: router),
),
);
expect(find.text('Artist'), findsOneWidget);
expect(find.text('Character'), findsOneWidget);
+145 -1
View File
@@ -99,6 +99,83 @@ void main() {
expect(find.text('new_tag'), findsOneWidget);
});
testWidgets('old external value does not restore deleted chip',
(tester) async {
String? changed;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag keep_tag',
onSubmitted: (_) {},
onChanged: (value) {
changed = value;
},
),
));
await tester.tap(find.byIcon(Icons.close_rounded).first);
await tester.pump();
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag keep_tag',
onSubmitted: (_) {},
onChanged: (value) {
changed = value;
},
),
));
await tester.pump();
expect(find.text('old_tag'), findsNothing);
expect(find.text('keep_tag'), findsOneWidget);
expect(changed, 'keep_tag');
});
testWidgets('focused draft is not replaced by stale external value',
(tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag',
onSubmitted: (_) {},
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'new_tag');
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'old_tag',
onSubmitted: (_) {},
),
));
await tester.pump();
expect(find.text('old_tag'), findsOneWidget);
expect(find.text('new_tag'), findsOneWidget);
});
testWidgets('matching external draft does not become a chip while focused',
(tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: '',
onSubmitted: (_) {},
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'draft_tag');
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'draft_tag',
onSubmitted: (_) {},
),
));
await tester.pump();
expect(find.byType(InputChip), findsNothing);
expect(find.text('draft_tag'), findsOneWidget);
});
testWidgets('suggestion appends active tag as a chip', (tester) async {
String? applied;
await tester.pumpWidget(_Harness(
@@ -118,8 +195,9 @@ void main() {
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'tou');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('touhou'));
await tester.pump();
@@ -127,6 +205,72 @@ void main() {
expect(applied, 'touhou');
});
testWidgets('stale suggestions are hidden for a newer active token',
(tester) async {
await tester.pumpWidget(_Harness(
child: SizedBox(
width: 360,
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'touhou',
category: TagCategory.copyright,
postCount: 1200,
providerId: 'gelbooru',
),
],
onSubmitted: (_) {},
),
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'tou');
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('touhou'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'cat');
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('touhou'), findsNothing);
});
testWidgets('suggestions overlay does not resize search bar', (tester) async {
await tester.pumpWidget(_Harness(
child: SizedBox(
width: 360,
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'touhou',
category: TagCategory.copyright,
postCount: 1200,
providerId: 'gelbooru',
),
TagSuggestion(
name: 'tou_tail',
category: TagCategory.general,
postCount: 900,
providerId: 'e621',
),
],
onSubmitted: (_) {},
),
),
));
final initialHeight = tester.getSize(find.byType(TagInputSearchBar)).height;
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'tou');
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(find.text('touhou'), findsOneWidget);
expect(find.text('tou_tail'), findsOneWidget);
expect(
tester.getSize(find.byType(TagInputSearchBar)).height, initialHeight);
});
testWidgets('many chips stay in a single compact row', (tester) async {
await tester.pumpWidget(_Harness(
child: SizedBox(
@@ -13,7 +13,6 @@
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <volume_controller/volume_controller_plugin_c_api.h>
#include <webview_windows/webview_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
@@ -30,6 +29,4 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
VolumeControllerPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
WebviewWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WebviewWindowsPlugin"));
}
-1
View File
@@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_video
url_launcher_windows
volume_controller
webview_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST