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, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления.
## Возможности
## Что умеет
- 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 -1
View File
@@ -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
View File
@@ -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),
),
],
),
+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 appBuildNumber = 20;
const appDisplayVersion = '2.0.1';
const appBuildNumber = 21;
const appReleaseChannel = 'Stable';
+11
View File
@@ -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
View File
@@ -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,
),
);
},
);
}
+2
View File
@@ -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';
+34 -2
View File
@@ -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',
},
),
+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),
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();
}
+16 -1
View File
@@ -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,
+13 -2
View File
@@ -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) {
+38
View File
@@ -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
+1 -1
View File
@@ -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);
},
+3 -2
View File
@@ -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),
};
}
+55 -12
View File
@@ -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),
+8
View File
@@ -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
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 2.0.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