Release Lunaris 2.0.1 polish update

This commit is contained in:
2026-05-26 18:47:07 +03:00
parent f65efe7cf7
commit af62ddf0da
37 changed files with 2607 additions and 143 deletions
+27 -24
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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),
), ),
], ],
), ),
+102
View File
@@ -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';
}
+2 -2
View File
@@ -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';
+11
View File
@@ -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
View File
@@ -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,
),
);
},
);
}
+2
View File
@@ -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';
+34 -2
View File
@@ -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',
}, },
), ),
+19
View File
@@ -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;
}
+2 -2
View File
@@ -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();
}
+16 -1
View File
@@ -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,
+13 -2
View File
@@ -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) {
+38
View File
@@ -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
+1 -1
View File
@@ -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);
}, },
+3 -2
View File
@@ -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),
}; };
} }
+55 -12
View File
@@ -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),
+8
View File
@@ -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
View File
@@ -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