feat: release v2.0.5 with 10 QoL features (AMOLED, Dynamic Color, Grid switch, Share, Presets, Cache Manager, Backup & Gestures)
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-2.0.4-blueviolet?style=for-the-badge" alt="Version 2.0.4" />
|
<img src="https://img.shields.io/badge/Version-2.0.5-blueviolet?style=for-the-badge" alt="Version 2.0.5" />
|
||||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||||
@@ -20,10 +20,17 @@
|
|||||||
|
|
||||||
## ✨ Основные возможности
|
## ✨ Основные возможности
|
||||||
|
|
||||||
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
|
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
|
||||||
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
||||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
||||||
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
|
- **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
|
||||||
|
- **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
|
||||||
|
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки с мгновенным переключением в один клик.
|
||||||
|
- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
|
||||||
|
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти (база данных, ОЗУ, временные файлы) и выборочная очистка.
|
||||||
|
- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
|
||||||
|
- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
|
||||||
|
- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
|
||||||
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
|
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
|
||||||
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
|
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||||
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
|
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
|
||||||
@@ -55,9 +62,9 @@
|
|||||||
|
|
||||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||||
|
|
||||||
- **Android**: `Lunaris-v2.0.4.apk`
|
- **Android**: `Lunaris-v2.0.5.apk`
|
||||||
- **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz`
|
- **Linux (x64)**: `Lunaris-v2.0.5-linux-x64.tar.gz`
|
||||||
- **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip`
|
- **Windows**: `LunarisSetup-v2.0.5.exe` / `LunarisPortable-v2.0.5.zip`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
val path = call.argument<String>("path") ?: error("Missing path")
|
val path = call.argument<String>("path") ?: error("Missing path")
|
||||||
val fileName = call.argument<String>("fileName") ?: File(path).name
|
val fileName = call.argument<String>("fileName") ?: File(path).name
|
||||||
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
|
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
|
||||||
result.success(saveToDownloads(path, fileName, mimeType))
|
val subDir = call.argument<String>("subDir")
|
||||||
|
result.success(saveToDownloads(path, fileName, mimeType, subDir))
|
||||||
}
|
}
|
||||||
"openFile" -> {
|
"openFile" -> {
|
||||||
val path = call.argument<String>("path") ?: error("Missing path")
|
val path = call.argument<String>("path") ?: error("Missing path")
|
||||||
@@ -61,13 +62,19 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveToDownloads(path: String, fileName: String, mimeType: String): String {
|
private fun saveToDownloads(path: String, fileName: String, mimeType: String, subDir: String? = null): String {
|
||||||
val source = File(path)
|
val source = File(path)
|
||||||
|
val relPath = if (!subDir.isNullOrBlank()) {
|
||||||
|
val clean = subDir.trim().removePrefix("/").removeSuffix("/")
|
||||||
|
"${Environment.DIRECTORY_DOWNLOADS}/$clean"
|
||||||
|
} else {
|
||||||
|
Environment.DIRECTORY_DOWNLOADS
|
||||||
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
||||||
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
||||||
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
put(MediaStore.Downloads.RELATIVE_PATH, relPath)
|
||||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||||
}
|
}
|
||||||
val resolver = applicationContext.contentResolver
|
val resolver = applicationContext.contentResolver
|
||||||
@@ -83,9 +90,10 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
val baseDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
downloads.mkdirs()
|
val targetDir = if (!subDir.isNullOrBlank()) File(baseDownloads, subDir.trim()) else baseDownloads
|
||||||
val destination = File(downloads, fileName)
|
targetDir.mkdirs()
|
||||||
|
val destination = File(targetDir, fileName)
|
||||||
FileInputStream(source).use { input ->
|
FileInputStream(source).use { input ->
|
||||||
FileOutputStream(destination).use { output -> input.copyTo(output) }
|
FileOutputStream(destination).use { output -> input.copyTo(output) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -34,16 +35,23 @@ class GelRuleApp extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
data: (_) {
|
data: (_) {
|
||||||
final appSettings = settings.value ?? AppSettings.defaults;
|
final appSettings = settings.value ?? AppSettings.defaults;
|
||||||
|
final amoled = appSettings.amoledMode;
|
||||||
|
final useDynamic = appSettings.useDynamicColor;
|
||||||
|
return DynamicColorBuilder(
|
||||||
|
builder: (lightDynamic, darkDynamic) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Lunaris',
|
title: 'Lunaris',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: buildTheme(
|
theme: buildTheme(
|
||||||
Brightness.light,
|
Brightness.light,
|
||||||
seedColor: appSettings.appSeedColor,
|
seedColor: appSettings.appSeedColor,
|
||||||
|
dynamicColorScheme: useDynamic ? lightDynamic : null,
|
||||||
),
|
),
|
||||||
darkTheme: buildTheme(
|
darkTheme: buildTheme(
|
||||||
Brightness.dark,
|
Brightness.dark,
|
||||||
seedColor: appSettings.appSeedColor,
|
seedColor: appSettings.appSeedColor,
|
||||||
|
amoled: amoled,
|
||||||
|
dynamicColorScheme: useDynamic ? darkDynamic : null,
|
||||||
),
|
),
|
||||||
themeMode: parseThemeMode(appSettings.themeMode),
|
themeMode: parseThemeMode(appSettings.themeMode),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
@@ -51,6 +59,8 @@ class GelRuleApp extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.4';
|
const appDisplayVersion = '2.0.5';
|
||||||
const appBuildNumber = 26;
|
const appBuildNumber = 27;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.5',
|
||||||
|
title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
|
||||||
|
bullets: [
|
||||||
|
'AMOLED Pure Black theme (#000000) for OLED battery saving.',
|
||||||
|
'Material You dynamic color support on Android matching system wallpaper.',
|
||||||
|
'Grid layout mode switcher in the feed toolbar: Masonry, 1:1 Square Grid, and List.',
|
||||||
|
'Swipe down to dismiss and double-tap with animated heart overlay to favorite.',
|
||||||
|
'Direct file sharing (Share sheet) from post details.',
|
||||||
|
'Saved search presets with instant filtering and bookmark creation.',
|
||||||
|
'Smart download folder template support ({Artist}, {Provider}, {Service}, {ID}, {Date}).',
|
||||||
|
'Interactive Cache Manager screen with detailed storage breakdown and selective cleanup.',
|
||||||
|
'Full JSON backup export and import for seamless configuration sync.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.0.4',
|
version: '2.0.4',
|
||||||
title: 'Platform service selection & media type filtering in artist posts',
|
title: 'Platform service selection & media type filtering in artist posts',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../features/providers/presentation/provider_check_screen.dart';
|
|||||||
import '../features/providers/presentation/provider_form_screen.dart';
|
import '../features/providers/presentation/provider_form_screen.dart';
|
||||||
import '../features/providers/presentation/providers_screen.dart';
|
import '../features/providers/presentation/providers_screen.dart';
|
||||||
import '../features/search/presentation/search_screen.dart';
|
import '../features/search/presentation/search_screen.dart';
|
||||||
|
import '../features/settings/presentation/cache_manager_screen.dart';
|
||||||
import '../features/settings/presentation/hidden_posts_screen.dart';
|
import '../features/settings/presentation/hidden_posts_screen.dart';
|
||||||
import '../features/settings/presentation/settings_screen.dart';
|
import '../features/settings/presentation/settings_screen.dart';
|
||||||
import '../features/viewed/presentation/viewed_screen.dart';
|
import '../features/viewed/presentation/viewed_screen.dart';
|
||||||
@@ -126,6 +127,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/settings/hidden',
|
path: '/settings/hidden',
|
||||||
builder: (context, state) => const HiddenPostsScreen(),
|
builder: (context, state) => const HiddenPostsScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings/cache',
|
||||||
|
builder: (context, state) => const CacheManagerScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+34
-7
@@ -1,26 +1,53 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
ThemeData buildTheme(Brightness brightness, {int seedColor = 0xFFE84D8A}) {
|
ThemeData buildTheme(
|
||||||
|
Brightness brightness, {
|
||||||
|
int seedColor = 0xFFE84D8A,
|
||||||
|
bool amoled = false,
|
||||||
|
ColorScheme? dynamicColorScheme,
|
||||||
|
}) {
|
||||||
final dark = brightness == Brightness.dark;
|
final dark = brightness == Brightness.dark;
|
||||||
final scheme = ColorScheme.fromSeed(
|
final scheme = dynamicColorScheme ??
|
||||||
|
ColorScheme.fromSeed(
|
||||||
seedColor: Color(seedColor),
|
seedColor: Color(seedColor),
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final scaffoldBg = amoled && dark
|
||||||
|
? const Color(0xFF000000)
|
||||||
|
: dark
|
||||||
|
? const Color(0xFF101114)
|
||||||
|
: const Color(0xFFF8F8FA);
|
||||||
|
final cardBg = amoled && dark
|
||||||
|
? const Color(0xFF0A0A0A)
|
||||||
|
: dark
|
||||||
|
? const Color(0xFF191B20)
|
||||||
|
: Colors.white;
|
||||||
|
final navBg = amoled && dark
|
||||||
|
? const Color(0xFF000000)
|
||||||
|
: dark
|
||||||
|
? const Color(0xFF15161A)
|
||||||
|
: Colors.white;
|
||||||
|
final appBarBg = amoled && dark
|
||||||
|
? const Color(0xFF000000)
|
||||||
|
: dark
|
||||||
|
? const Color(0xFF101114)
|
||||||
|
: const Color(0xFFF8F8FA);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
colorScheme: scheme,
|
colorScheme: scheme,
|
||||||
scaffoldBackgroundColor:
|
scaffoldBackgroundColor: scaffoldBg,
|
||||||
dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
|
cardColor: cardBg,
|
||||||
cardColor: dark ? const Color(0xFF191B20) : Colors.white,
|
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
backgroundColor: dark ? const Color(0xFF15161A) : Colors.white,
|
backgroundColor: navBg,
|
||||||
selectedIconTheme: IconThemeData(color: scheme.primary),
|
selectedIconTheme: IconThemeData(color: scheme.primary),
|
||||||
),
|
),
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
|
backgroundColor: appBarBg,
|
||||||
foregroundColor: scheme.onSurface,
|
foregroundColor: scheme.onSurface,
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ export 'services/search_service.dart';
|
|||||||
export 'services/settings_service.dart';
|
export 'services/settings_service.dart';
|
||||||
export 'services/update_service.dart';
|
export 'services/update_service.dart';
|
||||||
export 'services/viewed_history_service.dart';
|
export 'services/viewed_history_service.dart';
|
||||||
|
export 'services/backup_service.dart';
|
||||||
export 'utils/smart_blacklist.dart';
|
export 'utils/smart_blacklist.dart';
|
||||||
export 'utils/media_quality.dart';
|
export 'utils/media_quality.dart';
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import '../services/search_service.dart';
|
|||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/update_service.dart';
|
import '../services/update_service.dart';
|
||||||
import '../services/viewed_history_service.dart';
|
import '../services/viewed_history_service.dart';
|
||||||
|
import '../services/backup_service.dart';
|
||||||
|
|
||||||
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
|
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
|
||||||
final database = await AppDatabase.open();
|
final database = await AppDatabase.open();
|
||||||
@@ -169,6 +170,7 @@ final downloadManagerServiceProvider = Provider<DownloadManagerService>((ref) {
|
|||||||
return DownloadManagerService(
|
return DownloadManagerService(
|
||||||
ref.watch(downloadServiceProvider),
|
ref.watch(downloadServiceProvider),
|
||||||
downloadedMediaService: ref.watch(downloadedMediaServiceProvider),
|
downloadedMediaService: ref.watch(downloadedMediaServiceProvider),
|
||||||
|
settingsService: ref.watch(settingsServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,3 +217,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
|
|||||||
ref.watch(settingsServiceProvider),
|
ref.watch(settingsServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final backupServiceProvider = Provider<BackupService>((ref) {
|
||||||
|
return BackupService(ref.watch(settingsServiceProvider));
|
||||||
|
});
|
||||||
|
|||||||
@@ -83,5 +83,11 @@ class DownloadedMediaRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<int>> count() {
|
||||||
|
return _databaseService.safeRead((isar) async {
|
||||||
|
return isar.downloadedMediaEntitys.count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
String fileNameForPath(String path) => p.basename(path);
|
String fileNameForPath(String path) => p.basename(path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
import '../../core/errors/failure.dart';
|
||||||
|
import '../../core/utils/result.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
|
class BackupService {
|
||||||
|
BackupService(this._settingsService);
|
||||||
|
|
||||||
|
final SettingsService _settingsService;
|
||||||
|
|
||||||
|
Future<String> createBackupJson() async {
|
||||||
|
final result = await _settingsService.getSettings();
|
||||||
|
final settings =
|
||||||
|
result is Success<AppSettings> ? result.data : AppSettings.defaults;
|
||||||
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
|
return encoder.convert({
|
||||||
|
'version': 1,
|
||||||
|
'createdAt': DateTime.now().toIso8601String(),
|
||||||
|
'settings': settings.toJson(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<AppSettings>> restoreFromJson(String jsonContent) async {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(jsonContent);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return const Error(Failure(
|
||||||
|
code: 'invalid_json',
|
||||||
|
message: 'Неверный формат JSON файла',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
final rawSettings = decoded['settings'] is Map<String, dynamic>
|
||||||
|
? decoded['settings'] as Map<String, dynamic>
|
||||||
|
: decoded;
|
||||||
|
final settings = AppSettings.fromJson(rawSettings);
|
||||||
|
await _settingsService.updateSettings(settings);
|
||||||
|
return Success(settings);
|
||||||
|
} catch (e) {
|
||||||
|
return Error(Failure(
|
||||||
|
code: 'import_error',
|
||||||
|
message: 'Ошибка импорта настроек: $e',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> exportBackup() async {
|
||||||
|
final json = await createBackupJson();
|
||||||
|
final fileName =
|
||||||
|
'lunaris_backup_${DateTime.now().millisecondsSinceEpoch}.json';
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final file = File('${tempDir.path}/$fileName');
|
||||||
|
await file.writeAsString(json);
|
||||||
|
await Share.shareXFiles([XFile(file.path)], text: 'Lunaris Backup');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final location = await getSaveLocation(
|
||||||
|
suggestedName: fileName,
|
||||||
|
acceptedTypeGroups: [
|
||||||
|
const XTypeGroup(label: 'JSON files', extensions: ['json']),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (location == null) return false;
|
||||||
|
final file = File(location.path);
|
||||||
|
await file.writeAsString(json);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<AppSettings>?> importBackup() async {
|
||||||
|
const typeGroup = XTypeGroup(
|
||||||
|
label: 'JSON files',
|
||||||
|
extensions: ['json'],
|
||||||
|
);
|
||||||
|
final file = await openFile(acceptedTypeGroups: [typeGroup]);
|
||||||
|
if (file == null) return null;
|
||||||
|
final content = await file.readAsString();
|
||||||
|
return restoreFromJson(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../../core/utils/result.dart';
|
||||||
import '../models/download_task.dart';
|
import '../models/download_task.dart';
|
||||||
import '../models/post.dart';
|
import '../models/post.dart';
|
||||||
import '../utils/media_quality.dart';
|
import '../utils/media_quality.dart';
|
||||||
import 'download_service.dart';
|
import 'download_service.dart';
|
||||||
import 'downloaded_media_service.dart';
|
import 'downloaded_media_service.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
class DownloadManagerService {
|
class DownloadManagerService {
|
||||||
DownloadManagerService(
|
DownloadManagerService(
|
||||||
this._downloadService, {
|
this._downloadService, {
|
||||||
DownloadedMediaService? downloadedMediaService,
|
DownloadedMediaService? downloadedMediaService,
|
||||||
}) : _downloadedMediaService = downloadedMediaService;
|
SettingsService? settingsService,
|
||||||
|
}) : _downloadedMediaService = downloadedMediaService,
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
final DownloadService _downloadService;
|
final DownloadService _downloadService;
|
||||||
final DownloadedMediaService? _downloadedMediaService;
|
final DownloadedMediaService? _downloadedMediaService;
|
||||||
|
final SettingsService? _settingsService;
|
||||||
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
||||||
final Map<String, DownloadTask> _tasks = {};
|
final Map<String, DownloadTask> _tasks = {};
|
||||||
final Set<String> _runningTaskIds = {};
|
final Set<String> _runningTaskIds = {};
|
||||||
@@ -100,9 +105,17 @@ class DownloadManagerService {
|
|||||||
if (!_runningTaskIds.add(task.id)) return;
|
if (!_runningTaskIds.add(task.id)) return;
|
||||||
_set(task.copyWith(status: DownloadTaskStatus.running));
|
_set(task.copyWith(status: DownloadTaskStatus.running));
|
||||||
try {
|
try {
|
||||||
|
String? folderTemplate;
|
||||||
|
if (_settingsService != null) {
|
||||||
|
final settingsRes = await _settingsService.getSettings();
|
||||||
|
if (settingsRes is Success<AppSettings>) {
|
||||||
|
folderTemplate = settingsRes.data.downloadPathTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
final saved = task.sourceUrl == null
|
final saved = task.sourceUrl == null
|
||||||
? await _downloadService.downloadPost(
|
? await _downloadService.downloadPost(
|
||||||
task.post!,
|
task.post!,
|
||||||
|
folderTemplate: folderTemplate,
|
||||||
onProgress: (received, total) {
|
onProgress: (received, total) {
|
||||||
_updateProgress(task.id, received, total);
|
_updateProgress(task.id, received, total);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:file_selector/file_selector.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -16,7 +15,54 @@ class DownloadService {
|
|||||||
|
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
Future<String?> downloadPost(
|
String resolveSubDir(Post post, String template) {
|
||||||
|
if (template.isEmpty) return '';
|
||||||
|
String artist = 'Unknown';
|
||||||
|
final artistGroup =
|
||||||
|
post.tagGroups['artist'] ?? post.tagGroups['creator'];
|
||||||
|
if (artistGroup != null && artistGroup.isNotEmpty) {
|
||||||
|
artist = artistGroup.first;
|
||||||
|
} else {
|
||||||
|
for (final tag in post.tags) {
|
||||||
|
final lower = tag.toLowerCase();
|
||||||
|
if (lower.startsWith('artist:') || lower.startsWith('creator:')) {
|
||||||
|
final parts = tag.split(':');
|
||||||
|
if (parts.length > 1 && parts[1].isNotEmpty) {
|
||||||
|
artist = parts.sublist(1).join(':');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artist = artist.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final dateStr =
|
||||||
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
var cleanTemplate = template.trim();
|
||||||
|
if (cleanTemplate.toLowerCase().endsWith('/{id}')) {
|
||||||
|
cleanTemplate =
|
||||||
|
cleanTemplate.substring(0, cleanTemplate.length - 5).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolved = cleanTemplate
|
||||||
|
.replaceAll(
|
||||||
|
'{Artist}', artist)
|
||||||
|
.replaceAll(
|
||||||
|
'{Provider}', post.providerName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
|
||||||
|
.replaceAll(
|
||||||
|
'{Service}', post.providerId.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
|
||||||
|
.replaceAll(
|
||||||
|
'{Rating}', post.rating.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
|
||||||
|
.replaceAll(
|
||||||
|
'{ID}', post.id.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
|
||||||
|
.replaceAll('{Date}', dateStr);
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> prepareFileForShare(
|
||||||
Post post, {
|
Post post, {
|
||||||
void Function(int received, int total)? onProgress,
|
void Function(int received, int total)? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -25,6 +71,38 @@ class DownloadService {
|
|||||||
throw StateError('No downloadable URL for this post.');
|
throw StateError('No downloadable URL for this post.');
|
||||||
}
|
}
|
||||||
final fileName = _fileName(post, url);
|
final fileName = _fileName(post, url);
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final shareDir = Directory(p.join(tempDir.path, 'shares'));
|
||||||
|
if (!shareDir.existsSync()) {
|
||||||
|
shareDir.createSync(recursive: true);
|
||||||
|
}
|
||||||
|
final filePath = p.join(shareDir.path, fileName);
|
||||||
|
if (File(filePath).existsSync() && File(filePath).lengthSync() > 0) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
await _dio.download(
|
||||||
|
url,
|
||||||
|
filePath,
|
||||||
|
onReceiveProgress: onProgress,
|
||||||
|
options: Options(headers: _headersFor(post)),
|
||||||
|
);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> downloadPost(
|
||||||
|
Post post, {
|
||||||
|
String? folderTemplate,
|
||||||
|
void Function(int received, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final url = _downloadUrl(post);
|
||||||
|
if (url == null) {
|
||||||
|
throw StateError('No downloadable URL for this post.');
|
||||||
|
}
|
||||||
|
final fileName = _fileName(post, url);
|
||||||
|
final subDir = folderTemplate != null && folderTemplate.isNotEmpty
|
||||||
|
? resolveSubDir(post, folderTemplate)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final tempPath = p.join(tempDir.path, fileName);
|
final tempPath = p.join(tempDir.path, fileName);
|
||||||
@@ -38,19 +116,27 @@ class DownloadService {
|
|||||||
'path': tempPath,
|
'path': tempPath,
|
||||||
'fileName': fileName,
|
'fileName': fileName,
|
||||||
'mimeType': _mimeType(fileName, post.fileType),
|
'mimeType': _mimeType(fileName, post.fileType),
|
||||||
|
if (subDir != null && subDir.isNotEmpty) 'subDir': subDir,
|
||||||
});
|
});
|
||||||
return saved ?? fileName;
|
return saved ?? fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
final location = await getSaveLocation(suggestedName: fileName);
|
final downloads =
|
||||||
if (location == null) return null;
|
await getDownloadsDirectory() ?? await getTemporaryDirectory();
|
||||||
|
final targetDir = subDir != null && subDir.isNotEmpty
|
||||||
|
? Directory(p.join(downloads.path, subDir))
|
||||||
|
: downloads;
|
||||||
|
if (!targetDir.existsSync()) {
|
||||||
|
targetDir.createSync(recursive: true);
|
||||||
|
}
|
||||||
|
final savePath = p.join(targetDir.path, fileName);
|
||||||
await _dio.download(
|
await _dio.download(
|
||||||
url,
|
url,
|
||||||
location.path,
|
savePath,
|
||||||
onReceiveProgress: onProgress,
|
onReceiveProgress: onProgress,
|
||||||
options: Options(headers: _headersFor(post)),
|
options: Options(headers: _headersFor(post)),
|
||||||
);
|
);
|
||||||
return location.path;
|
return savePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> downloadUrl(
|
Future<String?> downloadUrl(
|
||||||
|
|||||||
@@ -46,4 +46,6 @@ class DownloadedMediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
|
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
|
||||||
|
|
||||||
|
Future<Result<int>> count() => _repository.count();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ class AppSettings {
|
|||||||
this.lastUpdateCheckAt,
|
this.lastUpdateCheckAt,
|
||||||
this.defaultRatingFilter,
|
this.defaultRatingFilter,
|
||||||
this.lastFeedRating,
|
this.lastFeedRating,
|
||||||
|
this.amoledMode = false,
|
||||||
|
this.useDynamicColor = false,
|
||||||
|
this.gridMode = 'masonry',
|
||||||
|
this.searchPresets = const [],
|
||||||
|
this.downloadPathTemplate = '{Artist}/{ID}',
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<String> favoriteArtists;
|
final List<String> favoriteArtists;
|
||||||
@@ -96,6 +101,11 @@ class AppSettings {
|
|||||||
final String? lastUpdateCheckAt;
|
final String? lastUpdateCheckAt;
|
||||||
final String? defaultRatingFilter;
|
final String? defaultRatingFilter;
|
||||||
final String? lastFeedRating;
|
final String? lastFeedRating;
|
||||||
|
final bool amoledMode;
|
||||||
|
final bool useDynamicColor;
|
||||||
|
final String gridMode;
|
||||||
|
final List<String> searchPresets;
|
||||||
|
final String downloadPathTemplate;
|
||||||
|
|
||||||
static const defaults = AppSettings(
|
static const defaults = AppSettings(
|
||||||
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
||||||
@@ -184,6 +194,11 @@ class AppSettings {
|
|||||||
String? lastFeedRating,
|
String? lastFeedRating,
|
||||||
bool clearLastFeedRating = false,
|
bool clearLastFeedRating = false,
|
||||||
List<String>? favoriteArtists,
|
List<String>? favoriteArtists,
|
||||||
|
bool? amoledMode,
|
||||||
|
bool? useDynamicColor,
|
||||||
|
String? gridMode,
|
||||||
|
List<String>? searchPresets,
|
||||||
|
String? downloadPathTemplate,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||||
@@ -234,6 +249,11 @@ class AppSettings {
|
|||||||
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
|
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
|
||||||
lastFeedRating:
|
lastFeedRating:
|
||||||
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
|
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
|
||||||
|
amoledMode: amoledMode ?? this.amoledMode,
|
||||||
|
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
||||||
|
gridMode: gridMode ?? this.gridMode,
|
||||||
|
searchPresets: searchPresets ?? this.searchPresets,
|
||||||
|
downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +299,11 @@ class AppSettings {
|
|||||||
'lastUpdateCheckAt': lastUpdateCheckAt,
|
'lastUpdateCheckAt': lastUpdateCheckAt,
|
||||||
'defaultRatingFilter': defaultRatingFilter,
|
'defaultRatingFilter': defaultRatingFilter,
|
||||||
'lastFeedRating': lastFeedRating,
|
'lastFeedRating': lastFeedRating,
|
||||||
|
'amoledMode': amoledMode,
|
||||||
|
'useDynamicColor': useDynamicColor,
|
||||||
|
'gridMode': gridMode,
|
||||||
|
'searchPresets': searchPresets,
|
||||||
|
'downloadPathTemplate': downloadPathTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
@@ -378,6 +403,14 @@ class AppSettings {
|
|||||||
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
||||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||||
lastFeedRating: json['lastFeedRating'] as String?,
|
lastFeedRating: json['lastFeedRating'] as String?,
|
||||||
|
amoledMode: (json['amoledMode'] as bool?) ?? false,
|
||||||
|
useDynamicColor: (json['useDynamicColor'] as bool?) ?? false,
|
||||||
|
gridMode: (json['gridMode'] as String?) ?? 'masonry',
|
||||||
|
searchPresets: List<String>.from(
|
||||||
|
(json['searchPresets'] as List?) ?? const [],
|
||||||
|
),
|
||||||
|
downloadPathTemplate:
|
||||||
|
(json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+6
@@ -62,6 +62,12 @@ class CacheService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<int>> getCachedPostsCount() {
|
||||||
|
return _databaseService.safeRead((isar) async {
|
||||||
|
return isar.cachedPostEntitys.count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _evictOverflow(Isar isar, int maxItems) async {
|
Future<void> _evictOverflow(Isar isar, int maxItems) async {
|
||||||
final entities = await isar.cachedPostEntitys.where().findAll();
|
final entities = await isar.cachedPostEntitys.where().findAll();
|
||||||
if (entities.length <= maxItems) return;
|
if (entities.length <= maxItems) return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -17,6 +18,7 @@ import '../../../shared/widgets/post_card.dart';
|
|||||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
import '../../collections/presentation/collection_form_dialog.dart';
|
import '../../collections/presentation/collection_form_dialog.dart';
|
||||||
import '../../favorites/presentation/favorites_controller.dart';
|
import '../../favorites/presentation/favorites_controller.dart';
|
||||||
|
import '../../settings/presentation/settings_controller.dart';
|
||||||
import 'feed_controller.dart';
|
import 'feed_controller.dart';
|
||||||
import 'widgets/feed_toolbar.dart';
|
import 'widgets/feed_toolbar.dart';
|
||||||
|
|
||||||
@@ -179,6 +181,20 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
child: AdaptiveScaffold(
|
child: AdaptiveScaffold(
|
||||||
title: 'Feed',
|
title: 'Feed',
|
||||||
titleWidget: const _FeedTitle(),
|
titleWidget: const _FeedTitle(),
|
||||||
|
actions: [
|
||||||
|
if (feed.value != null && feed.value!.selectedTags.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bookmark_add_rounded),
|
||||||
|
tooltip: 'Сохранить пресет',
|
||||||
|
onPressed: () => _saveCurrentPreset(
|
||||||
|
context, feed.value!.selectedTags, settings),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_gridModeIcon(settings.gridMode)),
|
||||||
|
tooltip: 'Вид сетки',
|
||||||
|
onPressed: () => _cycleGridMode(settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
floatingActionButton: _showScrollToTop
|
floatingActionButton: _showScrollToTop
|
||||||
? FloatingActionButton.small(
|
? FloatingActionButton.small(
|
||||||
tooltip: 'Наверх',
|
tooltip: 'Наверх',
|
||||||
@@ -201,6 +217,26 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
),
|
),
|
||||||
data: (state) => Column(
|
data: (state) => Column(
|
||||||
children: [
|
children: [
|
||||||
|
// --- Search Presets Bar ---
|
||||||
|
if (settings.searchPresets.isNotEmpty)
|
||||||
|
_SearchPresetsBar(
|
||||||
|
presets: settings.searchPresets,
|
||||||
|
currentTags: state.selectedTags,
|
||||||
|
onApply: (preset) {
|
||||||
|
final decoded =
|
||||||
|
_decodePreset(preset);
|
||||||
|
_submitSearch(decoded['tags'] ?? '');
|
||||||
|
},
|
||||||
|
onDelete: (preset) async {
|
||||||
|
final updated = settings.searchPresets
|
||||||
|
.where((p) => p != preset)
|
||||||
|
.toList();
|
||||||
|
await ref
|
||||||
|
.read(settingsControllerProvider.notifier)
|
||||||
|
.saveSettings(
|
||||||
|
settings.copyWith(searchPresets: updated));
|
||||||
|
},
|
||||||
|
),
|
||||||
FeedToolbar(
|
FeedToolbar(
|
||||||
selectedTags: state.selectedTags,
|
selectedTags: state.selectedTags,
|
||||||
selectedProviderIds: state.selectedProviderIds,
|
selectedProviderIds: state.selectedProviderIds,
|
||||||
@@ -294,6 +330,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
viewedKeys: viewedKeys,
|
viewedKeys: viewedKeys,
|
||||||
selectionMode: _selectionMode,
|
selectionMode: _selectionMode,
|
||||||
selectedKeys: _selectedKeys,
|
selectedKeys: _selectedKeys,
|
||||||
|
gridMode: settings.gridMode,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
extra: PostNavigationContext(
|
extra: PostNavigationContext(
|
||||||
@@ -527,6 +564,75 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
|
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
|
||||||
await ref.read(downloadManagerServiceProvider).start(post);
|
await ref.read(downloadManagerServiceProvider).start(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Grid mode helpers ────────────────────────────────────────────
|
||||||
|
IconData _gridModeIcon(String mode) {
|
||||||
|
return switch (mode) {
|
||||||
|
'grid' => Icons.grid_4x4_rounded,
|
||||||
|
'list' => Icons.view_list_rounded,
|
||||||
|
_ => Icons.dashboard_rounded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cycleGridMode(AppSettings settings) async {
|
||||||
|
const modes = ['masonry', 'grid', 'list'];
|
||||||
|
final idx = modes.indexOf(settings.gridMode);
|
||||||
|
final next = modes[(idx + 1) % modes.length];
|
||||||
|
await ref
|
||||||
|
.read(settingsControllerProvider.notifier)
|
||||||
|
.saveSettings(settings.copyWith(gridMode: next));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search preset helpers ─────────────────────────────────────────
|
||||||
|
Map<String, String> _decodePreset(String raw) {
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
return {'name': map['name'] as String, 'tags': map['tags'] as String};
|
||||||
|
} catch (_) {
|
||||||
|
return {'name': raw, 'tags': raw};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _showSavePresetDialog(
|
||||||
|
BuildContext context, String tags) async {
|
||||||
|
final ctrl = TextEditingController(text: tags);
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Сохранить пресет'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Название пресета',
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
|
||||||
|
child: const Text('Сохранить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveCurrentPreset(
|
||||||
|
BuildContext context, List<String> tags, AppSettings settings) async {
|
||||||
|
if (tags.isEmpty) return;
|
||||||
|
final tagsStr = tags.join(' ');
|
||||||
|
final name = await _showSavePresetDialog(context, tagsStr);
|
||||||
|
if (name == null || name.isEmpty || !context.mounted) return;
|
||||||
|
final preset = jsonEncode({'name': name, 'tags': tagsStr});
|
||||||
|
final updated = [...settings.searchPresets, preset];
|
||||||
|
await ref
|
||||||
|
.read(settingsControllerProvider.notifier)
|
||||||
|
.saveSettings(settings.copyWith(searchPresets: updated));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RefreshIntent extends Intent {
|
class _RefreshIntent extends Intent {
|
||||||
@@ -631,3 +737,86 @@ class _BatchActionBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Search Presets Bar ─────────────────────────────────────────────────────
|
||||||
|
class _SearchPresetsBar extends StatelessWidget {
|
||||||
|
const _SearchPresetsBar({
|
||||||
|
required this.presets,
|
||||||
|
required this.currentTags,
|
||||||
|
required this.onApply,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<String> presets;
|
||||||
|
final List<String> currentTags;
|
||||||
|
final ValueChanged<String> onApply;
|
||||||
|
final ValueChanged<String> onDelete;
|
||||||
|
|
||||||
|
Map<String, String> _decode(String raw) {
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
return {'name': map['name'] as String, 'tags': map['tags'] as String};
|
||||||
|
} catch (_) {
|
||||||
|
return {'name': raw, 'tags': raw};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: presets.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final raw = presets[i];
|
||||||
|
final decoded = _decode(raw);
|
||||||
|
final name = decoded['name'] ?? raw;
|
||||||
|
final tags = decoded['tags'] ?? raw;
|
||||||
|
final active = currentTags.join(' ') == tags;
|
||||||
|
return GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text('«$name»'),
|
||||||
|
content: Text('Теги: $tags'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
onDelete(raw);
|
||||||
|
},
|
||||||
|
child: const Text('Удалить',
|
||||||
|
style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Закрыть'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: FilterChip(
|
||||||
|
label: Text(name),
|
||||||
|
selected: active,
|
||||||
|
onSelected: (_) => onApply(raw),
|
||||||
|
selectedColor: scheme.primaryContainer,
|
||||||
|
checkmarkColor: scheme.onPrimaryContainer,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
|
||||||
|
fontWeight:
|
||||||
|
active ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../app/app.dart';
|
import '../../../app/app.dart';
|
||||||
@@ -269,6 +270,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
: null,
|
: null,
|
||||||
onDeleteLocalFile: () =>
|
onDeleteLocalFile: () =>
|
||||||
_deleteLocalFile(context, ref, post),
|
_deleteLocalFile(context, ref, post),
|
||||||
|
onShare: () => _sharePost(context, ref, post),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Wrap(
|
Wrap(
|
||||||
@@ -377,6 +379,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
? () => _download(context, ref, post)
|
? () => _download(context, ref, post)
|
||||||
: null,
|
: null,
|
||||||
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
|
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
|
||||||
|
onShare: () => _sharePost(context, ref, post),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
@@ -448,6 +451,30 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _sharePost(BuildContext context, WidgetRef ref, Post post) async {
|
||||||
|
final strings = ref.read(appStringsProvider);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
strings.ru
|
||||||
|
? 'Подготовка файла к отправке...'
|
||||||
|
: 'Preparing file for sharing...',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final path =
|
||||||
|
await ref.read(downloadServiceProvider).prepareFileForShare(post);
|
||||||
|
await Share.shareXFiles([XFile(path)], text: post.source ?? post.fileUrl);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Ошибка отправки: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showMobileQuickActions(
|
Future<void> _showMobileQuickActions(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@@ -558,6 +585,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
download: strings.download,
|
download: strings.download,
|
||||||
deleteLocalFile: strings.deleteLocalFile,
|
deleteLocalFile: strings.deleteLocalFile,
|
||||||
hidePost: strings.hidePost,
|
hidePost: strings.hidePost,
|
||||||
|
share: strings.ru ? 'Поделиться' : 'Share',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class PostActionBar extends StatelessWidget {
|
|||||||
this.onOpenSource,
|
this.onOpenSource,
|
||||||
this.onDownload,
|
this.onDownload,
|
||||||
this.onDeleteLocalFile,
|
this.onDeleteLocalFile,
|
||||||
|
this.onShare,
|
||||||
this.downloaded = false,
|
this.downloaded = false,
|
||||||
this.labels,
|
this.labels,
|
||||||
this.onSimilar,
|
this.onSimilar,
|
||||||
@@ -25,6 +26,7 @@ class PostActionBar extends StatelessWidget {
|
|||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
final VoidCallback? onDeleteLocalFile;
|
final VoidCallback? onDeleteLocalFile;
|
||||||
|
final VoidCallback? onShare;
|
||||||
final VoidCallback? onSimilar;
|
final VoidCallback? onSimilar;
|
||||||
final VoidCallback? onHide;
|
final VoidCallback? onHide;
|
||||||
final bool downloaded;
|
final bool downloaded;
|
||||||
@@ -91,6 +93,12 @@ class PostActionBar extends StatelessWidget {
|
|||||||
onPressed: onHide,
|
onPressed: onHide,
|
||||||
icon: const Icon(Icons.visibility_off_rounded),
|
icon: const Icon(Icons.visibility_off_rounded),
|
||||||
),
|
),
|
||||||
|
if (onShare != null)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
tooltip: text.share,
|
||||||
|
onPressed: onShare,
|
||||||
|
icon: const Icon(Icons.share_rounded),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,6 +116,7 @@ class PostActionLabels {
|
|||||||
this.download = 'Download',
|
this.download = 'Download',
|
||||||
this.deleteLocalFile = 'Delete local file',
|
this.deleteLocalFile = 'Delete local file',
|
||||||
this.hidePost = 'Hide post locally',
|
this.hidePost = 'Hide post locally',
|
||||||
|
this.share = 'Share',
|
||||||
});
|
});
|
||||||
|
|
||||||
final String favorite;
|
final String favorite;
|
||||||
@@ -120,4 +129,5 @@ class PostActionLabels {
|
|||||||
final String download;
|
final String download;
|
||||||
final String deleteLocalFile;
|
final String deleteLocalFile;
|
||||||
final String hidePost;
|
final String hidePost;
|
||||||
|
final String share;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../../app/app.dart';
|
||||||
|
import '../../../backend/backend.dart';
|
||||||
|
import '../../../core/utils/result.dart';
|
||||||
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
|
|
||||||
|
class CacheStats {
|
||||||
|
const CacheStats({
|
||||||
|
required this.cachedPostsCount,
|
||||||
|
required this.imageMemoryBytes,
|
||||||
|
required this.imageMemoryCount,
|
||||||
|
required this.tempDirectoryBytes,
|
||||||
|
required this.downloadedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int cachedPostsCount;
|
||||||
|
final int imageMemoryBytes;
|
||||||
|
final int imageMemoryCount;
|
||||||
|
final int tempDirectoryBytes;
|
||||||
|
final int downloadedCount;
|
||||||
|
|
||||||
|
int get totalEstimatedBytes =>
|
||||||
|
imageMemoryBytes + tempDirectoryBytes + (cachedPostsCount * 2048);
|
||||||
|
|
||||||
|
static const empty = CacheStats(
|
||||||
|
cachedPostsCount: 0,
|
||||||
|
imageMemoryBytes: 0,
|
||||||
|
imageMemoryCount: 0,
|
||||||
|
tempDirectoryBytes: 0,
|
||||||
|
downloadedCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cacheStatsProvider = FutureProvider.autoDispose<CacheStats>((ref) async {
|
||||||
|
final cacheService = ref.watch(cacheServiceProvider);
|
||||||
|
final downloadedService = ref.watch(downloadedMediaServiceProvider);
|
||||||
|
|
||||||
|
final postsRes = await cacheService.getCachedPostsCount();
|
||||||
|
final postsCount = postsRes is Success<int> ? postsRes.data : 0;
|
||||||
|
|
||||||
|
final downloadedRes = await downloadedService.count();
|
||||||
|
final downloadedCount =
|
||||||
|
downloadedRes is Success<int> ? downloadedRes.data : 0;
|
||||||
|
|
||||||
|
final imageCache = PaintingBinding.instance.imageCache;
|
||||||
|
final memBytes = imageCache.currentSizeBytes;
|
||||||
|
final memCount = imageCache.currentSize;
|
||||||
|
|
||||||
|
var tempBytes = 0;
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
if (tempDir.existsSync()) {
|
||||||
|
await for (final file
|
||||||
|
in tempDir.list(recursive: true, followLinks: false)) {
|
||||||
|
if (file is File) {
|
||||||
|
tempBytes += await file.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return CacheStats(
|
||||||
|
cachedPostsCount: postsCount,
|
||||||
|
imageMemoryBytes: memBytes,
|
||||||
|
imageMemoryCount: memCount,
|
||||||
|
tempDirectoryBytes: tempBytes,
|
||||||
|
downloadedCount: downloadedCount,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
class CacheManagerScreen extends ConsumerStatefulWidget {
|
||||||
|
const CacheManagerScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CacheManagerScreen> createState() => _CacheManagerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
|
||||||
|
bool _isClearing = false;
|
||||||
|
|
||||||
|
String _formatBytes(int bytes) {
|
||||||
|
if (bytes <= 0) return '0 B';
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
}
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearPostsCache() async {
|
||||||
|
setState(() => _isClearing = true);
|
||||||
|
await ref.read(cacheServiceProvider).clear();
|
||||||
|
ref.invalidate(cacheStatsProvider);
|
||||||
|
if (mounted) setState(() => _isClearing = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearImagesCache() async {
|
||||||
|
setState(() => _isClearing = true);
|
||||||
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
|
ref.invalidate(cacheStatsProvider);
|
||||||
|
if (mounted) setState(() => _isClearing = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearTempFiles() async {
|
||||||
|
setState(() => _isClearing = true);
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
if (tempDir.existsSync()) {
|
||||||
|
final entities = tempDir.listSync(recursive: false);
|
||||||
|
for (final entity in entities) {
|
||||||
|
try {
|
||||||
|
entity.deleteSync(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
ref.invalidate(cacheStatsProvider);
|
||||||
|
if (mounted) setState(() => _isClearing = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearAllCache() async {
|
||||||
|
setState(() => _isClearing = true);
|
||||||
|
await ref.read(cacheServiceProvider).clear();
|
||||||
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
if (tempDir.existsSync()) {
|
||||||
|
final entities = tempDir.listSync(recursive: false);
|
||||||
|
for (final entity in entities) {
|
||||||
|
try {
|
||||||
|
entity.deleteSync(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
ref.invalidate(cacheStatsProvider);
|
||||||
|
if (mounted) setState(() => _isClearing = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final statsAsync = ref.watch(cacheStatsProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final strings = ref.watch(appStringsProvider);
|
||||||
|
|
||||||
|
return AdaptiveScaffold(
|
||||||
|
title: strings.ru ? 'Менеджер кэша' : 'Cache Manager',
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: strings.ru ? 'Обновить' : 'Refresh',
|
||||||
|
onPressed: () => ref.invalidate(cacheStatsProvider),
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
body: statsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Ошибка: $e')),
|
||||||
|
data: (stats) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Summary card
|
||||||
|
Card(
|
||||||
|
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.35),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.primary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.pie_chart_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_formatBytes(stats.totalEstimatedBytes),
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
strings.ru
|
||||||
|
? 'Общий предполагаемый объем кэша'
|
||||||
|
: 'Total estimated cache size',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Detail cards
|
||||||
|
_CacheCategoryTile(
|
||||||
|
icon: Icons.article_rounded,
|
||||||
|
title: strings.ru ? 'Кэш постов (база данных)' : 'Posts cache (DB)',
|
||||||
|
subtitle: strings.ru
|
||||||
|
? '${stats.cachedPostsCount} сохраненных постов'
|
||||||
|
: '${stats.cachedPostsCount} cached posts',
|
||||||
|
sizeText: _formatBytes(stats.cachedPostsCount * 2048),
|
||||||
|
onClear: _isClearing ? null : _clearPostsCache,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
_CacheCategoryTile(
|
||||||
|
icon: Icons.image_rounded,
|
||||||
|
title: strings.ru ? 'Кэш картинок в памяти' : 'Image memory cache',
|
||||||
|
subtitle: strings.ru
|
||||||
|
? '${stats.imageMemoryCount} картинок в ОЗУ'
|
||||||
|
: '${stats.imageMemoryCount} images in RAM',
|
||||||
|
sizeText: _formatBytes(stats.imageMemoryBytes),
|
||||||
|
onClear: _isClearing ? null : _clearImagesCache,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
_CacheCategoryTile(
|
||||||
|
icon: Icons.folder_open_rounded,
|
||||||
|
title: strings.ru ? 'Временные файлы и медиа' : 'Temp files & media',
|
||||||
|
subtitle: strings.ru
|
||||||
|
? 'Файлы для отправки и временные буферы'
|
||||||
|
: 'Share exports & temporary buffers',
|
||||||
|
sizeText: _formatBytes(stats.tempDirectoryBytes),
|
||||||
|
onClear: _isClearing ? null : _clearTempFiles,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
_CacheCategoryTile(
|
||||||
|
icon: Icons.download_done_rounded,
|
||||||
|
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
|
||||||
|
subtitle: strings.ru
|
||||||
|
? '${stats.downloadedCount} файлов в истории скачиваний'
|
||||||
|
: '${stats.downloadedCount} files in download history',
|
||||||
|
sizeText: '',
|
||||||
|
showClear: false,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Global action button
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _isClearing ? null : _clearAllCache,
|
||||||
|
icon: _isClearing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.delete_sweep_rounded),
|
||||||
|
label: Text(
|
||||||
|
strings.ru ? 'Очистить весь кэш' : 'Clear all cache',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheCategoryTile extends StatelessWidget {
|
||||||
|
const _CacheCategoryTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.sizeText,
|
||||||
|
this.onClear,
|
||||||
|
this.showClear = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String sizeText;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final bool showClear;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: theme.colorScheme.primaryContainer,
|
||||||
|
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||||
|
child: Icon(icon, size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (sizeText.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
sizeText,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
if (showClear && onClear != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Очистить',
|
||||||
|
onPressed: onClear,
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded, size: 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../app/app.dart';
|
||||||
import '../../../app/app_version.dart';
|
import '../../../app/app_version.dart';
|
||||||
import '../../../app/changelog.dart';
|
import '../../../app/changelog.dart';
|
||||||
import '../../../app/app_strings.dart';
|
import '../../../app/app_strings.dart';
|
||||||
@@ -112,6 +113,99 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
settings.copyWith(autoDownloadFavorites: value),
|
settings.copyWith(autoDownloadFavorites: value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (settings.allowDownloads) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: const [
|
||||||
|
'{Artist}/{ID}',
|
||||||
|
'{Artist}',
|
||||||
|
'{Provider}/{Artist}',
|
||||||
|
'{Service}/{ID}',
|
||||||
|
'{Date}/{Artist}',
|
||||||
|
].contains(settings.downloadPathTemplate)
|
||||||
|
? settings.downloadPathTemplate
|
||||||
|
: 'custom',
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: settings.languageCode == 'ru'
|
||||||
|
? 'Структура папок скачивания'
|
||||||
|
: 'Download folder structure',
|
||||||
|
helperText: '{Artist}, {Provider}, {Service}, {ID}, {Date}',
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: '{Artist}/{ID}',
|
||||||
|
child: Text('{Artist}/{ID}'),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: '{Artist}',
|
||||||
|
child: Text('{Artist}'),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: '{Provider}/{Artist}',
|
||||||
|
child: Text('{Provider}/{Artist}'),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: '{Service}/{ID}',
|
||||||
|
child: Text('{Service}/{ID}'),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: '{Date}/{Artist}',
|
||||||
|
child: Text('{Date}/{Artist}'),
|
||||||
|
),
|
||||||
|
if (![
|
||||||
|
'{Artist}/{ID}',
|
||||||
|
'{Artist}',
|
||||||
|
'{Provider}/{Artist}',
|
||||||
|
'{Service}/{ID}',
|
||||||
|
'{Date}/{Artist}',
|
||||||
|
].contains(settings.downloadPathTemplate))
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: settings.downloadPathTemplate,
|
||||||
|
child: Text(settings.downloadPathTemplate),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: 'custom',
|
||||||
|
child: Text('Пользовательский шаблон...'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value == 'custom') {
|
||||||
|
final ctrl = TextEditingController(
|
||||||
|
text: settings.downloadPathTemplate);
|
||||||
|
final custom = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Шаблон пути скачивания'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Шаблон',
|
||||||
|
helperText:
|
||||||
|
'Доступно: {Artist}, {Provider}, {Service}, {ID}, {Date}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Отмена')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, ctrl.text.trim()),
|
||||||
|
child: const Text('Сохранить')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (custom != null && custom.isNotEmpty) {
|
||||||
|
_update(ref,
|
||||||
|
settings.copyWith(downloadPathTemplate: custom));
|
||||||
|
}
|
||||||
|
} else if (value != null) {
|
||||||
|
_update(ref,
|
||||||
|
settings.copyWith(downloadPathTemplate: value));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
@@ -130,6 +224,33 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
settings.copyWith(languageCode: value ?? 'ru'),
|
settings.copyWith(languageCode: value ?? 'ru'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
value: settings.amoledMode,
|
||||||
|
title: Text(settings.languageCode == 'ru'
|
||||||
|
? 'AMOLED Pure Black тема'
|
||||||
|
: 'AMOLED Pure Black theme'),
|
||||||
|
subtitle: Text(settings.languageCode == 'ru'
|
||||||
|
? 'Абсолютно черный фон (#000000) для OLED дисплеев.'
|
||||||
|
: 'Pitch-black background (#000000) for OLED displays.'),
|
||||||
|
onChanged: (value) => _update(
|
||||||
|
ref,
|
||||||
|
settings.copyWith(amoledMode: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isAndroid)
|
||||||
|
SwitchListTile(
|
||||||
|
value: settings.useDynamicColor,
|
||||||
|
title: Text(settings.languageCode == 'ru'
|
||||||
|
? 'Динамические цвета Material You'
|
||||||
|
: 'Material You Dynamic Colors'),
|
||||||
|
subtitle: Text(settings.languageCode == 'ru'
|
||||||
|
? 'Палитра интерфейса подстраивается под обои системы.'
|
||||||
|
: 'Palette adapts to device wallpaper colors.'),
|
||||||
|
onChanged: (value) => _update(
|
||||||
|
ref,
|
||||||
|
settings.copyWith(useDynamicColor: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_ColorSwatches(
|
_ColorSwatches(
|
||||||
selected: settings.appSeedColor,
|
selected: settings.appSeedColor,
|
||||||
@@ -280,6 +401,13 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
icon: const Icon(Icons.cleaning_services_rounded),
|
icon: const Icon(Icons.cleaning_services_rounded),
|
||||||
label: Text(strings.clearCache),
|
label: Text(strings.clearCache),
|
||||||
),
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () => context.go('/settings/cache'),
|
||||||
|
icon: const Icon(Icons.pie_chart_rounded),
|
||||||
|
label: Text(settings.languageCode == 'ru'
|
||||||
|
? 'Менеджер кэша'
|
||||||
|
: 'Cache Manager'),
|
||||||
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
@@ -369,6 +497,20 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
icon: const Icon(Icons.download_for_offline_rounded),
|
icon: const Icon(Icons.download_for_offline_rounded),
|
||||||
label: Text(strings.importJson),
|
label: Text(strings.importJson),
|
||||||
),
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _exportBackupFile(context, ref),
|
||||||
|
icon: const Icon(Icons.file_upload_rounded),
|
||||||
|
label: Text(settings.languageCode == 'ru'
|
||||||
|
? 'Экспорт бэкапа'
|
||||||
|
: 'Export backup'),
|
||||||
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _importBackupFile(context, ref),
|
||||||
|
icon: const Icon(Icons.file_download_rounded),
|
||||||
|
label: Text(settings.languageCode == 'ru'
|
||||||
|
? 'Импорт бэкапа'
|
||||||
|
: 'Import backup'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -413,6 +555,33 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _exportBackupFile(BuildContext context, WidgetRef ref) async {
|
||||||
|
final success = await ref.read(backupServiceProvider).exportBackup();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (success) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Бэкап сохранен / отправлен')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importBackupFile(BuildContext context, WidgetRef ref) async {
|
||||||
|
final result = await ref.read(backupServiceProvider).importBackup();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (result == null) return;
|
||||||
|
if (result is Success<AppSettings>) {
|
||||||
|
ref.invalidate(appSettingsProvider);
|
||||||
|
ref.invalidate(settingsControllerProvider);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Настройки успешно импортированы!')),
|
||||||
|
);
|
||||||
|
} else if (result is Error<AppSettings>) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Ошибка импорта: ${result.failure.message}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
|
Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
|
|||||||
@@ -54,26 +54,63 @@ class PostCard extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<PostCard> createState() => _PostCardState();
|
ConsumerState<PostCard> createState() => _PostCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PostCardState extends ConsumerState<PostCard> {
|
class _PostCardState extends ConsumerState<PostCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
static final Map<String, Post> _resolvedRealbooruPosts = {};
|
static final Map<String, Post> _resolvedRealbooruPosts = {};
|
||||||
|
|
||||||
bool _hovered = false;
|
bool _hovered = false;
|
||||||
Post? _resolvedPost;
|
Post? _resolvedPost;
|
||||||
|
bool _showHeart = false;
|
||||||
|
late final AnimationController _heartController;
|
||||||
|
late final Animation<double> _heartScale;
|
||||||
|
late final Animation<double> _heartOpacity;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
|
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
|
||||||
_maybeResolveRealbooruPost();
|
_maybeResolveRealbooruPost();
|
||||||
|
_heartController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
);
|
||||||
|
_heartScale = TweenSequence<double>([
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween(begin: 0.4, end: 1.3)
|
||||||
|
.chain(CurveTween(curve: Curves.easeOut)),
|
||||||
|
weight: 40),
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween(begin: 1.3, end: 1.0)
|
||||||
|
.chain(CurveTween(curve: Curves.easeIn)),
|
||||||
|
weight: 30),
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween(begin: 1.0, end: 1.0), weight: 30),
|
||||||
|
]).animate(_heartController);
|
||||||
|
_heartOpacity = TweenSequence<double>([
|
||||||
|
TweenSequenceItem(tween: ConstantTween(1.0), weight: 60),
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween(begin: 1.0, end: 0.0)
|
||||||
|
.chain(CurveTween(curve: Curves.easeIn)),
|
||||||
|
weight: 40),
|
||||||
|
]).animate(_heartController);
|
||||||
|
_heartController.addStatusListener((status) {
|
||||||
|
if (status == AnimationStatus.completed && mounted) {
|
||||||
|
setState(() => _showHeart = false);
|
||||||
|
_heartController.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant PostCard oldWidget) {
|
void dispose() {
|
||||||
super.didUpdateWidget(oldWidget);
|
_heartController.dispose();
|
||||||
if (oldWidget.post.cacheKey != widget.post.cacheKey) {
|
super.dispose();
|
||||||
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
|
|
||||||
_maybeResolveRealbooruPost();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _triggerDoubleTapFavorite() {
|
||||||
|
widget.onFavorite();
|
||||||
|
setState(() => _showHeart = true);
|
||||||
|
_heartController.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -97,6 +134,7 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
}
|
}
|
||||||
widget.onOpen();
|
widget.onOpen();
|
||||||
},
|
},
|
||||||
|
onDoubleTap: _triggerDoubleTapFavorite,
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
if (widget.selectionMode && widget.onToggleSelected != null) {
|
if (widget.selectionMode && widget.onToggleSelected != null) {
|
||||||
widget.onToggleSelected!();
|
widget.onToggleSelected!();
|
||||||
@@ -247,6 +285,32 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
onPressed: widget.onFavorite,
|
onPressed: widget.onFavorite,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Double-tap heart animation overlay
|
||||||
|
if (_showHeart)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _heartController,
|
||||||
|
builder: (context, _) => Opacity(
|
||||||
|
opacity: _heartOpacity.value,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: _heartScale.value,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.favorite_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 72,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.black54,
|
||||||
|
blurRadius: 12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_hovered || mobile,
|
ignoring: !_hovered || mobile,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
this.downloadedKeys = const {},
|
this.downloadedKeys = const {},
|
||||||
this.loading = false,
|
this.loading = false,
|
||||||
this.controller,
|
this.controller,
|
||||||
|
this.gridMode = 'masonry',
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,20 +50,15 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
final Set<String> downloadedKeys;
|
final Set<String> downloadedKeys;
|
||||||
final bool loading;
|
final bool loading;
|
||||||
final ScrollController? controller;
|
final ScrollController? controller;
|
||||||
|
final String gridMode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final mobile = Responsive.isMobile(context);
|
final mobile = Responsive.isMobile(context);
|
||||||
return MasonryGridView.count(
|
final pad = EdgeInsets.all(mobile ? 8 : 16);
|
||||||
controller: controller,
|
final spacing = mobile ? 8.0 : 12.0;
|
||||||
padding: EdgeInsets.all(mobile ? 8 : 16),
|
|
||||||
crossAxisCount: columns,
|
Widget buildCard(Post post) {
|
||||||
mainAxisSpacing: mobile ? 8 : 12,
|
|
||||||
crossAxisSpacing: mobile ? 8 : 12,
|
|
||||||
itemCount: posts.length + (loading ? columns : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index >= posts.length) return const LoadingSkeleton();
|
|
||||||
final post = posts[index];
|
|
||||||
return PostCard(
|
return PostCard(
|
||||||
post: post,
|
post: post,
|
||||||
blurExplicit: blurExplicit && !nsfwEnabled,
|
blurExplicit: blurExplicit && !nsfwEnabled,
|
||||||
@@ -82,6 +78,61 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
onAddToCollection:
|
onAddToCollection:
|
||||||
onAddToCollection == null ? null : () => onAddToCollection!(post),
|
onAddToCollection == null ? null : () => onAddToCollection!(post),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gridMode == 'grid') {
|
||||||
|
// Uniform square grid
|
||||||
|
return GridView.builder(
|
||||||
|
controller: controller,
|
||||||
|
padding: pad,
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: columns,
|
||||||
|
mainAxisSpacing: spacing,
|
||||||
|
crossAxisSpacing: spacing,
|
||||||
|
childAspectRatio: 1.0,
|
||||||
|
),
|
||||||
|
itemCount: posts.length + (loading ? columns : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= posts.length) return const LoadingSkeleton();
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: buildCard(posts[index]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gridMode == 'list') {
|
||||||
|
// Vertical list with larger cards
|
||||||
|
return ListView.separated(
|
||||||
|
controller: controller,
|
||||||
|
padding: pad,
|
||||||
|
separatorBuilder: (_, __) => SizedBox(height: spacing),
|
||||||
|
itemCount: posts.length + (loading ? 3 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= posts.length) return const LoadingSkeleton();
|
||||||
|
return buildCard(posts[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: masonry
|
||||||
|
return MasonryGridView.count(
|
||||||
|
controller: controller,
|
||||||
|
padding: pad,
|
||||||
|
crossAxisCount: columns,
|
||||||
|
mainAxisSpacing: spacing,
|
||||||
|
crossAxisSpacing: spacing,
|
||||||
|
itemCount: posts.length + (loading ? columns : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= posts.length) return const LoadingSkeleton();
|
||||||
|
return buildCard(posts[index]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <dynamic_color/dynamic_color_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
|
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
|
||||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||||
@@ -14,6 +15,9 @@
|
|||||||
#include <volume_controller/volume_controller_plugin.h>
|
#include <volume_controller/volume_controller_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
|
||||||
|
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
dynamic_color
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
isar_flutter_libs
|
isar_flutter_libs
|
||||||
media_kit_libs_linux
|
media_kit_libs_linux
|
||||||
|
|||||||
+32
-16
@@ -273,6 +273,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
dynamic_color:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dynamic_color
|
||||||
|
sha256: "4aeb76de323e8eb660bab5557d826ad7ebbf1434a598f0c6aa8a11a9696a6757"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -289,14 +297,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
ffi_leak_tracker:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: ffi_leak_tracker
|
|
||||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.1.2"
|
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -739,18 +739,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
|
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.1.0"
|
version: "9.0.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: package_info_plus_platform_interface
|
name: package_info_plus_platform_interface
|
||||||
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.0"
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -911,6 +911,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.1"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.1.4"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.2"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1208,10 +1224,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus
|
name: wakelock_plus
|
||||||
sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53"
|
sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.1"
|
version: "1.5.2"
|
||||||
wakelock_plus_platform_interface:
|
wakelock_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1256,10 +1272,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.0.4+26
|
version: 2.0.5+27
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
@@ -31,6 +31,8 @@ dependencies:
|
|||||||
file_selector: ^1.0.3
|
file_selector: ^1.0.3
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
media_kit_libs_android_video: ^1.3.8
|
media_kit_libs_android_video: ^1.3.8
|
||||||
|
share_plus: ^10.0.0
|
||||||
|
dynamic_color: ^1.7.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/backend/models/post.dart';
|
||||||
|
import 'package:gel_rule_app/backend/services/backup_service.dart';
|
||||||
|
import 'package:gel_rule_app/backend/services/download_service.dart';
|
||||||
|
import 'package:gel_rule_app/backend/services/settings_service.dart';
|
||||||
|
import 'package:gel_rule_app/core/utils/result.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('QoL Features Unit Tests', () {
|
||||||
|
test('AppSettings JSON roundtrip retains new QoL fields', () {
|
||||||
|
const initial = AppSettings(
|
||||||
|
enabledProviderIds: ['gelbooru'],
|
||||||
|
nsfwEnabled: true,
|
||||||
|
cacheTtlHours: 12,
|
||||||
|
cacheMaxItems: 500,
|
||||||
|
providerPriority: {'gelbooru': 0},
|
||||||
|
themeMode: 'dark',
|
||||||
|
languageCode: 'ru',
|
||||||
|
appSeedColor: 0xFF112233,
|
||||||
|
hiddenTabs: [],
|
||||||
|
allowExperimentalUpdates: false,
|
||||||
|
desktopColumns: 4,
|
||||||
|
mobileColumns: 2,
|
||||||
|
blurExplicitContent: false,
|
||||||
|
allowDownloads: true,
|
||||||
|
autoDownloadFavorites: true,
|
||||||
|
selectedFeedProviderIds: [],
|
||||||
|
showPostBadges: true,
|
||||||
|
defaultTopPeriodFilter: 'none',
|
||||||
|
blacklistedTags: [],
|
||||||
|
whitelistedTags: [],
|
||||||
|
smartBlacklistRules: [],
|
||||||
|
hideViewedPosts: false,
|
||||||
|
mediaQualityMode: 'auto',
|
||||||
|
motionRefreshMode: 'auto',
|
||||||
|
autoBatterySaver60Hz: false,
|
||||||
|
videoPlayerMuted: true,
|
||||||
|
videoPlayerHalfVolume: false,
|
||||||
|
videoPlayerLoop: false,
|
||||||
|
videoPlayerCover: false,
|
||||||
|
videoPlaybackPositions: {},
|
||||||
|
hiddenPostKeys: [],
|
||||||
|
diagnosticLogLines: [],
|
||||||
|
lastFeedTags: [],
|
||||||
|
lastFeedProviderIds: [],
|
||||||
|
lastFeedTopPeriod: 'none',
|
||||||
|
lastFeedScrollOffset: 0,
|
||||||
|
amoledMode: true,
|
||||||
|
useDynamicColor: true,
|
||||||
|
gridMode: 'grid',
|
||||||
|
searchPresets: ['{"name":"Favs","tags":"cat"}'],
|
||||||
|
downloadPathTemplate: '{Provider}/{Artist}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = initial.toJson();
|
||||||
|
final restored = AppSettings.fromJson(json);
|
||||||
|
|
||||||
|
expect(restored.amoledMode, isTrue);
|
||||||
|
expect(restored.useDynamicColor, isTrue);
|
||||||
|
expect(restored.gridMode, equals('grid'));
|
||||||
|
expect(restored.searchPresets, equals(['{"name":"Favs","tags":"cat"}']));
|
||||||
|
expect(restored.downloadPathTemplate, equals('{Provider}/{Artist}'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DownloadService resolveSubDir replaces template variables correctly', () {
|
||||||
|
final downloadService = DownloadService();
|
||||||
|
final post = Post(
|
||||||
|
id: '12345',
|
||||||
|
providerId: 'pawchive',
|
||||||
|
providerName: 'Pawchive',
|
||||||
|
previewUrl: 'https://example.com/prev.jpg',
|
||||||
|
sampleUrl: 'https://example.com/sample.jpg',
|
||||||
|
fileUrl: 'https://example.com/file.jpg',
|
||||||
|
tags: ['artist:CoolArtist', 'cat', 'safe'],
|
||||||
|
rating: 'safe',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
fileType: 'jpg',
|
||||||
|
score: 42,
|
||||||
|
tagGroups: {
|
||||||
|
'artist': ['CoolArtist'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final resolved = downloadService.resolveSubDir(post, '{Provider}/{Artist}');
|
||||||
|
expect(resolved, equals('Pawchive/CoolArtist'));
|
||||||
|
|
||||||
|
final singleDir = downloadService.resolveSubDir(post, '{Artist}');
|
||||||
|
expect(singleDir, equals('CoolArtist'));
|
||||||
|
|
||||||
|
final withId = downloadService.resolveSubDir(post, '{Artist}/{ID}');
|
||||||
|
expect(withId, equals('CoolArtist'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BackupService exports and restores settings accurately', () async {
|
||||||
|
final fakeSettings = _FakeSettingsService();
|
||||||
|
final backupService = BackupService(fakeSettings);
|
||||||
|
|
||||||
|
fakeSettings.current = AppSettings.defaults.copyWith(
|
||||||
|
amoledMode: true,
|
||||||
|
useDynamicColor: true,
|
||||||
|
gridMode: 'list',
|
||||||
|
downloadPathTemplate: '{Provider}/{ID}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final exportedJson = await backupService.createBackupJson();
|
||||||
|
expect(exportedJson, contains('version'));
|
||||||
|
expect(exportedJson, contains('settings'));
|
||||||
|
|
||||||
|
// Change settings to something else
|
||||||
|
fakeSettings.current = AppSettings.defaults.copyWith(
|
||||||
|
amoledMode: false,
|
||||||
|
gridMode: 'masonry',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore from backup
|
||||||
|
final restoreResult = await backupService.restoreFromJson(exportedJson);
|
||||||
|
expect(restoreResult.isSuccess, isTrue);
|
||||||
|
expect(fakeSettings.current.amoledMode, isTrue);
|
||||||
|
expect(fakeSettings.current.gridMode, equals('list'));
|
||||||
|
expect(fakeSettings.current.downloadPathTemplate, equals('{Provider}/{ID}'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeSettingsService extends Fake implements SettingsService {
|
||||||
|
AppSettings current = AppSettings.defaults;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<AppSettings>> getSettings() async => Success(current);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<void>> updateSettings(AppSettings settings) async {
|
||||||
|
current = settings;
|
||||||
|
return const Success(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,16 +7,20 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
|
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
|
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
|
||||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||||
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
#include <volume_controller/volume_controller_plugin_c_api.h>
|
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
|
DynamicColorPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
IsarFlutterLibsPluginRegisterWithRegistrar(
|
IsarFlutterLibsPluginRegisterWithRegistrar(
|
||||||
@@ -25,6 +29,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
||||||
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
||||||
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
VolumeControllerPluginCApiRegisterWithRegistrar(
|
VolumeControllerPluginCApiRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
connectivity_plus
|
connectivity_plus
|
||||||
|
dynamic_color
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
isar_flutter_libs
|
isar_flutter_libs
|
||||||
media_kit_libs_windows_video
|
media_kit_libs_windows_video
|
||||||
media_kit_video
|
media_kit_video
|
||||||
|
share_plus
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
volume_controller
|
volume_controller
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user