Release Lunaris 2.0.1 polish update
This commit is contained in:
@@ -1,32 +1,34 @@
|
|||||||
# Lunaris
|
# Lunaris
|
||||||
|
|
||||||
Lunaris - локальное Flutter-приложение для просмотра booru, gelbooru, rule34-подобных источников и artist-архивов в Pinterest-стиле.
|
Lunaris — локальное Flutter-приложение для просмотра booru, gelbooru, rule34-подобных источников и artist-архивов в Pinterest-стиле.
|
||||||
|
|
||||||
Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, локальный кеш metadata, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления.
|
Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, локальный кеш metadata, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления.
|
||||||
|
|
||||||
## Возможности
|
## Что умеет
|
||||||
|
|
||||||
- Masonry feed с несколькими провайдерами и перемешанной выдачей All providers.
|
- Masonry feed с несколькими провайдерами и естественно перемешанной выдачей.
|
||||||
- Поиск тегов через chip-input с подсказками из активных API.
|
- Поиск тегов через chip-input с подсказками из активных API.
|
||||||
- Избранное, Viewed history, коллекции и пакетное добавление в коллекции.
|
- Избранное, история просмотров, коллекции и пакетное добавление в коллекции.
|
||||||
- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score.
|
- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score.
|
||||||
- Details viewer для фото, GIF, видео и SWF на Windows.
|
- Details viewer для фото, GIF, видео и SWF на Windows.
|
||||||
- Видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек.
|
- Удобный видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек.
|
||||||
- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычным booru feed.
|
- Mobile image zoom: pinch-to-zoom и double tap в details.
|
||||||
|
- Опциональное автоскачивание новых favorites с отдельным удалением локального файла без удаления из избранного.
|
||||||
|
- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычной booru-лентой.
|
||||||
- Управление провайдерами, health-check и provider diagnostics.
|
- Управление провайдерами, health-check и provider diagnostics.
|
||||||
- Кастомизация интерфейса: тема, язык, акцентный цвет, видимые вкладки, beta/experimental обновления.
|
- Кастомизация интерфейса: тема, язык RU/EN, акцентный цвет, видимые вкладки, beta/experimental обновления.
|
||||||
- Экспорт и импорт настроек JSON.
|
- Экспорт и импорт настроек JSON.
|
||||||
- Автопроверка Gitea Releases, скачивание APK/Windows installer прямо из приложения.
|
- Проверка Gitea Releases и скачивание APK/Windows installer прямо из приложения.
|
||||||
|
|
||||||
## Скачать
|
## Скачать
|
||||||
|
|
||||||
Готовые сборки лежат в Gitea Releases:
|
Готовые сборки лежат в Gitea Releases:
|
||||||
|
|
||||||
[Lunaris 2.0.0](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v2.0.0)
|
[Lunaris 2.0.1](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v2.0.1)
|
||||||
|
|
||||||
- `Lunaris-v2.0.0.apk` - APK для Android.
|
- `Lunaris-v2.0.1.apk` — APK для Android.
|
||||||
- `LunarisSetup-v2.0.0.exe` - установщик для Windows.
|
- `LunarisSetup-v2.0.1.exe` — установщик для Windows.
|
||||||
- `LunarisPortable-v2.0.0.zip` - portable-версия для Windows.
|
- `LunarisPortable-v2.0.1.zip` — portable-версия для Windows.
|
||||||
|
|
||||||
## Провайдеры
|
## Провайдеры
|
||||||
|
|
||||||
@@ -51,15 +53,15 @@ Kemono/Coomer остаются отдельным разделом Artists и н
|
|||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
- `lib/core` - HTTP, база, кеш, ошибки, Result и утилиты.
|
- `lib/core` — HTTP, база, кеш, ошибки, Result и утилиты.
|
||||||
- `lib/backend/models` - доменные модели.
|
- `lib/backend/models` — доменные модели.
|
||||||
- `lib/backend/providers` - API-провайдеры и ProviderManager.
|
- `lib/backend/providers` — API-провайдеры и ProviderManager.
|
||||||
- `lib/backend/repositories` - доступ к локальному Isar-хранилищу.
|
- `lib/backend/repositories` — доступ к локальному Isar-хранилищу.
|
||||||
- `lib/backend/services` - Feed/Search/Favorites/Collections/Settings/Diagnostics/Downloads/Updates.
|
- `lib/backend/services` — Feed/Search/Favorites/Collections/Settings/Diagnostics/Downloads/Updates.
|
||||||
- `lib/backend/di` - Riverpod providers для backend/core.
|
- `lib/backend/di` — Riverpod providers для backend/core.
|
||||||
- `lib/app` - приложение, роутер, тема, motion и responsive helpers.
|
- `lib/app` — приложение, роутер, тема, локализация, motion и responsive helpers.
|
||||||
- `lib/shared/widgets` - общие UI-компоненты.
|
- `lib/shared/widgets` — общие UI-компоненты.
|
||||||
- `lib/features` - экраны и контроллеры фич.
|
- `lib/features` — экраны и контроллеры фич.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
@@ -94,8 +96,9 @@ ISCC.exe installer\Lunaris.iss
|
|||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Feed работает только с metadata/preview, full file не скачивается автоматически.
|
- Feed работает только с metadata/preview и не скачивает original/full files автоматически.
|
||||||
- Загрузка файлов происходит только по явному действию пользователя.
|
- Загрузка файлов происходит только по явному действию пользователя или при включенной настройке auto-download favorites.
|
||||||
- Приложение уважает API и rate limits сайтов.
|
- Приложение уважает API и rate limits сайтов.
|
||||||
- NSFW/blur/blacklist настраиваются локально.
|
- NSFW/blur/blacklist настраиваются локально.
|
||||||
- API keys/custom headers хранятся локально и экспортируются только через явный export settings.
|
- API keys/custom headers хранятся локально и экспортируются только через явный export settings.
|
||||||
|
- Локальные пути скачанных файлов не попадают в JSON export/import.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#define MyAppName "Lunaris"
|
#define MyAppName "Lunaris"
|
||||||
#define MyAppVersion "2.0.0"
|
#define MyAppVersion "2.0.1"
|
||||||
#define MyAppPublisher "RarDog"
|
#define MyAppPublisher "RarDog"
|
||||||
#define MyAppExeName "gel_rule_app.exe"
|
#define MyAppExeName "gel_rule_app.exe"
|
||||||
|
|
||||||
|
|||||||
+18
-14
@@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
|
|
||||||
import '../backend/backend.dart';
|
import '../backend/backend.dart';
|
||||||
import '../core/utils/result.dart';
|
import '../core/utils/result.dart';
|
||||||
|
import 'app_strings.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
@@ -82,12 +83,11 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showUpdateDialog(AppUpdateInfo info) async {
|
Future<void> _showUpdateDialog(AppUpdateInfo info) async {
|
||||||
|
final strings = ref.read(appStringsProvider);
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(_ru
|
title: Text('${strings.appUpdateAvailable}: Lunaris ${info.version}'),
|
||||||
? 'Доступен Lunaris ${info.version}'
|
|
||||||
: 'Lunaris ${info.version} is available'),
|
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -114,14 +114,14 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
|||||||
await ref.read(updateServiceProvider).skipVersion(info);
|
await ref.read(updateServiceProvider).skipVersion(info);
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: Text(_ru ? 'Пропустить' : 'Skip this version'),
|
child: Text(strings.skipThisVersion),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await ref.read(updateServiceProvider).remindLater();
|
await ref.read(updateServiceProvider).remindLater();
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: Text(_ru ? 'Позже' : 'Later'),
|
child: Text(strings.later),
|
||||||
),
|
),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -129,7 +129,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
|||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.download_rounded),
|
icon: const Icon(Icons.download_rounded),
|
||||||
label: Text(_ru ? 'Скачать и открыть' : 'Download & open'),
|
label: Text(strings.downloadAndOpen),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -137,11 +137,6 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
|||||||
ref.invalidate(appSettingsProvider);
|
ref.invalidate(appSettingsProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _ru =>
|
|
||||||
(ref.read(appSettingsProvider).value ?? AppSettings.defaults)
|
|
||||||
.languageCode ==
|
|
||||||
'ru';
|
|
||||||
|
|
||||||
Future<void> _downloadUpdate(AppUpdateInfo info) async {
|
Future<void> _downloadUpdate(AppUpdateInfo info) async {
|
||||||
final updateService = ref.read(updateServiceProvider);
|
final updateService = ref.read(updateServiceProvider);
|
||||||
final assetUrl = updateService.assetUrlForCurrentPlatform(info);
|
final assetUrl = updateService.assetUrlForCurrentPlatform(info);
|
||||||
@@ -225,6 +220,7 @@ class _DownloadPanel extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final strings = ref.watch(appStringsProvider);
|
||||||
return Material(
|
return Material(
|
||||||
elevation: 12,
|
elevation: 12,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -250,7 +246,7 @@ class _DownloadPanel extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (task.status == DownloadTaskStatus.failed)
|
if (task.status == DownloadTaskStatus.failed)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Retry',
|
tooltip: strings.retry,
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(downloadManagerServiceProvider)
|
.read(downloadManagerServiceProvider)
|
||||||
.retry(task.id),
|
.retry(task.id),
|
||||||
@@ -304,6 +300,11 @@ final appSettingsProvider = FutureProvider<AppSettings>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final appStringsProvider = Provider<AppStrings>((ref) {
|
||||||
|
final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
return AppStrings(settings.languageCode);
|
||||||
|
});
|
||||||
|
|
||||||
class _StartupLoadingScreen extends StatelessWidget {
|
class _StartupLoadingScreen extends StatelessWidget {
|
||||||
const _StartupLoadingScreen();
|
const _StartupLoadingScreen();
|
||||||
|
|
||||||
@@ -330,6 +331,7 @@ class _StartupErrorScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final strings = ref.watch(appStringsProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -341,7 +343,9 @@ class _StartupErrorScreen extends ConsumerWidget {
|
|||||||
const Icon(Icons.storage_rounded, size: 48),
|
const Icon(Icons.storage_rounded, size: 48),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Could not start local database',
|
strings.ru
|
||||||
|
? 'Не удалось открыть локальную базу'
|
||||||
|
: 'Could not start local database',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -356,7 +360,7 @@ class _StartupErrorScreen extends ConsumerWidget {
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => ref.invalidate(appDatabaseProvider),
|
onPressed: () => ref.invalidate(appDatabaseProvider),
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Retry'),
|
label: Text(strings.retry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
class AppStrings {
|
||||||
|
const AppStrings(this.languageCode);
|
||||||
|
|
||||||
|
final String languageCode;
|
||||||
|
bool get ru => languageCode == 'ru';
|
||||||
|
|
||||||
|
String get feed => ru ? 'Лента' : 'Feed';
|
||||||
|
String get search => ru ? 'Поиск' : 'Search';
|
||||||
|
String get favorites => ru ? 'Избранное' : 'Favorites';
|
||||||
|
String get viewed => ru ? 'История' : 'Viewed';
|
||||||
|
String get collections => ru ? 'Коллекции' : 'Collections';
|
||||||
|
String get boardsShort => ru ? 'Доски' : 'Boards';
|
||||||
|
String get artists => ru ? 'Артисты' : 'Artists';
|
||||||
|
String get providers => ru ? 'Провайдеры' : 'Providers';
|
||||||
|
String get settings => ru ? 'Настройки' : 'Settings';
|
||||||
|
String get post => ru ? 'Пост' : 'Post';
|
||||||
|
String get close => ru ? 'Закрыть' : 'Close';
|
||||||
|
String get retry => ru ? 'Повторить' : 'Retry';
|
||||||
|
String get tags => ru ? 'Теги' : 'Tags';
|
||||||
|
String get comments => ru ? 'Комментарии' : 'Comments';
|
||||||
|
String get commentsUnavailable =>
|
||||||
|
ru ? 'Комментарии недоступны' : 'Comments unavailable';
|
||||||
|
String get noComments => ru ? 'Комментариев нет' : 'No comments';
|
||||||
|
String get anonymous => ru ? 'Аноним' : 'Anonymous';
|
||||||
|
String get source => ru ? 'Источник' : 'Source';
|
||||||
|
String get similar => ru ? 'Похожие' : 'Similar';
|
||||||
|
String get favorite => ru ? 'В избранное' : 'Favorite';
|
||||||
|
String get removeFavorite => ru ? 'Убрать из избранного' : 'Remove favorite';
|
||||||
|
String get collection => ru ? 'Коллекция' : 'Collection';
|
||||||
|
String get addToCollection =>
|
||||||
|
ru ? 'Добавить в коллекцию' : 'Add to collection';
|
||||||
|
String get download => ru ? 'Скачать' : 'Download';
|
||||||
|
String get deleteLocalFile =>
|
||||||
|
ru ? 'Удалить локальный файл' : 'Delete local file';
|
||||||
|
String get downloaded => ru ? 'Скачано' : 'Downloaded';
|
||||||
|
String get downloading => ru ? 'Скачивается' : 'Downloading';
|
||||||
|
String get failed => ru ? 'Ошибка' : 'Failed';
|
||||||
|
String get open => ru ? 'Открыть' : 'Open';
|
||||||
|
String get hidePost => ru ? 'Скрыть пост' : 'Hide post';
|
||||||
|
String get hiddenLocally =>
|
||||||
|
ru ? 'Пост скрыт локально' : 'Post hidden locally';
|
||||||
|
String get undo => ru ? 'Отменить' : 'Undo';
|
||||||
|
String get noPostsYet => ru ? 'Постов пока нет' : 'No posts yet';
|
||||||
|
String get tryAnotherTag => ru
|
||||||
|
? 'Попробуй другой тег или проверь провайдеры.'
|
||||||
|
: 'Try another tag or check providers.';
|
||||||
|
String get searchTags => ru ? 'Поиск тегов' : 'Search tags';
|
||||||
|
String get selected => ru ? 'выбрано' : 'selected';
|
||||||
|
String get createCollection => ru ? 'Создать коллекцию' : 'Create collection';
|
||||||
|
String get noFavorites => ru ? 'Избранного пока нет' : 'No favorites yet';
|
||||||
|
String get general => ru ? 'Основное' : 'General';
|
||||||
|
String get appearance => ru ? 'Внешний вид' : 'Appearance';
|
||||||
|
String get feedLayout => ru ? 'Лента и раскладка' : 'Feed & Layout';
|
||||||
|
String get filters => ru ? 'Фильтры' : 'Filters';
|
||||||
|
String get storage => ru ? 'Хранилище' : 'Storage';
|
||||||
|
String get about => ru ? 'О приложении' : 'About';
|
||||||
|
String get theme => ru ? 'Тема' : 'Theme';
|
||||||
|
String get language => ru ? 'Язык' : 'Language';
|
||||||
|
String get allowNsfw => ru ? 'Показывать NSFW' : 'Allow NSFW content';
|
||||||
|
String get blurSensitive =>
|
||||||
|
ru ? 'Размывать чувствительные превью' : 'Blur sensitive previews';
|
||||||
|
String get showPostBadges =>
|
||||||
|
ru ? 'Показывать подписи на карточках' : 'Show post badges';
|
||||||
|
String get allowDownloads =>
|
||||||
|
ru ? 'Разрешить ручные скачивания' : 'Allow manual downloads';
|
||||||
|
String get autoDownloadFavorites => ru
|
||||||
|
? 'Автоскачивание при добавлении в избранное'
|
||||||
|
: 'Auto-download new favorites';
|
||||||
|
String get autoDownloadFavoritesHint => ru
|
||||||
|
? 'Избранное добавляется сразу, а файл качается в фоне.'
|
||||||
|
: 'The favorite is saved immediately while the file downloads in the background.';
|
||||||
|
String get mediaQuality => ru ? 'Качество медиа' : 'Media quality';
|
||||||
|
String get desktopColumns => ru ? 'Колонки на ПК' : 'Desktop columns';
|
||||||
|
String get mobileColumns => ru ? 'Колонки на телефоне' : 'Mobile columns';
|
||||||
|
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 clearViewed => ru ? 'Очистить историю' : 'Clear viewed history';
|
||||||
|
String get exportJson => ru ? 'Экспорт JSON' : 'Export JSON';
|
||||||
|
String get importJson => ru ? 'Импорт JSON' : 'Import JSON';
|
||||||
|
String get checkUpdates => ru ? 'Проверить обновления' : 'Check updates';
|
||||||
|
String get appUpdateAvailable =>
|
||||||
|
ru ? 'Доступно обновление' : 'Update available';
|
||||||
|
String get skipThisVersion => ru ? 'Пропустить версию' : 'Skip this version';
|
||||||
|
String get later => ru ? 'Позже' : 'Later';
|
||||||
|
String get downloadAndOpen => ru ? 'Скачать и открыть' : 'Download & open';
|
||||||
|
String get couldNotLoadImage =>
|
||||||
|
ru ? 'Не удалось загрузить изображение' : 'Could not load image';
|
||||||
|
String get couldNotLoadVideo =>
|
||||||
|
ru ? 'Не удалось загрузить видео' : 'Could not load video';
|
||||||
|
String get play => ru ? 'Воспроизвести' : 'Play';
|
||||||
|
String get pause => ru ? 'Пауза' : 'Pause';
|
||||||
|
String get mute => ru ? 'Выключить звук' : 'Mute';
|
||||||
|
String get unmute => ru ? 'Включить звук' : 'Unmute';
|
||||||
|
String get repeatVideo => ru ? 'Повторять видео' : 'Repeat video';
|
||||||
|
String get disableRepeat => ru ? 'Отключить повтор' : 'Disable repeat';
|
||||||
|
String get fullscreen => ru ? 'На весь экран' : 'Fullscreen';
|
||||||
|
String get exitFullscreen =>
|
||||||
|
ru ? 'Выйти из полного экрана' : 'Exit fullscreen';
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.0';
|
const appDisplayVersion = '2.0.1';
|
||||||
const appBuildNumber = 20;
|
const appBuildNumber = 21;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.1',
|
||||||
|
title: 'Localization, zoom and favorite downloads',
|
||||||
|
bullets: [
|
||||||
|
'Russian and English UI labels were expanded across the main screens and settings.',
|
||||||
|
'Mobile image details now support pinch and double-tap zoom without restoring desktop wheel zoom.',
|
||||||
|
'Provider chips in the feed update immediately and debounce network reloads to avoid stale results.',
|
||||||
|
'Feed previews choose smarter URLs and cache widths for cleaner high-quality thumbnails.',
|
||||||
|
'Favorites can optionally auto-download media in the background, with local files tracked separately from favorites.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
title: 'Customization and provider polish',
|
title: 'Customization and provider polish',
|
||||||
|
|||||||
+32
-1
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../backend/backend.dart';
|
import '../backend/backend.dart';
|
||||||
@@ -27,7 +28,8 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
pageBuilder: (context, state) => NoTransitionPage(
|
pageBuilder: (context, state) => _transitionPage(
|
||||||
|
state,
|
||||||
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
|
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -108,3 +110,32 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
CustomTransitionPage<void> _transitionPage(
|
||||||
|
GoRouterState state, {
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<void>(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: child,
|
||||||
|
transitionDuration: const Duration(milliseconds: 180),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 140),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
final curve = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: curve,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0.015, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curve),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export 'models/app_update_info.dart';
|
|||||||
export 'models/artist_profile.dart';
|
export 'models/artist_profile.dart';
|
||||||
export 'models/artist_work_query.dart';
|
export 'models/artist_work_query.dart';
|
||||||
export 'models/download_task.dart';
|
export 'models/download_task.dart';
|
||||||
|
export 'models/downloaded_media.dart';
|
||||||
export 'models/favorite.dart';
|
export 'models/favorite.dart';
|
||||||
export 'models/post.dart';
|
export 'models/post.dart';
|
||||||
export 'models/post_comment.dart';
|
export 'models/post_comment.dart';
|
||||||
@@ -19,6 +20,7 @@ export 'providers/provider_manager.dart';
|
|||||||
export 'services/collection_service.dart';
|
export 'services/collection_service.dart';
|
||||||
export 'services/download_service.dart';
|
export 'services/download_service.dart';
|
||||||
export 'services/download_manager_service.dart';
|
export 'services/download_manager_service.dart';
|
||||||
|
export 'services/downloaded_media_service.dart';
|
||||||
export 'services/favorite_service.dart';
|
export 'services/favorite_service.dart';
|
||||||
export 'services/feed_service.dart';
|
export 'services/feed_service.dart';
|
||||||
export 'services/provider_check_service.dart';
|
export 'services/provider_check_service.dart';
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import '../../core/utils/result.dart';
|
|||||||
import '../providers/provider_factory.dart';
|
import '../providers/provider_factory.dart';
|
||||||
import '../providers/provider_manager.dart';
|
import '../providers/provider_manager.dart';
|
||||||
import '../models/download_task.dart';
|
import '../models/download_task.dart';
|
||||||
|
import '../models/downloaded_media.dart';
|
||||||
import '../repositories/collection_repository.dart';
|
import '../repositories/collection_repository.dart';
|
||||||
|
import '../repositories/downloaded_media_repository.dart';
|
||||||
import '../repositories/favorite_repository.dart';
|
import '../repositories/favorite_repository.dart';
|
||||||
import '../repositories/post_repository.dart';
|
import '../repositories/post_repository.dart';
|
||||||
import '../repositories/provider_repository.dart';
|
import '../repositories/provider_repository.dart';
|
||||||
@@ -20,6 +22,7 @@ import '../repositories/viewed_post_repository.dart';
|
|||||||
import '../services/collection_service.dart';
|
import '../services/collection_service.dart';
|
||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
import '../services/download_manager_service.dart';
|
import '../services/download_manager_service.dart';
|
||||||
|
import '../services/downloaded_media_service.dart';
|
||||||
import '../services/favorite_service.dart';
|
import '../services/favorite_service.dart';
|
||||||
import '../services/feed_service.dart';
|
import '../services/feed_service.dart';
|
||||||
import '../services/provider_check_service.dart';
|
import '../services/provider_check_service.dart';
|
||||||
@@ -65,6 +68,11 @@ final viewedPostRepositoryProvider = Provider<ViewedPostRepository>((ref) {
|
|||||||
return ViewedPostRepository(ref.watch(databaseServiceProvider));
|
return ViewedPostRepository(ref.watch(databaseServiceProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final downloadedMediaRepositoryProvider =
|
||||||
|
Provider<DownloadedMediaRepository>((ref) {
|
||||||
|
return DownloadedMediaRepository(ref.watch(databaseServiceProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
|
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
|
||||||
return FavoriteRepository(
|
return FavoriteRepository(
|
||||||
ref.watch(databaseServiceProvider),
|
ref.watch(databaseServiceProvider),
|
||||||
@@ -152,8 +160,32 @@ final downloadServiceProvider = Provider<DownloadService>((ref) {
|
|||||||
return DownloadService();
|
return DownloadService();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final downloadedMediaServiceProvider = Provider<DownloadedMediaService>((ref) {
|
||||||
|
return DownloadedMediaService(ref.watch(downloadedMediaRepositoryProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final downloadManagerServiceProvider = Provider<DownloadManagerService>((ref) {
|
final downloadManagerServiceProvider = Provider<DownloadManagerService>((ref) {
|
||||||
return DownloadManagerService(ref.watch(downloadServiceProvider));
|
return DownloadManagerService(
|
||||||
|
ref.watch(downloadServiceProvider),
|
||||||
|
downloadedMediaService: ref.watch(downloadedMediaServiceProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final downloadedMediaByKeysProvider =
|
||||||
|
FutureProvider.family<Map<String, DownloadedMedia>, Iterable<String>>(
|
||||||
|
(ref, keys) async {
|
||||||
|
final result =
|
||||||
|
await ref.watch(downloadedMediaServiceProvider).allByKeys(keys);
|
||||||
|
return result is Success<Map<String, DownloadedMedia>>
|
||||||
|
? result.data
|
||||||
|
: const {};
|
||||||
|
});
|
||||||
|
|
||||||
|
final downloadedMediaByKeyProvider =
|
||||||
|
FutureProvider.family<DownloadedMedia?, String>((ref, cacheKey) async {
|
||||||
|
final result =
|
||||||
|
await ref.watch(downloadedMediaServiceProvider).getByCacheKey(cacheKey);
|
||||||
|
return result is Success<DownloadedMedia?> ? result.data : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
|
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
|
||||||
@@ -168,7 +200,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
|
|||||||
connectTimeout: const Duration(seconds: 12),
|
connectTimeout: const Duration(seconds: 12),
|
||||||
receiveTimeout: const Duration(seconds: 12),
|
receiveTimeout: const Duration(seconds: 12),
|
||||||
headers: const {
|
headers: const {
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class DownloadedMedia {
|
||||||
|
const DownloadedMedia({
|
||||||
|
required this.cacheKey,
|
||||||
|
required this.providerId,
|
||||||
|
required this.postId,
|
||||||
|
required this.savedPath,
|
||||||
|
required this.fileName,
|
||||||
|
required this.downloadedAt,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String cacheKey;
|
||||||
|
final String providerId;
|
||||||
|
final String postId;
|
||||||
|
final String savedPath;
|
||||||
|
final String fileName;
|
||||||
|
final DateTime downloadedAt;
|
||||||
|
final String status;
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ class KemonoProvider
|
|||||||
receiveTimeout: const Duration(seconds: 20),
|
receiveTimeout: const Duration(seconds: 20),
|
||||||
headers: const {
|
headers: const {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -458,7 +458,7 @@ class KemonoProvider
|
|||||||
receiveTimeout: const Duration(seconds: 25),
|
receiveTimeout: const Duration(seconds: 25),
|
||||||
headers: const {
|
headers: const {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../../core/database/app_database.dart';
|
||||||
|
import '../../core/database/database_service.dart';
|
||||||
|
import '../../core/utils/result.dart';
|
||||||
|
import '../models/downloaded_media.dart';
|
||||||
|
import '../models/post.dart';
|
||||||
|
|
||||||
|
class DownloadedMediaRepository {
|
||||||
|
DownloadedMediaRepository(this._databaseService);
|
||||||
|
|
||||||
|
final DatabaseService _databaseService;
|
||||||
|
|
||||||
|
Future<Result<void>> markDownloaded(
|
||||||
|
Post post, {
|
||||||
|
required String savedPath,
|
||||||
|
required String fileName,
|
||||||
|
String status = 'downloaded',
|
||||||
|
}) {
|
||||||
|
return _databaseService.safeWrite((isar) async {
|
||||||
|
await isar.downloadedMediaEntitys.put(
|
||||||
|
DownloadedMediaEntity.fromModel(
|
||||||
|
DownloadedMedia(
|
||||||
|
cacheKey: post.cacheKey,
|
||||||
|
providerId: post.providerId,
|
||||||
|
postId: post.id,
|
||||||
|
savedPath: savedPath,
|
||||||
|
fileName: fileName,
|
||||||
|
downloadedAt: DateTime.now(),
|
||||||
|
status: status,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<DownloadedMedia?>> getByCacheKey(String cacheKey) {
|
||||||
|
return _databaseService.safeRead((isar) async {
|
||||||
|
final entity = await isar.downloadedMediaEntitys
|
||||||
|
.filter()
|
||||||
|
.cacheKeyEqualTo(cacheKey)
|
||||||
|
.findFirst();
|
||||||
|
return entity?.toModel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<Map<String, DownloadedMedia>>> allByKeys(
|
||||||
|
Iterable<String> cacheKeys,
|
||||||
|
) {
|
||||||
|
return _databaseService.safeRead((isar) async {
|
||||||
|
final result = <String, DownloadedMedia>{};
|
||||||
|
for (final key in cacheKeys) {
|
||||||
|
final entity = await isar.downloadedMediaEntitys
|
||||||
|
.filter()
|
||||||
|
.cacheKeyEqualTo(key)
|
||||||
|
.findFirst();
|
||||||
|
if (entity != null) result[key] = entity.toModel();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> deleteRecord(String cacheKey) {
|
||||||
|
return _databaseService.safeWrite((isar) async {
|
||||||
|
await isar.downloadedMediaEntitys
|
||||||
|
.filter()
|
||||||
|
.cacheKeyEqualTo(cacheKey)
|
||||||
|
.deleteAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> clearMissingFiles() {
|
||||||
|
return _databaseService.safeWrite((isar) async {
|
||||||
|
final items = await isar.downloadedMediaEntitys.where().findAll();
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.savedPath.isEmpty || !File(item.savedPath).existsSync()) {
|
||||||
|
await isar.downloadedMediaEntitys.delete(item.isarId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileNameForPath(String path) => p.basename(path);
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ class ProviderRepository {
|
|||||||
priority: 5,
|
priority: 5,
|
||||||
timeoutSeconds: 20,
|
timeoutSeconds: 20,
|
||||||
customHeaders: const {
|
customHeaders: const {
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -102,7 +102,7 @@ class ProviderRepository {
|
|||||||
priority: 6,
|
priority: 6,
|
||||||
timeoutSeconds: 20,
|
timeoutSeconds: 20,
|
||||||
customHeaders: const {
|
customHeaders: const {
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import '../models/download_task.dart';
|
|||||||
import '../models/post.dart';
|
import '../models/post.dart';
|
||||||
import '../utils/media_quality.dart';
|
import '../utils/media_quality.dart';
|
||||||
import 'download_service.dart';
|
import 'download_service.dart';
|
||||||
|
import 'downloaded_media_service.dart';
|
||||||
|
|
||||||
class DownloadManagerService {
|
class DownloadManagerService {
|
||||||
DownloadManagerService(this._downloadService);
|
DownloadManagerService(
|
||||||
|
this._downloadService, {
|
||||||
|
DownloadedMediaService? downloadedMediaService,
|
||||||
|
}) : _downloadedMediaService = downloadedMediaService;
|
||||||
|
|
||||||
final DownloadService _downloadService;
|
final DownloadService _downloadService;
|
||||||
|
final DownloadedMediaService? _downloadedMediaService;
|
||||||
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
||||||
final Map<String, DownloadTask> _tasks = {};
|
final Map<String, DownloadTask> _tasks = {};
|
||||||
|
|
||||||
@@ -97,6 +102,13 @@ class DownloadManagerService {
|
|||||||
savedPath: saved,
|
savedPath: saved,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (task.post != null && saved != null && saved.isNotEmpty) {
|
||||||
|
await _downloadedMediaService?.markDownloaded(
|
||||||
|
task.post!,
|
||||||
|
savedPath: saved,
|
||||||
|
fileName: task.fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
_scheduleAutoRemove(task.id, const Duration(seconds: 6));
|
_scheduleAutoRemove(task.id, const Duration(seconds: 6));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
final current = _tasks[task.id];
|
final current = _tasks[task.id];
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../../core/utils/result.dart';
|
||||||
|
import '../models/downloaded_media.dart';
|
||||||
|
import '../models/post.dart';
|
||||||
|
import '../repositories/downloaded_media_repository.dart';
|
||||||
|
|
||||||
|
class DownloadedMediaService {
|
||||||
|
DownloadedMediaService(this._repository);
|
||||||
|
|
||||||
|
final DownloadedMediaRepository _repository;
|
||||||
|
|
||||||
|
Future<Result<void>> markDownloaded(
|
||||||
|
Post post, {
|
||||||
|
required String savedPath,
|
||||||
|
required String fileName,
|
||||||
|
}) {
|
||||||
|
return _repository.markDownloaded(
|
||||||
|
post,
|
||||||
|
savedPath: savedPath,
|
||||||
|
fileName: fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<DownloadedMedia?>> getByCacheKey(String cacheKey) {
|
||||||
|
return _repository.getByCacheKey(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<Map<String, DownloadedMedia>>> allByKeys(
|
||||||
|
Iterable<String> cacheKeys,
|
||||||
|
) {
|
||||||
|
return _repository.allByKeys(cacheKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> deleteLocalFile(String cacheKey) async {
|
||||||
|
final result = await getByCacheKey(cacheKey);
|
||||||
|
if (result is Error<DownloadedMedia?>) return Error(result.failure);
|
||||||
|
final media = (result as Success<DownloadedMedia?>).data;
|
||||||
|
if (media != null && media.savedPath.isNotEmpty) {
|
||||||
|
final file = File(media.savedPath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _repository.deleteRecord(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import '../models/favorite.dart';
|
|||||||
import '../models/post.dart';
|
import '../models/post.dart';
|
||||||
import '../repositories/favorite_repository.dart';
|
import '../repositories/favorite_repository.dart';
|
||||||
import '../repositories/post_repository.dart';
|
import '../repositories/post_repository.dart';
|
||||||
|
import 'download_manager_service.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
class FavoriteService {
|
class FavoriteService {
|
||||||
FavoriteService(this._repository, [this._postRepository]);
|
FavoriteService(this._repository, [this._postRepository]);
|
||||||
@@ -10,7 +12,20 @@ class FavoriteService {
|
|||||||
final FavoriteRepository _repository;
|
final FavoriteRepository _repository;
|
||||||
final PostRepository? _postRepository;
|
final PostRepository? _postRepository;
|
||||||
|
|
||||||
Future<Result<void>> addFavorite(Post post) => _repository.add(post);
|
Future<Result<void>> addFavorite(
|
||||||
|
Post post, {
|
||||||
|
AppSettings settings = AppSettings.defaults,
|
||||||
|
DownloadManagerService? downloadManager,
|
||||||
|
}) async {
|
||||||
|
final result = await _repository.add(post);
|
||||||
|
if (result is Success<void> &&
|
||||||
|
settings.autoDownloadFavorites &&
|
||||||
|
settings.allowDownloads) {
|
||||||
|
await downloadManager?.start(post);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<void>> removeFavorite(String postId, String providerId) {
|
Future<Result<void>> removeFavorite(String postId, String providerId) {
|
||||||
return _repository.remove(postId, providerId);
|
return _repository.remove(postId, providerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class AppSettings {
|
|||||||
required this.mobileColumns,
|
required this.mobileColumns,
|
||||||
required this.blurExplicitContent,
|
required this.blurExplicitContent,
|
||||||
required this.allowDownloads,
|
required this.allowDownloads,
|
||||||
|
required this.autoDownloadFavorites,
|
||||||
required this.selectedFeedProviderIds,
|
required this.selectedFeedProviderIds,
|
||||||
required this.showPostBadges,
|
required this.showPostBadges,
|
||||||
required this.defaultTopPeriodFilter,
|
required this.defaultTopPeriodFilter,
|
||||||
@@ -66,6 +67,7 @@ class AppSettings {
|
|||||||
final int mobileColumns;
|
final int mobileColumns;
|
||||||
final bool blurExplicitContent;
|
final bool blurExplicitContent;
|
||||||
final bool allowDownloads;
|
final bool allowDownloads;
|
||||||
|
final bool autoDownloadFavorites;
|
||||||
final List<String> selectedFeedProviderIds;
|
final List<String> selectedFeedProviderIds;
|
||||||
final bool showPostBadges;
|
final bool showPostBadges;
|
||||||
final String defaultTopPeriodFilter;
|
final String defaultTopPeriodFilter;
|
||||||
@@ -106,6 +108,7 @@ class AppSettings {
|
|||||||
mobileColumns: 2,
|
mobileColumns: 2,
|
||||||
blurExplicitContent: true,
|
blurExplicitContent: true,
|
||||||
allowDownloads: true,
|
allowDownloads: true,
|
||||||
|
autoDownloadFavorites: false,
|
||||||
selectedFeedProviderIds: [],
|
selectedFeedProviderIds: [],
|
||||||
showPostBadges: true,
|
showPostBadges: true,
|
||||||
defaultTopPeriodFilter: 'none',
|
defaultTopPeriodFilter: 'none',
|
||||||
@@ -147,6 +150,7 @@ class AppSettings {
|
|||||||
int? mobileColumns,
|
int? mobileColumns,
|
||||||
bool? blurExplicitContent,
|
bool? blurExplicitContent,
|
||||||
bool? allowDownloads,
|
bool? allowDownloads,
|
||||||
|
bool? autoDownloadFavorites,
|
||||||
List<String>? selectedFeedProviderIds,
|
List<String>? selectedFeedProviderIds,
|
||||||
bool? showPostBadges,
|
bool? showPostBadges,
|
||||||
String? defaultTopPeriodFilter,
|
String? defaultTopPeriodFilter,
|
||||||
@@ -189,6 +193,8 @@ class AppSettings {
|
|||||||
mobileColumns: mobileColumns ?? this.mobileColumns,
|
mobileColumns: mobileColumns ?? this.mobileColumns,
|
||||||
blurExplicitContent: blurExplicitContent ?? this.blurExplicitContent,
|
blurExplicitContent: blurExplicitContent ?? this.blurExplicitContent,
|
||||||
allowDownloads: allowDownloads ?? this.allowDownloads,
|
allowDownloads: allowDownloads ?? this.allowDownloads,
|
||||||
|
autoDownloadFavorites:
|
||||||
|
autoDownloadFavorites ?? this.autoDownloadFavorites,
|
||||||
selectedFeedProviderIds:
|
selectedFeedProviderIds:
|
||||||
selectedFeedProviderIds ?? this.selectedFeedProviderIds,
|
selectedFeedProviderIds ?? this.selectedFeedProviderIds,
|
||||||
showPostBadges: showPostBadges ?? this.showPostBadges,
|
showPostBadges: showPostBadges ?? this.showPostBadges,
|
||||||
@@ -235,6 +241,7 @@ class AppSettings {
|
|||||||
'mobileColumns': mobileColumns,
|
'mobileColumns': mobileColumns,
|
||||||
'blurExplicitContent': blurExplicitContent,
|
'blurExplicitContent': blurExplicitContent,
|
||||||
'allowDownloads': allowDownloads,
|
'allowDownloads': allowDownloads,
|
||||||
|
'autoDownloadFavorites': autoDownloadFavorites,
|
||||||
'selectedFeedProviderIds': selectedFeedProviderIds,
|
'selectedFeedProviderIds': selectedFeedProviderIds,
|
||||||
'showPostBadges': showPostBadges,
|
'showPostBadges': showPostBadges,
|
||||||
'defaultTopPeriodFilter': defaultTopPeriodFilter,
|
'defaultTopPeriodFilter': defaultTopPeriodFilter,
|
||||||
@@ -290,6 +297,8 @@ class AppSettings {
|
|||||||
defaults.blurExplicitContent,
|
defaults.blurExplicitContent,
|
||||||
allowDownloads:
|
allowDownloads:
|
||||||
(json['allowDownloads'] as bool?) ?? defaults.allowDownloads,
|
(json['allowDownloads'] as bool?) ?? defaults.allowDownloads,
|
||||||
|
autoDownloadFavorites: (json['autoDownloadFavorites'] as bool?) ??
|
||||||
|
defaults.autoDownloadFavorites,
|
||||||
selectedFeedProviderIds: List<String>.from(
|
selectedFeedProviderIds: List<String>.from(
|
||||||
(json['selectedFeedProviderIds'] as List?) ??
|
(json['selectedFeedProviderIds'] as List?) ??
|
||||||
defaults.selectedFeedProviderIds,
|
defaults.selectedFeedProviderIds,
|
||||||
|
|||||||
@@ -24,8 +24,19 @@ enum MediaQualityMode {
|
|||||||
class MediaUrlSelector {
|
class MediaUrlSelector {
|
||||||
const MediaUrlSelector._();
|
const MediaUrlSelector._();
|
||||||
|
|
||||||
static List<String> feed(Post post) {
|
static List<String> feed(
|
||||||
return _compact([post.previewUrl, post.sampleUrl]);
|
Post post, {
|
||||||
|
MediaQualityMode mode = MediaQualityMode.auto,
|
||||||
|
bool mobile = false,
|
||||||
|
}) {
|
||||||
|
return switch (mode) {
|
||||||
|
MediaQualityMode.dataSaver => _compact([post.previewUrl, post.sampleUrl]),
|
||||||
|
MediaQualityMode.highQuality =>
|
||||||
|
_compact([post.sampleUrl, post.previewUrl]),
|
||||||
|
MediaQualityMode.auto => mobile
|
||||||
|
? _compact([post.previewUrl, post.sampleUrl])
|
||||||
|
: _compact([post.sampleUrl, post.previewUrl]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<String> preview(Post post) {
|
static List<String> preview(Post post) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../backend/models/provider_diagnostics.dart';
|
|||||||
import '../../backend/models/provider_health.dart';
|
import '../../backend/models/provider_health.dart';
|
||||||
import '../../backend/models/search_history.dart';
|
import '../../backend/models/search_history.dart';
|
||||||
import '../../backend/models/viewed_post.dart';
|
import '../../backend/models/viewed_post.dart';
|
||||||
|
import '../../backend/models/downloaded_media.dart';
|
||||||
|
|
||||||
part 'app_database.g.dart';
|
part 'app_database.g.dart';
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ class AppDatabase {
|
|||||||
AppSettingEntitySchema,
|
AppSettingEntitySchema,
|
||||||
ViewedPostEntitySchema,
|
ViewedPostEntitySchema,
|
||||||
ProviderDiagnosticsEntitySchema,
|
ProviderDiagnosticsEntitySchema,
|
||||||
|
DownloadedMediaEntitySchema,
|
||||||
],
|
],
|
||||||
directory: dir,
|
directory: dir,
|
||||||
name: 'gel_rule_app',
|
name: 'gel_rule_app',
|
||||||
@@ -333,3 +335,39 @@ class ProviderDiagnosticsEntity {
|
|||||||
..lastErrorMessage = model.lastErrorMessage;
|
..lastErrorMessage = model.lastErrorMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@collection
|
||||||
|
class DownloadedMediaEntity {
|
||||||
|
Id isarId = Isar.autoIncrement;
|
||||||
|
|
||||||
|
@Index(unique: true, replace: true)
|
||||||
|
late String cacheKey;
|
||||||
|
@Index(composite: [CompositeIndex('postId')])
|
||||||
|
late String providerId;
|
||||||
|
late String postId;
|
||||||
|
late String savedPath;
|
||||||
|
late String fileName;
|
||||||
|
late DateTime downloadedAt;
|
||||||
|
late String status;
|
||||||
|
|
||||||
|
DownloadedMedia toModel() => DownloadedMedia(
|
||||||
|
cacheKey: cacheKey,
|
||||||
|
providerId: providerId,
|
||||||
|
postId: postId,
|
||||||
|
savedPath: savedPath,
|
||||||
|
fileName: fileName,
|
||||||
|
downloadedAt: downloadedAt,
|
||||||
|
status: status,
|
||||||
|
);
|
||||||
|
|
||||||
|
static DownloadedMediaEntity fromModel(DownloadedMedia model) {
|
||||||
|
return DownloadedMediaEntity()
|
||||||
|
..cacheKey = model.cacheKey
|
||||||
|
..providerId = model.providerId
|
||||||
|
..postId = model.postId
|
||||||
|
..savedPath = model.savedPath
|
||||||
|
..fileName = model.fileName
|
||||||
|
..downloadedAt = model.downloadedAt
|
||||||
|
..status = model.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ class DioClient {
|
|||||||
receiveTimeout: timeout,
|
receiveTimeout: timeout,
|
||||||
sendTimeout: timeout,
|
sendTimeout: timeout,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8',
|
'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8',
|
||||||
...?headers,
|
...?headers,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ class ArtistPostsScreen extends ConsumerWidget {
|
|||||||
blurExplicit: settings.blurExplicitContent,
|
blurExplicit: settings.blurExplicitContent,
|
||||||
showBadges: settings.showPostBadges,
|
showBadges: settings.showPostBadges,
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode:
|
||||||
|
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||||
favoriteKeys: favoriteKeys,
|
favoriteKeys: favoriteKeys,
|
||||||
viewedKeys: viewedKeys,
|
viewedKeys: viewedKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
@@ -94,7 +96,12 @@ class ArtistPostsScreen extends ConsumerWidget {
|
|||||||
.read(favoriteServiceProvider)
|
.read(favoriteServiceProvider)
|
||||||
.removeFavorite(post.id, post.providerId);
|
.removeFavorite(post.id, post.providerId);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
await ref.read(favoriteServiceProvider).addFavorite(
|
||||||
|
post,
|
||||||
|
settings: settings,
|
||||||
|
downloadManager:
|
||||||
|
ref.read(downloadManagerServiceProvider),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
ref.invalidate(favoritesControllerProvider);
|
ref.invalidate(favoritesControllerProvider);
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
blurExplicit: settings.blurExplicitContent,
|
blurExplicit: settings.blurExplicitContent,
|
||||||
showBadges: settings.showPostBadges,
|
showBadges: settings.showPostBadges,
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode:
|
||||||
|
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||||
favoriteKeys: favoriteKeys,
|
favoriteKeys: favoriteKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
@@ -57,7 +59,12 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
.read(favoriteServiceProvider)
|
.read(favoriteServiceProvider)
|
||||||
.removeFavorite(post.id, post.providerId);
|
.removeFavorite(post.id, post.providerId);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
await ref.read(favoriteServiceProvider).addFavorite(
|
||||||
|
post,
|
||||||
|
settings: settings,
|
||||||
|
downloadManager:
|
||||||
|
ref.read(downloadManagerServiceProvider),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
ref.invalidate(favoritesControllerProvider);
|
ref.invalidate(favoritesControllerProvider);
|
||||||
|
|||||||
@@ -19,8 +19,15 @@ class FavoritesScreen extends ConsumerWidget {
|
|||||||
final state = ref.watch(favoritesControllerProvider);
|
final state = ref.watch(favoritesControllerProvider);
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final posts = state.value?.posts ?? const <Post>[];
|
||||||
|
final downloaded = ref
|
||||||
|
.watch(downloadedMediaByKeysProvider(
|
||||||
|
posts.map((post) => post.cacheKey).toList(growable: false),
|
||||||
|
))
|
||||||
|
.value ??
|
||||||
|
const <String, DownloadedMedia>{};
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Favorites',
|
title: settings.languageCode == 'ru' ? 'Избранное' : 'Favorites',
|
||||||
body: state.when(
|
body: state.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
@@ -36,7 +43,10 @@ class FavoritesScreen extends ConsumerWidget {
|
|||||||
blurExplicit: settings.blurExplicitContent,
|
blurExplicit: settings.blurExplicitContent,
|
||||||
showBadges: settings.showPostBadges,
|
showBadges: settings.showPostBadges,
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode:
|
||||||
|
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||||
favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(),
|
favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(),
|
||||||
|
downloadedKeys: downloaded.keys.toSet(),
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
extra: post,
|
extra: post,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../app/app.dart';
|
import '../../../app/app.dart';
|
||||||
@@ -9,8 +11,12 @@ final feedControllerProvider =
|
|||||||
AsyncNotifierProvider<FeedController, FeedState>(FeedController.new);
|
AsyncNotifierProvider<FeedController, FeedState>(FeedController.new);
|
||||||
|
|
||||||
class FeedController extends AsyncNotifier<FeedState> {
|
class FeedController extends AsyncNotifier<FeedState> {
|
||||||
|
Timer? _providerDebounce;
|
||||||
|
int _loadRequestId = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<FeedState> build() async {
|
Future<FeedState> build() async {
|
||||||
|
ref.onDispose(() => _providerDebounce?.cancel());
|
||||||
final providers = await _loadProviders();
|
final providers = await _loadProviders();
|
||||||
final settings = await _settings();
|
final settings = await _settings();
|
||||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||||
@@ -32,23 +38,29 @@ class FeedController extends AsyncNotifier<FeedState> {
|
|||||||
|
|
||||||
Future<void> loadInitial() async {
|
Future<void> loadInitial() async {
|
||||||
final current = state.value ?? const FeedState();
|
final current = state.value ?? const FeedState();
|
||||||
|
final requestId = ++_loadRequestId;
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
final posts = await _load(refresh: true, current: current);
|
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, clearError: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
final current = state.value ?? const FeedState();
|
final current = state.value ?? const FeedState();
|
||||||
|
final requestId = ++_loadRequestId;
|
||||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||||
final posts = await _load(refresh: true, current: current);
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadNextPage() async {
|
Future<void> loadNextPage() async {
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||||
|
final requestId = ++_loadRequestId;
|
||||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||||
final posts = await _load(refresh: false, current: current);
|
final posts = await _load(refresh: false, current: current);
|
||||||
|
if (requestId != _loadRequestId) return;
|
||||||
final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
|
final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
|
||||||
final newPosts = posts
|
final newPosts = posts
|
||||||
.where((post) => existingKeys.add(post.cacheKey))
|
.where((post) => existingKeys.add(post.cacheKey))
|
||||||
@@ -98,16 +110,21 @@ class FeedController extends AsyncNotifier<FeedState> {
|
|||||||
|
|
||||||
Future<void> setProviders(List<String> providerIds) async {
|
Future<void> setProviders(List<String> providerIds) async {
|
||||||
final current = state.value ?? const FeedState();
|
final current = state.value ?? const FeedState();
|
||||||
state = AsyncData(
|
final enabledIds = current.providers.map((provider) => provider.id).toSet();
|
||||||
current.copyWith(selectedProviderIds: providerIds, posts: []));
|
final selected = providerIds.where(enabledIds.contains).toList();
|
||||||
|
state =
|
||||||
|
AsyncData(current.copyWith(selectedProviderIds: selected, posts: []));
|
||||||
final settings = await _settings();
|
final settings = await _settings();
|
||||||
await ref.read(settingsServiceProvider).updateSettings(
|
await ref.read(settingsServiceProvider).updateSettings(
|
||||||
settings.copyWith(
|
settings.copyWith(
|
||||||
selectedFeedProviderIds: providerIds,
|
selectedFeedProviderIds: selected,
|
||||||
lastFeedProviderIds: providerIds,
|
lastFeedProviderIds: selected,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await refresh();
|
_providerDebounce?.cancel();
|
||||||
|
_providerDebounce = Timer(const Duration(milliseconds: 320), () {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setTopPeriod(TopPeriodFilter period) async {
|
Future<void> setTopPeriod(TopPeriodFilter period) async {
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
|||||||
blurExplicit: settings.blurExplicitContent,
|
blurExplicit: settings.blurExplicitContent,
|
||||||
showBadges: settings.showPostBadges,
|
showBadges: settings.showPostBadges,
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode: MediaQualityMode.fromName(
|
||||||
|
settings.mediaQualityMode),
|
||||||
loading: state.isLoadingMore,
|
loading: state.isLoadingMore,
|
||||||
favoriteKeys: favoriteKeys,
|
favoriteKeys: favoriteKeys,
|
||||||
viewedKeys: viewedKeys,
|
viewedKeys: viewedKeys,
|
||||||
@@ -309,6 +311,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
|||||||
for (final post in _selectedPosts(posts)) {
|
for (final post in _selectedPosts(posts)) {
|
||||||
if (!favoriteKeys.contains(post.cacheKey)) {
|
if (!favoriteKeys.contains(post.cacheKey)) {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||||
|
await _maybeAutoDownloadFavorite(ref, post);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
@@ -398,6 +401,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
|||||||
.removeFavorite(post.id, post.providerId);
|
.removeFavorite(post.id, post.providerId);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||||
|
await _maybeAutoDownloadFavorite(ref, post);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
ref.invalidate(favoritesControllerProvider);
|
ref.invalidate(favoritesControllerProvider);
|
||||||
@@ -426,6 +430,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
|
||||||
|
final settings =
|
||||||
|
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
|
||||||
|
await ref.read(downloadManagerServiceProvider).start(post);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RefreshIntent extends Intent {
|
class _RefreshIntent extends Intent {
|
||||||
@@ -448,13 +459,17 @@ class _FeedTitle extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Builder(builder: (context) {
|
||||||
'Feed',
|
return Consumer(
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
builder: (context, ref, _) => Text(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
ref.watch(appStringsProvider).feed,
|
||||||
fontWeight: FontWeight.w600,
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../app/app.dart';
|
import '../../../app/app.dart';
|
||||||
|
import '../../../app/app_strings.dart';
|
||||||
import '../../../app/motion.dart';
|
import '../../../app/motion.dart';
|
||||||
import '../../../app/responsive.dart';
|
import '../../../app/responsive.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
@@ -55,14 +56,15 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
final post = ref.watch(postDetailsControllerProvider(args));
|
final post = ref.watch(postDetailsControllerProvider(args));
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final strings = ref.watch(appStringsProvider);
|
||||||
final feedPosts =
|
final feedPosts =
|
||||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Post',
|
title: strings.post,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Close',
|
tooltip: strings.close,
|
||||||
onPressed: () => _close(context),
|
onPressed: () => _close(context),
|
||||||
icon: const Icon(Icons.close_rounded),
|
icon: const Icon(Icons.close_rounded),
|
||||||
),
|
),
|
||||||
@@ -99,6 +101,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
settings,
|
settings,
|
||||||
favoriteKeys,
|
favoriteKeys,
|
||||||
qualityMode,
|
qualityMode,
|
||||||
|
strings,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,6 +112,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
settings,
|
settings,
|
||||||
favoriteKeys,
|
favoriteKeys,
|
||||||
qualityMode,
|
qualityMode,
|
||||||
|
strings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Shortcuts(
|
return Shortcuts(
|
||||||
@@ -174,7 +178,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Previous',
|
tooltip:
|
||||||
|
strings.ru ? 'Предыдущий' : 'Previous',
|
||||||
onPressed: previous == null
|
onPressed: previous == null
|
||||||
? null
|
? null
|
||||||
: () => _openPost(context, previous),
|
: () => _openPost(context, previous),
|
||||||
@@ -200,7 +205,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Next',
|
tooltip: strings.ru ? 'Следующий' : 'Next',
|
||||||
onPressed: next == null
|
onPressed: next == null
|
||||||
? null
|
? null
|
||||||
: () => _openPost(context, next),
|
: () => _openPost(context, next),
|
||||||
@@ -219,6 +224,12 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
PostActionBar(
|
PostActionBar(
|
||||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||||
|
labels: _postActionLabels(strings),
|
||||||
|
downloaded: ref
|
||||||
|
.watch(
|
||||||
|
downloadedMediaByKeyProvider(post.cacheKey))
|
||||||
|
.value !=
|
||||||
|
null,
|
||||||
onFavorite: () =>
|
onFavorite: () =>
|
||||||
_toggleFavorite(ref, post, favoriteKeys),
|
_toggleFavorite(ref, post, favoriteKeys),
|
||||||
onCollection: () => _addToCollection(context, ref, post),
|
onCollection: () => _addToCollection(context, ref, post),
|
||||||
@@ -230,6 +241,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
onDownload: settings.allowDownloads
|
onDownload: settings.allowDownloads
|
||||||
? () => _download(context, ref, post)
|
? () => _download(context, ref, post)
|
||||||
: null,
|
: null,
|
||||||
|
onDeleteLocalFile: () =>
|
||||||
|
_deleteLocalFile(context, ref, post),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Wrap(
|
Wrap(
|
||||||
@@ -242,13 +255,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
Chip(label: Text('${post.width} x ${post.height}')),
|
Chip(label: Text('${post.width} x ${post.height}')),
|
||||||
if (post.source != null && post.source!.isNotEmpty)
|
if (post.source != null && post.source!.isNotEmpty)
|
||||||
ActionChip(
|
ActionChip(
|
||||||
label: const Text('Source'),
|
label: Text(strings.source),
|
||||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
|
Text(strings.tags,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
PostTagsPanel(post: post),
|
PostTagsPanel(post: post),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -270,6 +284,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
AppSettings settings,
|
AppSettings settings,
|
||||||
Set<String> favoriteKeys,
|
Set<String> favoriteKeys,
|
||||||
MediaQualityMode qualityMode,
|
MediaQualityMode qualityMode,
|
||||||
|
AppStrings strings,
|
||||||
) {
|
) {
|
||||||
final isVideo = MediaUrlSelector.isVideo(post);
|
final isVideo = MediaUrlSelector.isVideo(post);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -310,6 +325,10 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
PostActionBar(
|
PostActionBar(
|
||||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||||
|
labels: _postActionLabels(strings),
|
||||||
|
downloaded:
|
||||||
|
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value !=
|
||||||
|
null,
|
||||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||||
onCollection: () => _addToCollection(context, ref, post),
|
onCollection: () => _addToCollection(context, ref, post),
|
||||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||||
@@ -319,6 +338,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
onDownload: settings.allowDownloads
|
onDownload: settings.allowDownloads
|
||||||
? () => _download(context, ref, post)
|
? () => _download(context, ref, post)
|
||||||
: null,
|
: null,
|
||||||
|
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
@@ -330,7 +350,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
Chip(label: Text('${post.width} x ${post.height}')),
|
Chip(label: Text('${post.width} x ${post.height}')),
|
||||||
if (post.source != null && post.source!.isNotEmpty)
|
if (post.source != null && post.source!.isNotEmpty)
|
||||||
ActionChip(
|
ActionChip(
|
||||||
label: const Text('Source'),
|
label: Text(strings.source),
|
||||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -339,7 +359,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
ExpansionTile(
|
ExpansionTile(
|
||||||
tilePadding: EdgeInsets.zero,
|
tilePadding: EdgeInsets.zero,
|
||||||
initiallyExpanded: false,
|
initiallyExpanded: false,
|
||||||
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
|
title: Text(strings.tags,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
@@ -378,9 +399,17 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await ref.read(downloadManagerServiceProvider).start(post);
|
await ref.read(downloadManagerServiceProvider).start(post);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
final strings = ref.read(appStringsProvider);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Download started')),
|
SnackBar(
|
||||||
|
content: Text(strings.ru
|
||||||
|
? 'Скачивание началось'
|
||||||
|
: 'Download started'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||||
|
ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showMobileQuickActions(
|
Future<void> _showMobileQuickActions(
|
||||||
@@ -453,11 +482,49 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
.removeFavorite(post.id, post.providerId);
|
.removeFavorite(post.id, post.providerId);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||||
|
await _maybeAutoDownloadFavorite(ref, post);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
ref.invalidate(favoritesControllerProvider);
|
ref.invalidate(favoritesControllerProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async {
|
||||||
|
final settings =
|
||||||
|
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
|
||||||
|
await ref.read(downloadManagerServiceProvider).start(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteLocalFile(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Post post,
|
||||||
|
) async {
|
||||||
|
final strings = ref.read(appStringsProvider);
|
||||||
|
await ref.read(downloadedMediaServiceProvider).deleteLocalFile(
|
||||||
|
post.cacheKey,
|
||||||
|
);
|
||||||
|
ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(strings.deleteLocalFile)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PostActionLabels _postActionLabels(AppStrings strings) {
|
||||||
|
return PostActionLabels(
|
||||||
|
favorite: strings.favorite,
|
||||||
|
unfavorite: strings.removeFavorite,
|
||||||
|
collection: strings.collection,
|
||||||
|
similar: strings.similar,
|
||||||
|
openOriginal: strings.open,
|
||||||
|
copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link',
|
||||||
|
download: strings.download,
|
||||||
|
deleteLocalFile: strings.deleteLocalFile,
|
||||||
|
hidePost: strings.hidePost,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _hidePost(
|
Future<void> _hidePost(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@@ -610,7 +677,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
|||||||
|
|
||||||
Map<String, String> _headersFor(Post post) {
|
Map<String, String> _headersFor(Post post) {
|
||||||
return {
|
return {
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
'Accept': '*/*',
|
'Accept': '*/*',
|
||||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||||
post.fileUrl.contains('gelbooru.com') ||
|
post.fileUrl.contains('gelbooru.com') ||
|
||||||
@@ -742,6 +809,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final strings = ref.watch(appStringsProvider);
|
||||||
final comments = _expanded
|
final comments = _expanded
|
||||||
? ref.watch(
|
? ref.watch(
|
||||||
postCommentsProvider(
|
postCommentsProvider(
|
||||||
@@ -756,27 +824,28 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
|||||||
tilePadding: EdgeInsets.zero,
|
tilePadding: EdgeInsets.zero,
|
||||||
initiallyExpanded: false,
|
initiallyExpanded: false,
|
||||||
onExpansionChanged: (value) => setState(() => _expanded = value),
|
onExpansionChanged: (value) => setState(() => _expanded = value),
|
||||||
title: Text('Comments', style: Theme.of(context).textTheme.titleMedium),
|
title: Text(strings.comments,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
children: [
|
children: [
|
||||||
(comments ?? const AsyncValue<List<PostComment>>.data([])).when(
|
(comments ?? const AsyncValue<List<PostComment>>.data([])).when(
|
||||||
loading: () => const Padding(
|
loading: () => const Padding(
|
||||||
padding: EdgeInsets.all(12),
|
padding: EdgeInsets.all(12),
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
error: (_, __) => const Align(
|
error: (_, __) => Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text('Comments unavailable'),
|
child: Text(strings.commentsUnavailable),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (items) {
|
data: (items) {
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return const Align(
|
return Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text('No comments'),
|
child: Text(strings.noComments),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -788,7 +857,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(
|
title: Text(
|
||||||
comment.authorName.isEmpty
|
comment.authorName.isEmpty
|
||||||
? 'Anonymous'
|
? strings.anonymous
|
||||||
: comment.authorName,
|
: comment.authorName,
|
||||||
),
|
),
|
||||||
subtitle: Text(comment.body),
|
subtitle: Text(comment.body),
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ class SimilarPostsScreen extends ConsumerWidget {
|
|||||||
blurExplicit: settings.blurExplicitContent,
|
blurExplicit: settings.blurExplicitContent,
|
||||||
showBadges: settings.showPostBadges,
|
showBadges: settings.showPostBadges,
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode:
|
||||||
|
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||||
favoriteKeys: favorites,
|
favoriteKeys: favorites,
|
||||||
viewedKeys: viewed,
|
viewedKeys: viewed,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
@@ -123,7 +125,13 @@ class SimilarPostsScreen extends ConsumerWidget {
|
|||||||
.read(favoriteServiceProvider)
|
.read(favoriteServiceProvider)
|
||||||
.removeFavorite(post.id, post.providerId);
|
.removeFavorite(post.id, post.providerId);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
final settings =
|
||||||
|
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
await ref.read(favoriteServiceProvider).addFavorite(
|
||||||
|
post,
|
||||||
|
settings: settings,
|
||||||
|
downloadManager: ref.read(downloadManagerServiceProvider),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
ref.invalidate(viewedControllerProvider);
|
ref.invalidate(viewedControllerProvider);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class PostActionBar extends StatelessWidget {
|
|||||||
required this.onCopy,
|
required this.onCopy,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
this.onDownload,
|
this.onDownload,
|
||||||
|
this.onDeleteLocalFile,
|
||||||
|
this.downloaded = false,
|
||||||
|
this.labels,
|
||||||
this.onSimilar,
|
this.onSimilar,
|
||||||
this.onHide,
|
this.onHide,
|
||||||
super.key,
|
super.key,
|
||||||
@@ -19,11 +22,15 @@ class PostActionBar extends StatelessWidget {
|
|||||||
final VoidCallback onCopy;
|
final VoidCallback onCopy;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
|
final VoidCallback? onDeleteLocalFile;
|
||||||
final VoidCallback? onSimilar;
|
final VoidCallback? onSimilar;
|
||||||
final VoidCallback? onHide;
|
final VoidCallback? onHide;
|
||||||
|
final bool downloaded;
|
||||||
|
final PostActionLabels? labels;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final text = labels ?? const PostActionLabels();
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@@ -33,38 +40,46 @@ class PostActionBar extends StatelessWidget {
|
|||||||
icon: Icon(
|
icon: Icon(
|
||||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
),
|
),
|
||||||
label: Text(isFavorite ? 'Unfavorite' : 'Favorite'),
|
label: Text(isFavorite ? text.unfavorite : text.favorite),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: onCollection,
|
onPressed: onCollection,
|
||||||
icon: const Icon(Icons.add_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
label: const Text('Collection'),
|
label: Text(text.collection),
|
||||||
),
|
),
|
||||||
if (onSimilar != null)
|
if (onSimilar != null)
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: onSimilar,
|
onPressed: onSimilar,
|
||||||
icon: const Icon(Icons.auto_awesome_rounded),
|
icon: const Icon(Icons.auto_awesome_rounded),
|
||||||
label: const Text('Similar'),
|
label: Text(text.similar),
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Open original',
|
tooltip: text.openOriginal,
|
||||||
onPressed: onOpen,
|
onPressed: onOpen,
|
||||||
icon: const Icon(Icons.open_in_new_rounded),
|
icon: const Icon(Icons.open_in_new_rounded),
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Copy link',
|
tooltip: text.copyLink,
|
||||||
onPressed: onCopy,
|
onPressed: onCopy,
|
||||||
icon: const Icon(Icons.copy_rounded),
|
icon: const Icon(Icons.copy_rounded),
|
||||||
),
|
),
|
||||||
if (onDownload != null)
|
if (onDownload != null)
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Download',
|
tooltip: text.download,
|
||||||
onPressed: onDownload,
|
onPressed: onDownload,
|
||||||
icon: const Icon(Icons.download_rounded),
|
icon: Icon(
|
||||||
|
downloaded ? Icons.download_done_rounded : Icons.download_rounded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (downloaded && onDeleteLocalFile != null)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
tooltip: text.deleteLocalFile,
|
||||||
|
onPressed: onDeleteLocalFile,
|
||||||
|
icon: const Icon(Icons.delete_sweep_rounded),
|
||||||
),
|
),
|
||||||
if (onHide != null)
|
if (onHide != null)
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Hide post locally',
|
tooltip: text.hidePost,
|
||||||
onPressed: onHide,
|
onPressed: onHide,
|
||||||
icon: const Icon(Icons.visibility_off_rounded),
|
icon: const Icon(Icons.visibility_off_rounded),
|
||||||
),
|
),
|
||||||
@@ -72,3 +87,27 @@ class PostActionBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PostActionLabels {
|
||||||
|
const PostActionLabels({
|
||||||
|
this.favorite = 'Favorite',
|
||||||
|
this.unfavorite = 'Unfavorite',
|
||||||
|
this.collection = 'Collection',
|
||||||
|
this.similar = 'Similar',
|
||||||
|
this.openOriginal = 'Open original',
|
||||||
|
this.copyLink = 'Copy link',
|
||||||
|
this.download = 'Download',
|
||||||
|
this.deleteLocalFile = 'Delete local file',
|
||||||
|
this.hidePost = 'Hide post locally',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String favorite;
|
||||||
|
final String unfavorite;
|
||||||
|
final String collection;
|
||||||
|
final String similar;
|
||||||
|
final String openOriginal;
|
||||||
|
final String copyLink;
|
||||||
|
final String download;
|
||||||
|
final String deleteLocalFile;
|
||||||
|
final String hidePost;
|
||||||
|
}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
|
final allowPinchZoom = MediaQuery.sizeOf(context).width < 700;
|
||||||
return allowPinchZoom ? InteractiveViewer(child: image) : image;
|
return allowPinchZoom ? _ZoomableImage(child: image) : image;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializeVideo() {
|
void _initializeVideo() {
|
||||||
@@ -348,7 +348,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
|
|
||||||
Map<String, String> _headersFor(Post post) {
|
Map<String, String> _headersFor(Post post) {
|
||||||
return {
|
return {
|
||||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||||
'Accept': '*/*',
|
'Accept': '*/*',
|
||||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||||
post.fileUrl.contains('gelbooru.com') ||
|
post.fileUrl.contains('gelbooru.com') ||
|
||||||
@@ -1170,6 +1170,61 @@ class VideoErrorOverlay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ZoomableImage extends StatefulWidget {
|
||||||
|
const _ZoomableImage({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ZoomableImage> createState() => _ZoomableImageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZoomableImageState extends State<_ZoomableImage> {
|
||||||
|
final _controller = TransformationController();
|
||||||
|
TapDownDetails? _doubleTapDetails;
|
||||||
|
bool _zoomed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onDoubleTapDown: (details) => _doubleTapDetails = details,
|
||||||
|
onDoubleTap: _toggleZoom,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
transformationController: _controller,
|
||||||
|
minScale: 1,
|
||||||
|
maxScale: 5,
|
||||||
|
panEnabled: _zoomed,
|
||||||
|
scaleEnabled: true,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
onInteractionEnd: (_) {
|
||||||
|
final scale = _controller.value.getMaxScaleOnAxis();
|
||||||
|
if (mounted) setState(() => _zoomed = scale > 1.03);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleZoom() {
|
||||||
|
final tap = _doubleTapDetails?.localPosition ?? Offset.zero;
|
||||||
|
if (_zoomed) {
|
||||||
|
_controller.value = Matrix4.identity();
|
||||||
|
setState(() => _zoomed = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_controller.value = Matrix4.identity()
|
||||||
|
..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1)
|
||||||
|
..scaleByDouble(2.3, 2.3, 1, 1);
|
||||||
|
setState(() => _zoomed = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _RoundControlButton extends StatelessWidget {
|
class _RoundControlButton extends StatelessWidget {
|
||||||
const _RoundControlButton({
|
const _RoundControlButton({
|
||||||
required this.tooltip,
|
required this.tooltip,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
|
|
||||||
import '../../../app/app_version.dart';
|
import '../../../app/app_version.dart';
|
||||||
import '../../../app/changelog.dart';
|
import '../../../app/changelog.dart';
|
||||||
|
import '../../../app/app_strings.dart';
|
||||||
import '../../../app/motion.dart';
|
import '../../../app/motion.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../core/utils/result.dart';
|
import '../../../core/utils/result.dart';
|
||||||
@@ -21,8 +22,10 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final settings = ref.watch(settingsControllerProvider);
|
final settings = ref.watch(settingsControllerProvider);
|
||||||
|
final strings = AppStrings(
|
||||||
|
settings.value?.languageCode ?? AppSettings.defaults.languageCode);
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Settings',
|
title: strings.settings,
|
||||||
body: settings.when(
|
body: settings.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
@@ -40,6 +43,7 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isAndroid = Platform.isAndroid;
|
final isAndroid = Platform.isAndroid;
|
||||||
|
final strings = AppStrings(settings.languageCode);
|
||||||
final deviceInfo =
|
final deviceInfo =
|
||||||
isAndroid ? ref.watch(motionDeviceInfoProvider).value : null;
|
isAndroid ? ref.watch(motionDeviceInfoProvider).value : null;
|
||||||
final detectedHz =
|
final detectedHz =
|
||||||
@@ -55,13 +59,13 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
title: 'General',
|
title: strings.general,
|
||||||
icon: Icons.tune_rounded,
|
icon: Icons.tune_rounded,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: settings.themeMode,
|
initialValue: settings.themeMode,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: settings.languageCode == 'ru' ? 'Тема' : 'Theme',
|
labelText: strings.theme,
|
||||||
),
|
),
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 'dark', child: Text('Dark')),
|
DropdownMenuItem(value: 'dark', child: Text('Dark')),
|
||||||
@@ -73,37 +77,46 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: settings.nsfwEnabled,
|
value: settings.nsfwEnabled,
|
||||||
title: const Text('Allow NSFW content'),
|
title: Text(strings.allowNsfw),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(nsfwEnabled: value)),
|
_update(ref, settings.copyWith(nsfwEnabled: value)),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: settings.blurExplicitContent,
|
value: settings.blurExplicitContent,
|
||||||
title: const Text('Blur sensitive previews'),
|
title: Text(strings.blurSensitive),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(blurExplicitContent: value)),
|
_update(ref, settings.copyWith(blurExplicitContent: value)),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: settings.showPostBadges,
|
value: settings.showPostBadges,
|
||||||
title: const Text('Show post badges'),
|
title: Text(strings.showPostBadges),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(showPostBadges: value)),
|
_update(ref, settings.copyWith(showPostBadges: value)),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: settings.allowDownloads,
|
value: settings.allowDownloads,
|
||||||
title: const Text('Allow manual downloads'),
|
title: Text(strings.allowDownloads),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(allowDownloads: value)),
|
_update(ref, settings.copyWith(allowDownloads: value)),
|
||||||
),
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
value: settings.autoDownloadFavorites,
|
||||||
|
title: Text(strings.autoDownloadFavorites),
|
||||||
|
subtitle: Text(strings.autoDownloadFavoritesHint),
|
||||||
|
onChanged: (value) => _update(
|
||||||
|
ref,
|
||||||
|
settings.copyWith(autoDownloadFavorites: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
title: settings.languageCode == 'ru' ? 'Внешний вид' : 'Appearance',
|
title: strings.appearance,
|
||||||
icon: Icons.palette_rounded,
|
icon: Icons.palette_rounded,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: settings.languageCode,
|
initialValue: settings.languageCode,
|
||||||
decoration: const InputDecoration(labelText: 'Language / Язык'),
|
decoration: InputDecoration(labelText: strings.language),
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 'ru', child: Text('Русский')),
|
DropdownMenuItem(value: 'ru', child: Text('Русский')),
|
||||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||||
@@ -127,10 +140,12 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: settings.allowExperimentalUpdates,
|
value: settings.allowExperimentalUpdates,
|
||||||
title: const Text('Receive beta / experimental updates'),
|
title: Text(settings.languageCode == 'ru'
|
||||||
subtitle: const Text(
|
? 'Получать beta / experimental обновления'
|
||||||
'When enabled, the updater also sees prerelease Gitea builds.',
|
: 'Receive beta / experimental updates'),
|
||||||
),
|
subtitle: Text(settings.languageCode == 'ru'
|
||||||
|
? 'Обновлятор будет видеть prerelease-сборки Gitea.'
|
||||||
|
: 'When enabled, the updater also sees prerelease Gitea builds.'),
|
||||||
onChanged: (value) => _update(
|
onChanged: (value) => _update(
|
||||||
ref,
|
ref,
|
||||||
settings.copyWith(allowExperimentalUpdates: value),
|
settings.copyWith(allowExperimentalUpdates: value),
|
||||||
@@ -139,12 +154,12 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
title: 'Feed & Layout',
|
title: strings.feedLayout,
|
||||||
icon: Icons.dashboard_customize_rounded,
|
icon: Icons.dashboard_customize_rounded,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: settings.mediaQualityMode,
|
initialValue: settings.mediaQualityMode,
|
||||||
decoration: const InputDecoration(labelText: 'Media quality'),
|
decoration: InputDecoration(labelText: strings.mediaQuality),
|
||||||
items: [
|
items: [
|
||||||
for (final mode in MediaQualityMode.values)
|
for (final mode in MediaQualityMode.values)
|
||||||
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
|
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
|
||||||
@@ -185,7 +200,7 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
_StepperTile(
|
_StepperTile(
|
||||||
title: 'Desktop columns',
|
title: strings.desktopColumns,
|
||||||
value: settings.desktopColumns,
|
value: settings.desktopColumns,
|
||||||
min: 3,
|
min: 3,
|
||||||
max: 8,
|
max: 8,
|
||||||
@@ -195,7 +210,7 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_StepperTile(
|
_StepperTile(
|
||||||
title: 'Mobile columns',
|
title: strings.mobileColumns,
|
||||||
value: settings.mobileColumns,
|
value: settings.mobileColumns,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 3,
|
max: 3,
|
||||||
@@ -207,17 +222,17 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
title: 'Filters',
|
title: strings.filters,
|
||||||
icon: Icons.filter_alt_rounded,
|
icon: Icons.filter_alt_rounded,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: settings.hideViewedPosts,
|
value: settings.hideViewedPosts,
|
||||||
title: const Text('Hide viewed posts'),
|
title: Text(strings.hideViewed),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(hideViewedPosts: value)),
|
_update(ref, settings.copyWith(hideViewedPosts: value)),
|
||||||
),
|
),
|
||||||
_TagListEditor(
|
_TagListEditor(
|
||||||
title: 'Smart blacklist',
|
title: strings.smartBlacklist,
|
||||||
icon: Icons.visibility_off_rounded,
|
icon: Icons.visibility_off_rounded,
|
||||||
tags: settings.smartBlacklistRules,
|
tags: settings.smartBlacklistRules,
|
||||||
helper:
|
helper:
|
||||||
@@ -229,7 +244,7 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_TagListEditor(
|
_TagListEditor(
|
||||||
title: 'Whitelisted tags',
|
title: strings.whitelistedTags,
|
||||||
icon: Icons.verified_rounded,
|
icon: Icons.verified_rounded,
|
||||||
tags: settings.whitelistedTags,
|
tags: settings.whitelistedTags,
|
||||||
onChanged: (tags) => _update(
|
onChanged: (tags) => _update(
|
||||||
@@ -240,11 +255,11 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
title: 'Storage',
|
title: strings.storage,
|
||||||
icon: Icons.storage_rounded,
|
icon: Icons.storage_rounded,
|
||||||
children: [
|
children: [
|
||||||
_StepperTile(
|
_StepperTile(
|
||||||
title: 'Cache max items',
|
title: strings.cacheMaxItems,
|
||||||
value: settings.cacheMaxItems,
|
value: settings.cacheMaxItems,
|
||||||
min: 100,
|
min: 100,
|
||||||
max: 10000,
|
max: 10000,
|
||||||
@@ -259,14 +274,14 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.clearCache(),
|
.clearCache(),
|
||||||
icon: const Icon(Icons.cleaning_services_rounded),
|
icon: const Icon(Icons.cleaning_services_rounded),
|
||||||
label: const Text('Clear cache'),
|
label: Text(strings.clearCache),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.clearViewedHistory(),
|
.clearViewedHistory(),
|
||||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
icon: const Icon(Icons.history_toggle_off_rounded),
|
||||||
label: const Text('Clear viewed'),
|
label: Text(strings.clearViewed),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
@@ -318,7 +333,7 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
title: 'About',
|
title: strings.about,
|
||||||
icon: Icons.info_rounded,
|
icon: Icons.info_rounded,
|
||||||
children: [
|
children: [
|
||||||
_ActionGrid(
|
_ActionGrid(
|
||||||
@@ -326,7 +341,7 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _checkUpdates(context, ref),
|
onPressed: () => _checkUpdates(context, ref),
|
||||||
icon: const Icon(Icons.system_update_alt_rounded),
|
icon: const Icon(Icons.system_update_alt_rounded),
|
||||||
label: const Text('Check updates'),
|
label: Text(strings.checkUpdates),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _showChangelog(context),
|
onPressed: () => _showChangelog(context),
|
||||||
@@ -336,12 +351,12 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _exportJson(context, ref),
|
onPressed: () => _exportJson(context, ref),
|
||||||
icon: const Icon(Icons.upload_file_rounded),
|
icon: const Icon(Icons.upload_file_rounded),
|
||||||
label: const Text('Export JSON'),
|
label: Text(strings.exportJson),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _importDialog(context, ref),
|
onPressed: () => _importDialog(context, ref),
|
||||||
icon: const Icon(Icons.download_for_offline_rounded),
|
icon: const Icon(Icons.download_for_offline_rounded),
|
||||||
label: const Text('Import JSON'),
|
label: Text(strings.importJson),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Viewed',
|
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear viewed history',
|
tooltip: 'Clear viewed history',
|
||||||
@@ -49,6 +49,8 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
blurExplicit: settings.blurExplicitContent,
|
blurExplicit: settings.blurExplicitContent,
|
||||||
showBadges: settings.showPostBadges,
|
showBadges: settings.showPostBadges,
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode:
|
||||||
|
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||||
favoriteKeys: favoriteKeys,
|
favoriteKeys: favoriteKeys,
|
||||||
viewedKeys: viewedKeys,
|
viewedKeys: viewedKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
@@ -61,7 +63,12 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
.read(favoriteServiceProvider)
|
.read(favoriteServiceProvider)
|
||||||
.removeFavorite(post.id, post.providerId);
|
.removeFavorite(post.id, post.providerId);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
await ref.read(favoriteServiceProvider).addFavorite(
|
||||||
|
post,
|
||||||
|
settings: settings,
|
||||||
|
downloadManager:
|
||||||
|
ref.read(downloadManagerServiceProvider),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class _Destination {
|
|||||||
'favorites' => 'Избранное',
|
'favorites' => 'Избранное',
|
||||||
'viewed' => 'История',
|
'viewed' => 'История',
|
||||||
'collections' => 'Коллекции',
|
'collections' => 'Коллекции',
|
||||||
'artists' => 'Авторы',
|
'artists' => 'Артисты',
|
||||||
'providers' => 'Провайдеры',
|
'providers' => 'Провайдеры',
|
||||||
'settings' => 'Настройки',
|
'settings' => 'Настройки',
|
||||||
_ => label,
|
_ => label,
|
||||||
@@ -340,7 +340,8 @@ class _Destination {
|
|||||||
String mobileLabelFor(bool ru) {
|
String mobileLabelFor(bool ru) {
|
||||||
if (!ru) return mobileLabel;
|
if (!ru) return mobileLabel;
|
||||||
return switch (id) {
|
return switch (id) {
|
||||||
'collections' => 'Колл.',
|
'collections' => 'Доски',
|
||||||
|
'favorites' => 'Любимое',
|
||||||
_ => labelFor(true),
|
_ => labelFor(true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class PostCard extends StatefulWidget {
|
|||||||
required this.showBadges,
|
required this.showBadges,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
required this.isViewed,
|
required this.isViewed,
|
||||||
|
required this.isDownloaded,
|
||||||
|
required this.mediaQualityMode,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onFavorite,
|
required this.onFavorite,
|
||||||
this.onAddToCollection,
|
this.onAddToCollection,
|
||||||
@@ -30,6 +32,8 @@ class PostCard extends StatefulWidget {
|
|||||||
final bool showBadges;
|
final bool showBadges;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final bool isViewed;
|
final bool isViewed;
|
||||||
|
final bool isDownloaded;
|
||||||
|
final MediaQualityMode mediaQualityMode;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
final VoidCallback onFavorite;
|
final VoidCallback onFavorite;
|
||||||
final VoidCallback? onAddToCollection;
|
final VoidCallback? onAddToCollection;
|
||||||
@@ -54,6 +58,12 @@ class _PostCardState extends State<PostCard> {
|
|||||||
? post.width / post.height
|
? post.width / post.height
|
||||||
: _fallbackAspect(post.fileType);
|
: _fallbackAspect(post.fileType);
|
||||||
final mobile = MediaQuery.sizeOf(context).width < 700;
|
final mobile = MediaQuery.sizeOf(context).width < 700;
|
||||||
|
final urls = MediaUrlSelector.feed(
|
||||||
|
post,
|
||||||
|
mode: widget.mediaQualityMode,
|
||||||
|
mobile: mobile,
|
||||||
|
);
|
||||||
|
final imageUrl = urls.isEmpty ? post.previewUrl : urls.first;
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
@@ -115,18 +125,27 @@ class _PostCardState extends State<PostCard> {
|
|||||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||||
child: BlurContent(
|
child: BlurContent(
|
||||||
enabled: widget.blurExplicit && sensitive,
|
enabled: widget.blurExplicit && sensitive,
|
||||||
child: CachedNetworkImage(
|
child: LayoutBuilder(
|
||||||
imageUrl: post.previewUrl.isNotEmpty
|
builder: (context, constraints) {
|
||||||
? post.previewUrl
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||||
: post.sampleUrl,
|
final cacheWidth =
|
||||||
fit: BoxFit.cover,
|
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||||
placeholder: (context, url) => const LoadingSkeleton(),
|
return CachedNetworkImage(
|
||||||
errorWidget: (context, url, error) => ColoredBox(
|
imageUrl: imageUrl,
|
||||||
color:
|
memCacheWidth: cacheWidth,
|
||||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
maxWidthDiskCache: cacheWidth,
|
||||||
child:
|
fit: BoxFit.cover,
|
||||||
const Center(child: Icon(Icons.broken_image_rounded)),
|
placeholder: (context, url) => const LoadingSkeleton(),
|
||||||
),
|
errorWidget: (context, url, error) => ColoredBox(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.broken_image_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -152,6 +171,12 @@ class _PostCardState extends State<PostCard> {
|
|||||||
bottom: 8,
|
bottom: 8,
|
||||||
child: _SeenBadge(),
|
child: _SeenBadge(),
|
||||||
),
|
),
|
||||||
|
if (widget.isDownloaded)
|
||||||
|
Positioned(
|
||||||
|
right: widget.isViewed ? 72 : 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: const _DownloadedBadge(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
if (widget.selected)
|
if (widget.selected)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
@@ -390,6 +415,24 @@ class _SeenBadge extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DownloadedBadge extends StatelessWidget {
|
||||||
|
const _DownloadedBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||||
|
child: Icon(Icons.download_done_rounded, size: 13, color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> showAddToCollectionPicker(
|
Future<void> showAddToCollectionPicker(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<Collection> collections,
|
required List<Collection> collections,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
required this.blurExplicit,
|
required this.blurExplicit,
|
||||||
required this.showBadges,
|
required this.showBadges,
|
||||||
required this.nsfwEnabled,
|
required this.nsfwEnabled,
|
||||||
|
required this.mediaQualityMode,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onFavorite,
|
required this.onFavorite,
|
||||||
this.onAddToCollection,
|
this.onAddToCollection,
|
||||||
@@ -23,6 +24,7 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
this.selectedKeys = const {},
|
this.selectedKeys = const {},
|
||||||
this.favoriteKeys = const {},
|
this.favoriteKeys = const {},
|
||||||
this.viewedKeys = const {},
|
this.viewedKeys = const {},
|
||||||
|
this.downloadedKeys = const {},
|
||||||
this.loading = false,
|
this.loading = false,
|
||||||
this.controller,
|
this.controller,
|
||||||
super.key,
|
super.key,
|
||||||
@@ -33,6 +35,7 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
final bool blurExplicit;
|
final bool blurExplicit;
|
||||||
final bool showBadges;
|
final bool showBadges;
|
||||||
final bool nsfwEnabled;
|
final bool nsfwEnabled;
|
||||||
|
final MediaQualityMode mediaQualityMode;
|
||||||
final ValueChanged<Post> onOpen;
|
final ValueChanged<Post> onOpen;
|
||||||
final ValueChanged<Post> onFavorite;
|
final ValueChanged<Post> onFavorite;
|
||||||
final ValueChanged<Post>? onAddToCollection;
|
final ValueChanged<Post>? onAddToCollection;
|
||||||
@@ -43,6 +46,7 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
final Set<String> selectedKeys;
|
final Set<String> selectedKeys;
|
||||||
final Set<String> favoriteKeys;
|
final Set<String> favoriteKeys;
|
||||||
final Set<String> viewedKeys;
|
final Set<String> viewedKeys;
|
||||||
|
final Set<String> downloadedKeys;
|
||||||
final bool loading;
|
final bool loading;
|
||||||
final ScrollController? controller;
|
final ScrollController? controller;
|
||||||
|
|
||||||
@@ -65,6 +69,8 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
showBadges: showBadges,
|
showBadges: showBadges,
|
||||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||||
isViewed: viewedKeys.contains(post.cacheKey),
|
isViewed: viewedKeys.contains(post.cacheKey),
|
||||||
|
isDownloaded: downloadedKeys.contains(post.cacheKey),
|
||||||
|
mediaQualityMode: mediaQualityMode,
|
||||||
onOpen: () => onOpen(post),
|
onOpen: () => onOpen(post),
|
||||||
onFavorite: () => onFavorite(post),
|
onFavorite: () => onFavorite(post),
|
||||||
onPreview: onPreview == null ? null : () => onPreview!(post),
|
onPreview: onPreview == null ? null : () => onPreview!(post),
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
dart_style:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.0.0+20
|
version: 2.0.1+21
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
@@ -10,6 +10,7 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
flutter_riverpod: ^2.5.1
|
flutter_riverpod: ^2.5.1
|
||||||
dio: ^5.4.3+1
|
dio: ^5.4.3+1
|
||||||
isar: ^3.1.0+1
|
isar: ^3.1.0+1
|
||||||
|
|||||||
Reference in New Issue
Block a user