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, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления.
|
||||
|
||||
## Возможности
|
||||
## Что умеет
|
||||
|
||||
- Masonry feed с несколькими провайдерами и перемешанной выдачей All providers.
|
||||
- Masonry feed с несколькими провайдерами и естественно перемешанной выдачей.
|
||||
- Поиск тегов через chip-input с подсказками из активных API.
|
||||
- Избранное, Viewed history, коллекции и пакетное добавление в коллекции.
|
||||
- Избранное, история просмотров, коллекции и пакетное добавление в коллекции.
|
||||
- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score.
|
||||
- Details viewer для фото, GIF, видео и SWF на Windows.
|
||||
- Видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек.
|
||||
- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычным booru feed.
|
||||
- Удобный видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек.
|
||||
- Mobile image zoom: pinch-to-zoom и double tap в details.
|
||||
- Опциональное автоскачивание новых favorites с отдельным удалением локального файла без удаления из избранного.
|
||||
- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычной booru-лентой.
|
||||
- Управление провайдерами, health-check и provider diagnostics.
|
||||
- Кастомизация интерфейса: тема, язык, акцентный цвет, видимые вкладки, beta/experimental обновления.
|
||||
- Кастомизация интерфейса: тема, язык RU/EN, акцентный цвет, видимые вкладки, beta/experimental обновления.
|
||||
- Экспорт и импорт настроек JSON.
|
||||
- Автопроверка Gitea Releases, скачивание APK/Windows installer прямо из приложения.
|
||||
- Проверка Gitea Releases и скачивание APK/Windows installer прямо из приложения.
|
||||
|
||||
## Скачать
|
||||
|
||||
Готовые сборки лежат в 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.
|
||||
- `LunarisSetup-v2.0.0.exe` - установщик для Windows.
|
||||
- `LunarisPortable-v2.0.0.zip` - portable-версия для Windows.
|
||||
- `Lunaris-v2.0.1.apk` — APK для Android.
|
||||
- `LunarisSetup-v2.0.1.exe` — установщик для Windows.
|
||||
- `LunarisPortable-v2.0.1.zip` — portable-версия для Windows.
|
||||
|
||||
## Провайдеры
|
||||
|
||||
@@ -51,15 +53,15 @@ Kemono/Coomer остаются отдельным разделом Artists и н
|
||||
|
||||
## Архитектура
|
||||
|
||||
- `lib/core` - HTTP, база, кеш, ошибки, Result и утилиты.
|
||||
- `lib/backend/models` - доменные модели.
|
||||
- `lib/backend/providers` - API-провайдеры и ProviderManager.
|
||||
- `lib/backend/repositories` - доступ к локальному Isar-хранилищу.
|
||||
- `lib/backend/services` - Feed/Search/Favorites/Collections/Settings/Diagnostics/Downloads/Updates.
|
||||
- `lib/backend/di` - Riverpod providers для backend/core.
|
||||
- `lib/app` - приложение, роутер, тема, motion и responsive helpers.
|
||||
- `lib/shared/widgets` - общие UI-компоненты.
|
||||
- `lib/features` - экраны и контроллеры фич.
|
||||
- `lib/core` — HTTP, база, кеш, ошибки, Result и утилиты.
|
||||
- `lib/backend/models` — доменные модели.
|
||||
- `lib/backend/providers` — API-провайдеры и ProviderManager.
|
||||
- `lib/backend/repositories` — доступ к локальному Isar-хранилищу.
|
||||
- `lib/backend/services` — Feed/Search/Favorites/Collections/Settings/Diagnostics/Downloads/Updates.
|
||||
- `lib/backend/di` — Riverpod providers для backend/core.
|
||||
- `lib/app` — приложение, роутер, тема, локализация, motion и responsive helpers.
|
||||
- `lib/shared/widgets` — общие UI-компоненты.
|
||||
- `lib/features` — экраны и контроллеры фич.
|
||||
|
||||
## Запуск
|
||||
|
||||
@@ -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 сайтов.
|
||||
- NSFW/blur/blacklist настраиваются локально.
|
||||
- API keys/custom headers хранятся локально и экспортируются только через явный export settings.
|
||||
- Локальные пути скачанных файлов не попадают в JSON export/import.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#define MyAppName "Lunaris"
|
||||
#define MyAppVersion "2.0.0"
|
||||
#define MyAppVersion "2.0.1"
|
||||
#define MyAppPublisher "RarDog"
|
||||
#define MyAppExeName "gel_rule_app.exe"
|
||||
|
||||
|
||||
+18
-14
@@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../backend/backend.dart';
|
||||
import '../core/utils/result.dart';
|
||||
import 'app_strings.dart';
|
||||
import 'motion.dart';
|
||||
import 'router.dart';
|
||||
import 'theme.dart';
|
||||
@@ -82,12 +83,11 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
||||
}
|
||||
|
||||
Future<void> _showUpdateDialog(AppUpdateInfo info) async {
|
||||
final strings = ref.read(appStringsProvider);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(_ru
|
||||
? 'Доступен Lunaris ${info.version}'
|
||||
: 'Lunaris ${info.version} is available'),
|
||||
title: Text('${strings.appUpdateAvailable}: Lunaris ${info.version}'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: SingleChildScrollView(
|
||||
@@ -114,14 +114,14 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
||||
await ref.read(updateServiceProvider).skipVersion(info);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: Text(_ru ? 'Пропустить' : 'Skip this version'),
|
||||
child: Text(strings.skipThisVersion),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(updateServiceProvider).remindLater();
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: Text(_ru ? 'Позже' : 'Later'),
|
||||
child: Text(strings.later),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
@@ -129,7 +129,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
bool get _ru =>
|
||||
(ref.read(appSettingsProvider).value ?? AppSettings.defaults)
|
||||
.languageCode ==
|
||||
'ru';
|
||||
|
||||
Future<void> _downloadUpdate(AppUpdateInfo info) async {
|
||||
final updateService = ref.read(updateServiceProvider);
|
||||
final assetUrl = updateService.assetUrlForCurrentPlatform(info);
|
||||
@@ -225,6 +220,7 @@ class _DownloadPanel extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
return Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -250,7 +246,7 @@ class _DownloadPanel extends ConsumerWidget {
|
||||
),
|
||||
if (task.status == DownloadTaskStatus.failed)
|
||||
IconButton(
|
||||
tooltip: 'Retry',
|
||||
tooltip: strings.retry,
|
||||
onPressed: () => ref
|
||||
.read(downloadManagerServiceProvider)
|
||||
.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 {
|
||||
const _StartupLoadingScreen();
|
||||
|
||||
@@ -330,6 +331,7 @@ class _StartupErrorScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
@@ -341,7 +343,9 @@ class _StartupErrorScreen extends ConsumerWidget {
|
||||
const Icon(Icons.storage_rounded, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Could not start local database',
|
||||
strings.ru
|
||||
? 'Не удалось открыть локальную базу'
|
||||
: 'Could not start local database',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -356,7 +360,7 @@ class _StartupErrorScreen extends ConsumerWidget {
|
||||
FilledButton.icon(
|
||||
onPressed: () => ref.invalidate(appDatabaseProvider),
|
||||
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 appBuildNumber = 20;
|
||||
const appDisplayVersion = '2.0.1';
|
||||
const appBuildNumber = 21;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
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(
|
||||
version: '2.0.0',
|
||||
title: 'Customization and provider polish',
|
||||
|
||||
+32
-1
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../backend/backend.dart';
|
||||
@@ -27,7 +28,8 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
pageBuilder: (context, state) => _transitionPage(
|
||||
state,
|
||||
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_work_query.dart';
|
||||
export 'models/download_task.dart';
|
||||
export 'models/downloaded_media.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/post.dart';
|
||||
export 'models/post_comment.dart';
|
||||
@@ -19,6 +20,7 @@ export 'providers/provider_manager.dart';
|
||||
export 'services/collection_service.dart';
|
||||
export 'services/download_service.dart';
|
||||
export 'services/download_manager_service.dart';
|
||||
export 'services/downloaded_media_service.dart';
|
||||
export 'services/favorite_service.dart';
|
||||
export 'services/feed_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_manager.dart';
|
||||
import '../models/download_task.dart';
|
||||
import '../models/downloaded_media.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
import '../repositories/downloaded_media_repository.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
@@ -20,6 +22,7 @@ import '../repositories/viewed_post_repository.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../services/download_manager_service.dart';
|
||||
import '../services/downloaded_media_service.dart';
|
||||
import '../services/favorite_service.dart';
|
||||
import '../services/feed_service.dart';
|
||||
import '../services/provider_check_service.dart';
|
||||
@@ -65,6 +68,11 @@ final viewedPostRepositoryProvider = Provider<ViewedPostRepository>((ref) {
|
||||
return ViewedPostRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final downloadedMediaRepositoryProvider =
|
||||
Provider<DownloadedMediaRepository>((ref) {
|
||||
return DownloadedMediaRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
|
||||
return FavoriteRepository(
|
||||
ref.watch(databaseServiceProvider),
|
||||
@@ -152,8 +160,32 @@ final downloadServiceProvider = Provider<DownloadService>((ref) {
|
||||
return DownloadService();
|
||||
});
|
||||
|
||||
final downloadedMediaServiceProvider = Provider<DownloadedMediaService>((ref) {
|
||||
return DownloadedMediaService(ref.watch(downloadedMediaRepositoryProvider));
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -168,7 +200,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 12),
|
||||
headers: const {
|
||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'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),
|
||||
headers: const {
|
||||
'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),
|
||||
headers: const {
|
||||
'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,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -102,7 +102,7 @@ class ProviderRepository {
|
||||
priority: 6,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -4,11 +4,16 @@ import '../models/download_task.dart';
|
||||
import '../models/post.dart';
|
||||
import '../utils/media_quality.dart';
|
||||
import 'download_service.dart';
|
||||
import 'downloaded_media_service.dart';
|
||||
|
||||
class DownloadManagerService {
|
||||
DownloadManagerService(this._downloadService);
|
||||
DownloadManagerService(
|
||||
this._downloadService, {
|
||||
DownloadedMediaService? downloadedMediaService,
|
||||
}) : _downloadedMediaService = downloadedMediaService;
|
||||
|
||||
final DownloadService _downloadService;
|
||||
final DownloadedMediaService? _downloadedMediaService;
|
||||
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
||||
final Map<String, DownloadTask> _tasks = {};
|
||||
|
||||
@@ -97,6 +102,13 @@ class DownloadManagerService {
|
||||
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));
|
||||
} catch (error) {
|
||||
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 '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
import 'download_manager_service.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
class FavoriteService {
|
||||
FavoriteService(this._repository, [this._postRepository]);
|
||||
@@ -10,7 +12,20 @@ class FavoriteService {
|
||||
final FavoriteRepository _repository;
|
||||
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) {
|
||||
return _repository.remove(postId, providerId);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class AppSettings {
|
||||
required this.mobileColumns,
|
||||
required this.blurExplicitContent,
|
||||
required this.allowDownloads,
|
||||
required this.autoDownloadFavorites,
|
||||
required this.selectedFeedProviderIds,
|
||||
required this.showPostBadges,
|
||||
required this.defaultTopPeriodFilter,
|
||||
@@ -66,6 +67,7 @@ class AppSettings {
|
||||
final int mobileColumns;
|
||||
final bool blurExplicitContent;
|
||||
final bool allowDownloads;
|
||||
final bool autoDownloadFavorites;
|
||||
final List<String> selectedFeedProviderIds;
|
||||
final bool showPostBadges;
|
||||
final String defaultTopPeriodFilter;
|
||||
@@ -106,6 +108,7 @@ class AppSettings {
|
||||
mobileColumns: 2,
|
||||
blurExplicitContent: true,
|
||||
allowDownloads: true,
|
||||
autoDownloadFavorites: false,
|
||||
selectedFeedProviderIds: [],
|
||||
showPostBadges: true,
|
||||
defaultTopPeriodFilter: 'none',
|
||||
@@ -147,6 +150,7 @@ class AppSettings {
|
||||
int? mobileColumns,
|
||||
bool? blurExplicitContent,
|
||||
bool? allowDownloads,
|
||||
bool? autoDownloadFavorites,
|
||||
List<String>? selectedFeedProviderIds,
|
||||
bool? showPostBadges,
|
||||
String? defaultTopPeriodFilter,
|
||||
@@ -189,6 +193,8 @@ class AppSettings {
|
||||
mobileColumns: mobileColumns ?? this.mobileColumns,
|
||||
blurExplicitContent: blurExplicitContent ?? this.blurExplicitContent,
|
||||
allowDownloads: allowDownloads ?? this.allowDownloads,
|
||||
autoDownloadFavorites:
|
||||
autoDownloadFavorites ?? this.autoDownloadFavorites,
|
||||
selectedFeedProviderIds:
|
||||
selectedFeedProviderIds ?? this.selectedFeedProviderIds,
|
||||
showPostBadges: showPostBadges ?? this.showPostBadges,
|
||||
@@ -235,6 +241,7 @@ class AppSettings {
|
||||
'mobileColumns': mobileColumns,
|
||||
'blurExplicitContent': blurExplicitContent,
|
||||
'allowDownloads': allowDownloads,
|
||||
'autoDownloadFavorites': autoDownloadFavorites,
|
||||
'selectedFeedProviderIds': selectedFeedProviderIds,
|
||||
'showPostBadges': showPostBadges,
|
||||
'defaultTopPeriodFilter': defaultTopPeriodFilter,
|
||||
@@ -290,6 +297,8 @@ class AppSettings {
|
||||
defaults.blurExplicitContent,
|
||||
allowDownloads:
|
||||
(json['allowDownloads'] as bool?) ?? defaults.allowDownloads,
|
||||
autoDownloadFavorites: (json['autoDownloadFavorites'] as bool?) ??
|
||||
defaults.autoDownloadFavorites,
|
||||
selectedFeedProviderIds: List<String>.from(
|
||||
(json['selectedFeedProviderIds'] as List?) ??
|
||||
defaults.selectedFeedProviderIds,
|
||||
|
||||
@@ -24,8 +24,19 @@ enum MediaQualityMode {
|
||||
class MediaUrlSelector {
|
||||
const MediaUrlSelector._();
|
||||
|
||||
static List<String> feed(Post post) {
|
||||
return _compact([post.previewUrl, post.sampleUrl]);
|
||||
static List<String> feed(
|
||||
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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../backend/models/provider_diagnostics.dart';
|
||||
import '../../backend/models/provider_health.dart';
|
||||
import '../../backend/models/search_history.dart';
|
||||
import '../../backend/models/viewed_post.dart';
|
||||
import '../../backend/models/downloaded_media.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
@@ -33,6 +34,7 @@ class AppDatabase {
|
||||
AppSettingEntitySchema,
|
||||
ViewedPostEntitySchema,
|
||||
ProviderDiagnosticsEntitySchema,
|
||||
DownloadedMediaEntitySchema,
|
||||
],
|
||||
directory: dir,
|
||||
name: 'gel_rule_app',
|
||||
@@ -333,3 +335,39 @@ class ProviderDiagnosticsEntity {
|
||||
..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,
|
||||
sendTimeout: timeout,
|
||||
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',
|
||||
...?headers,
|
||||
},
|
||||
|
||||
@@ -82,6 +82,8 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
@@ -94,7 +96,12 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
|
||||
@@ -46,6 +46,8 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
@@ -57,7 +59,12 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
|
||||
@@ -19,8 +19,15 @@ class FavoritesScreen extends ConsumerWidget {
|
||||
final state = ref.watch(favoritesControllerProvider);
|
||||
final settings =
|
||||
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(
|
||||
title: 'Favorites',
|
||||
title: settings.languageCode == 'ru' ? 'Избранное' : 'Favorites',
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
@@ -36,7 +43,10 @@ class FavoritesScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: data.posts.map((post) => post.cacheKey).toSet(),
|
||||
downloadedKeys: downloaded.keys.toSet(),
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
@@ -9,8 +11,12 @@ final feedControllerProvider =
|
||||
AsyncNotifierProvider<FeedController, FeedState>(FeedController.new);
|
||||
|
||||
class FeedController extends AsyncNotifier<FeedState> {
|
||||
Timer? _providerDebounce;
|
||||
int _loadRequestId = 0;
|
||||
|
||||
@override
|
||||
Future<FeedState> build() async {
|
||||
ref.onDispose(() => _providerDebounce?.cancel());
|
||||
final providers = await _loadProviders();
|
||||
final settings = await _settings();
|
||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||
@@ -32,23 +38,29 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
final requestId = ++_loadRequestId;
|
||||
state = const AsyncLoading();
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, clearError: true));
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.value ?? const FeedState();
|
||||
final requestId = ++_loadRequestId;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: true, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
state = AsyncData(current.copyWith(posts: posts, isLoadingMore: false));
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||
final requestId = ++_loadRequestId;
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true, clearError: true));
|
||||
final posts = await _load(refresh: false, current: current);
|
||||
if (requestId != _loadRequestId) return;
|
||||
final existingKeys = current.posts.map((post) => post.cacheKey).toSet();
|
||||
final newPosts = posts
|
||||
.where((post) => existingKeys.add(post.cacheKey))
|
||||
@@ -98,16 +110,21 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
|
||||
Future<void> setProviders(List<String> providerIds) async {
|
||||
final current = state.value ?? const FeedState();
|
||||
state = AsyncData(
|
||||
current.copyWith(selectedProviderIds: providerIds, posts: []));
|
||||
final enabledIds = current.providers.map((provider) => provider.id).toSet();
|
||||
final selected = providerIds.where(enabledIds.contains).toList();
|
||||
state =
|
||||
AsyncData(current.copyWith(selectedProviderIds: selected, posts: []));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(
|
||||
selectedFeedProviderIds: providerIds,
|
||||
lastFeedProviderIds: providerIds,
|
||||
selectedFeedProviderIds: selected,
|
||||
lastFeedProviderIds: selected,
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
_providerDebounce?.cancel();
|
||||
_providerDebounce = Timer(const Duration(milliseconds: 320), () {
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setTopPeriod(TopPeriodFilter period) async {
|
||||
|
||||
@@ -235,6 +235,8 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode: MediaQualityMode.fromName(
|
||||
settings.mediaQualityMode),
|
||||
loading: state.isLoadingMore,
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
@@ -309,6 +311,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
for (final post in _selectedPosts(posts)) {
|
||||
if (!favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await _maybeAutoDownloadFavorite(ref, post);
|
||||
}
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
@@ -398,6 +401,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await _maybeAutoDownloadFavorite(ref, post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
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 {
|
||||
@@ -448,13 +459,17 @@ class _FeedTitle extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Feed',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Builder(builder: (context) {
|
||||
return Consumer(
|
||||
builder: (context, ref, _) => Text(
|
||||
ref.watch(appStringsProvider).feed,
|
||||
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 '../../../app/app.dart';
|
||||
import '../../../app/app_strings.dart';
|
||||
import '../../../app/motion.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
@@ -55,14 +56,15 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
final post = ref.watch(postDetailsControllerProvider(args));
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final feedPosts =
|
||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: 'Post',
|
||||
title: strings.post,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
tooltip: strings.close,
|
||||
onPressed: () => _close(context),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
@@ -99,6 +101,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
settings,
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
strings,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -109,6 +112,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
settings,
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
strings,
|
||||
);
|
||||
}
|
||||
return Shortcuts(
|
||||
@@ -174,7 +178,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Previous',
|
||||
tooltip:
|
||||
strings.ru ? 'Предыдущий' : 'Previous',
|
||||
onPressed: previous == null
|
||||
? null
|
||||
: () => _openPost(context, previous),
|
||||
@@ -200,7 +205,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Next',
|
||||
tooltip: strings.ru ? 'Следующий' : 'Next',
|
||||
onPressed: next == null
|
||||
? null
|
||||
: () => _openPost(context, next),
|
||||
@@ -219,6 +224,12 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
PostActionBar(
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
labels: _postActionLabels(strings),
|
||||
downloaded: ref
|
||||
.watch(
|
||||
downloadedMediaByKeyProvider(post.cacheKey))
|
||||
.value !=
|
||||
null,
|
||||
onFavorite: () =>
|
||||
_toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
@@ -230,6 +241,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
onDownload: settings.allowDownloads
|
||||
? () => _download(context, ref, post)
|
||||
: null,
|
||||
onDeleteLocalFile: () =>
|
||||
_deleteLocalFile(context, ref, post),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
@@ -242,13 +255,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: const Text('Source'),
|
||||
label: Text(strings.source),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
PostTagsPanel(post: post),
|
||||
const SizedBox(height: 16),
|
||||
@@ -270,6 +284,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
AppSettings settings,
|
||||
Set<String> favoriteKeys,
|
||||
MediaQualityMode qualityMode,
|
||||
AppStrings strings,
|
||||
) {
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
return GestureDetector(
|
||||
@@ -310,6 +325,10 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 12),
|
||||
PostActionBar(
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
labels: _postActionLabels(strings),
|
||||
downloaded:
|
||||
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value !=
|
||||
null,
|
||||
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
|
||||
onCollection: () => _addToCollection(context, ref, post),
|
||||
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
|
||||
@@ -319,6 +338,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
onDownload: settings.allowDownloads
|
||||
? () => _download(context, ref, post)
|
||||
: null,
|
||||
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
@@ -330,7 +350,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Chip(label: Text('${post.width} x ${post.height}')),
|
||||
if (post.source != null && post.source!.isNotEmpty)
|
||||
ActionChip(
|
||||
label: const Text('Source'),
|
||||
label: Text(strings.source),
|
||||
onPressed: () => launchUrl(Uri.parse(post.source!)),
|
||||
),
|
||||
],
|
||||
@@ -339,7 +359,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
|
||||
title: Text(strings.tags,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -378,9 +399,17 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
if (!context.mounted) return;
|
||||
await ref.read(downloadManagerServiceProvider).start(post);
|
||||
if (!context.mounted) return;
|
||||
final strings = ref.read(appStringsProvider);
|
||||
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(
|
||||
@@ -453,11 +482,49 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await _maybeAutoDownloadFavorite(ref, post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
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(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -610,7 +677,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||
post.fileUrl.contains('gelbooru.com') ||
|
||||
@@ -742,6 +809,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final comments = _expanded
|
||||
? ref.watch(
|
||||
postCommentsProvider(
|
||||
@@ -756,27 +824,28 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
tilePadding: EdgeInsets.zero,
|
||||
initiallyExpanded: false,
|
||||
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: [
|
||||
(comments ?? const AsyncValue<List<PostComment>>.data([])).when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (_, __) => const Align(
|
||||
error: (_, __) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text('Comments unavailable'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(strings.commentsUnavailable),
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Align(
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text('No comments'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(strings.noComments),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -788,7 +857,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
comment.authorName.isEmpty
|
||||
? 'Anonymous'
|
||||
? strings.anonymous
|
||||
: comment.authorName,
|
||||
),
|
||||
subtitle: Text(comment.body),
|
||||
|
||||
@@ -94,6 +94,8 @@ class SimilarPostsScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favorites,
|
||||
viewedKeys: viewed,
|
||||
onOpen: (post) => context.push(
|
||||
@@ -123,7 +125,13 @@ class SimilarPostsScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} 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(viewedControllerProvider);
|
||||
|
||||
@@ -8,6 +8,9 @@ class PostActionBar extends StatelessWidget {
|
||||
required this.onCopy,
|
||||
required this.isFavorite,
|
||||
this.onDownload,
|
||||
this.onDeleteLocalFile,
|
||||
this.downloaded = false,
|
||||
this.labels,
|
||||
this.onSimilar,
|
||||
this.onHide,
|
||||
super.key,
|
||||
@@ -19,11 +22,15 @@ class PostActionBar extends StatelessWidget {
|
||||
final VoidCallback onCopy;
|
||||
final bool isFavorite;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onDeleteLocalFile;
|
||||
final VoidCallback? onSimilar;
|
||||
final VoidCallback? onHide;
|
||||
final bool downloaded;
|
||||
final PostActionLabels? labels;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = labels ?? const PostActionLabels();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
@@ -33,38 +40,46 @@ class PostActionBar extends StatelessWidget {
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
),
|
||||
label: Text(isFavorite ? 'Unfavorite' : 'Favorite'),
|
||||
label: Text(isFavorite ? text.unfavorite : text.favorite),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCollection,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Collection'),
|
||||
label: Text(text.collection),
|
||||
),
|
||||
if (onSimilar != null)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onSimilar,
|
||||
icon: const Icon(Icons.auto_awesome_rounded),
|
||||
label: const Text('Similar'),
|
||||
label: Text(text.similar),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Open original',
|
||||
tooltip: text.openOriginal,
|
||||
onPressed: onOpen,
|
||||
icon: const Icon(Icons.open_in_new_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Copy link',
|
||||
tooltip: text.copyLink,
|
||||
onPressed: onCopy,
|
||||
icon: const Icon(Icons.copy_rounded),
|
||||
),
|
||||
if (onDownload != null)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Download',
|
||||
tooltip: text.download,
|
||||
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)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Hide post locally',
|
||||
tooltip: text.hidePost,
|
||||
onPressed: onHide,
|
||||
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;
|
||||
return allowPinchZoom ? InteractiveViewer(child: image) : image;
|
||||
return allowPinchZoom ? _ZoomableImage(child: image) : image;
|
||||
}
|
||||
|
||||
void _initializeVideo() {
|
||||
@@ -348,7 +348,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0 Flutter local booru browser',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (post.providerName.toLowerCase().contains('gelbooru') ||
|
||||
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 {
|
||||
const _RoundControlButton({
|
||||
required this.tooltip,
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app_version.dart';
|
||||
import '../../../app/changelog.dart';
|
||||
import '../../../app/app_strings.dart';
|
||||
import '../../../app/motion.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
@@ -21,8 +22,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsControllerProvider);
|
||||
final strings = AppStrings(
|
||||
settings.value?.languageCode ?? AppSettings.defaults.languageCode);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Settings',
|
||||
title: strings.settings,
|
||||
body: settings.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
@@ -40,6 +43,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAndroid = Platform.isAndroid;
|
||||
final strings = AppStrings(settings.languageCode);
|
||||
final deviceInfo =
|
||||
isAndroid ? ref.watch(motionDeviceInfoProvider).value : null;
|
||||
final detectedHz =
|
||||
@@ -55,13 +59,13 @@ class _SettingsContent extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SettingsSection(
|
||||
title: 'General',
|
||||
title: strings.general,
|
||||
icon: Icons.tune_rounded,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.themeMode,
|
||||
decoration: InputDecoration(
|
||||
labelText: settings.languageCode == 'ru' ? 'Тема' : 'Theme',
|
||||
labelText: strings.theme,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'dark', child: Text('Dark')),
|
||||
@@ -73,37 +77,46 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.nsfwEnabled,
|
||||
title: const Text('Allow NSFW content'),
|
||||
title: Text(strings.allowNsfw),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(nsfwEnabled: value)),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.blurExplicitContent,
|
||||
title: const Text('Blur sensitive previews'),
|
||||
title: Text(strings.blurSensitive),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(blurExplicitContent: value)),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showPostBadges,
|
||||
title: const Text('Show post badges'),
|
||||
title: Text(strings.showPostBadges),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(showPostBadges: value)),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.allowDownloads,
|
||||
title: const Text('Allow manual downloads'),
|
||||
title: Text(strings.allowDownloads),
|
||||
onChanged: (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(
|
||||
title: settings.languageCode == 'ru' ? 'Внешний вид' : 'Appearance',
|
||||
title: strings.appearance,
|
||||
icon: Icons.palette_rounded,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.languageCode,
|
||||
decoration: const InputDecoration(labelText: 'Language / Язык'),
|
||||
decoration: InputDecoration(labelText: strings.language),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'ru', child: Text('Русский')),
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
@@ -127,10 +140,12 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.allowExperimentalUpdates,
|
||||
title: const Text('Receive beta / experimental updates'),
|
||||
subtitle: const Text(
|
||||
'When enabled, the updater also sees prerelease Gitea builds.',
|
||||
),
|
||||
title: Text(settings.languageCode == 'ru'
|
||||
? 'Получать beta / experimental обновления'
|
||||
: 'Receive beta / experimental updates'),
|
||||
subtitle: Text(settings.languageCode == 'ru'
|
||||
? 'Обновлятор будет видеть prerelease-сборки Gitea.'
|
||||
: 'When enabled, the updater also sees prerelease Gitea builds.'),
|
||||
onChanged: (value) => _update(
|
||||
ref,
|
||||
settings.copyWith(allowExperimentalUpdates: value),
|
||||
@@ -139,12 +154,12 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'Feed & Layout',
|
||||
title: strings.feedLayout,
|
||||
icon: Icons.dashboard_customize_rounded,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.mediaQualityMode,
|
||||
decoration: const InputDecoration(labelText: 'Media quality'),
|
||||
decoration: InputDecoration(labelText: strings.mediaQuality),
|
||||
items: [
|
||||
for (final mode in MediaQualityMode.values)
|
||||
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
|
||||
@@ -185,7 +200,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
_StepperTile(
|
||||
title: 'Desktop columns',
|
||||
title: strings.desktopColumns,
|
||||
value: settings.desktopColumns,
|
||||
min: 3,
|
||||
max: 8,
|
||||
@@ -195,7 +210,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
_StepperTile(
|
||||
title: 'Mobile columns',
|
||||
title: strings.mobileColumns,
|
||||
value: settings.mobileColumns,
|
||||
min: 1,
|
||||
max: 3,
|
||||
@@ -207,17 +222,17 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'Filters',
|
||||
title: strings.filters,
|
||||
icon: Icons.filter_alt_rounded,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.hideViewedPosts,
|
||||
title: const Text('Hide viewed posts'),
|
||||
title: Text(strings.hideViewed),
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(hideViewedPosts: value)),
|
||||
),
|
||||
_TagListEditor(
|
||||
title: 'Smart blacklist',
|
||||
title: strings.smartBlacklist,
|
||||
icon: Icons.visibility_off_rounded,
|
||||
tags: settings.smartBlacklistRules,
|
||||
helper:
|
||||
@@ -229,7 +244,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_TagListEditor(
|
||||
title: 'Whitelisted tags',
|
||||
title: strings.whitelistedTags,
|
||||
icon: Icons.verified_rounded,
|
||||
tags: settings.whitelistedTags,
|
||||
onChanged: (tags) => _update(
|
||||
@@ -240,11 +255,11 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'Storage',
|
||||
title: strings.storage,
|
||||
icon: Icons.storage_rounded,
|
||||
children: [
|
||||
_StepperTile(
|
||||
title: 'Cache max items',
|
||||
title: strings.cacheMaxItems,
|
||||
value: settings.cacheMaxItems,
|
||||
min: 100,
|
||||
max: 10000,
|
||||
@@ -259,14 +274,14 @@ class _SettingsContent extends ConsumerWidget {
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.clearCache(),
|
||||
icon: const Icon(Icons.cleaning_services_rounded),
|
||||
label: const Text('Clear cache'),
|
||||
label: Text(strings.clearCache),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.clearViewedHistory(),
|
||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
||||
label: const Text('Clear viewed'),
|
||||
label: Text(strings.clearViewed),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => ref
|
||||
@@ -318,7 +333,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'About',
|
||||
title: strings.about,
|
||||
icon: Icons.info_rounded,
|
||||
children: [
|
||||
_ActionGrid(
|
||||
@@ -326,7 +341,7 @@ class _SettingsContent extends ConsumerWidget {
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _checkUpdates(context, ref),
|
||||
icon: const Icon(Icons.system_update_alt_rounded),
|
||||
label: const Text('Check updates'),
|
||||
label: Text(strings.checkUpdates),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _showChangelog(context),
|
||||
@@ -336,12 +351,12 @@ class _SettingsContent extends ConsumerWidget {
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _exportJson(context, ref),
|
||||
icon: const Icon(Icons.upload_file_rounded),
|
||||
label: const Text('Export JSON'),
|
||||
label: Text(strings.exportJson),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _importDialog(context, ref),
|
||||
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 viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: 'Viewed',
|
||||
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear viewed history',
|
||||
@@ -49,6 +49,8 @@ class ViewedScreen extends ConsumerWidget {
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
@@ -61,7 +63,12 @@ class ViewedScreen extends ConsumerWidget {
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
await ref.read(favoriteServiceProvider).addFavorite(
|
||||
post,
|
||||
settings: settings,
|
||||
downloadManager:
|
||||
ref.read(downloadManagerServiceProvider),
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
},
|
||||
|
||||
@@ -330,7 +330,7 @@ class _Destination {
|
||||
'favorites' => 'Избранное',
|
||||
'viewed' => 'История',
|
||||
'collections' => 'Коллекции',
|
||||
'artists' => 'Авторы',
|
||||
'artists' => 'Артисты',
|
||||
'providers' => 'Провайдеры',
|
||||
'settings' => 'Настройки',
|
||||
_ => label,
|
||||
@@ -340,7 +340,8 @@ class _Destination {
|
||||
String mobileLabelFor(bool ru) {
|
||||
if (!ru) return mobileLabel;
|
||||
return switch (id) {
|
||||
'collections' => 'Колл.',
|
||||
'collections' => 'Доски',
|
||||
'favorites' => 'Любимое',
|
||||
_ => labelFor(true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class PostCard extends StatefulWidget {
|
||||
required this.showBadges,
|
||||
required this.isFavorite,
|
||||
required this.isViewed,
|
||||
required this.isDownloaded,
|
||||
required this.mediaQualityMode,
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
@@ -30,6 +32,8 @@ class PostCard extends StatefulWidget {
|
||||
final bool showBadges;
|
||||
final bool isFavorite;
|
||||
final bool isViewed;
|
||||
final bool isDownloaded;
|
||||
final MediaQualityMode mediaQualityMode;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback? onAddToCollection;
|
||||
@@ -54,6 +58,12 @@ class _PostCardState extends State<PostCard> {
|
||||
? post.width / post.height
|
||||
: _fallbackAspect(post.fileType);
|
||||
final mobile = MediaQuery.sizeOf(context).width < 700;
|
||||
final urls = MediaUrlSelector.feed(
|
||||
post,
|
||||
mode: widget.mediaQualityMode,
|
||||
mobile: mobile,
|
||||
);
|
||||
final imageUrl = urls.isEmpty ? post.previewUrl : urls.first;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
@@ -115,18 +125,27 @@ class _PostCardState extends State<PostCard> {
|
||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||
child: BlurContent(
|
||||
enabled: widget.blurExplicit && sensitive,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: post.previewUrl.isNotEmpty
|
||||
? post.previewUrl
|
||||
: post.sampleUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const LoadingSkeleton(),
|
||||
errorWidget: (context, url, error) => ColoredBox(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child:
|
||||
const Center(child: Icon(Icons.broken_image_rounded)),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
memCacheWidth: cacheWidth,
|
||||
maxWidthDiskCache: cacheWidth,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const LoadingSkeleton(),
|
||||
errorWidget: (context, url, error) => ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_rounded),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -152,6 +171,12 @@ class _PostCardState extends State<PostCard> {
|
||||
bottom: 8,
|
||||
child: _SeenBadge(),
|
||||
),
|
||||
if (widget.isDownloaded)
|
||||
Positioned(
|
||||
right: widget.isViewed ? 72 : 8,
|
||||
bottom: 8,
|
||||
child: const _DownloadedBadge(),
|
||||
),
|
||||
],
|
||||
if (widget.selected)
|
||||
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(
|
||||
BuildContext context, {
|
||||
required List<Collection> collections,
|
||||
|
||||
@@ -13,6 +13,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
required this.blurExplicit,
|
||||
required this.showBadges,
|
||||
required this.nsfwEnabled,
|
||||
required this.mediaQualityMode,
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
@@ -23,6 +24,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
this.selectedKeys = const {},
|
||||
this.favoriteKeys = const {},
|
||||
this.viewedKeys = const {},
|
||||
this.downloadedKeys = const {},
|
||||
this.loading = false,
|
||||
this.controller,
|
||||
super.key,
|
||||
@@ -33,6 +35,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
final bool blurExplicit;
|
||||
final bool showBadges;
|
||||
final bool nsfwEnabled;
|
||||
final MediaQualityMode mediaQualityMode;
|
||||
final ValueChanged<Post> onOpen;
|
||||
final ValueChanged<Post> onFavorite;
|
||||
final ValueChanged<Post>? onAddToCollection;
|
||||
@@ -43,6 +46,7 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
final Set<String> selectedKeys;
|
||||
final Set<String> favoriteKeys;
|
||||
final Set<String> viewedKeys;
|
||||
final Set<String> downloadedKeys;
|
||||
final bool loading;
|
||||
final ScrollController? controller;
|
||||
|
||||
@@ -65,6 +69,8 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
showBadges: showBadges,
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
isViewed: viewedKeys.contains(post.cacheKey),
|
||||
isDownloaded: downloadedKeys.contains(post.cacheKey),
|
||||
mediaQualityMode: mediaQualityMode,
|
||||
onOpen: () => onOpen(post),
|
||||
onFavorite: () => onFavorite(post),
|
||||
onPreview: onPreview == null ? null : () => onPreview!(post),
|
||||
|
||||
@@ -225,6 +225,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.0+20
|
||||
version: 2.0.1+21
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
@@ -10,6 +10,7 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_riverpod: ^2.5.1
|
||||
dio: ^5.4.3+1
|
||||
isar: ^3.1.0+1
|
||||
|
||||
Reference in New Issue
Block a user