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:
2026-09-03 20:20:58 +03:00
parent 1f624fc020
commit 84eeb909a1
30 changed files with 1446 additions and 92 deletions
+87
View File
@@ -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 '../../core/utils/result.dart';
import '../models/download_task.dart';
import '../models/post.dart';
import '../utils/media_quality.dart';
import 'download_service.dart';
import 'downloaded_media_service.dart';
import 'settings_service.dart';
class DownloadManagerService {
DownloadManagerService(
this._downloadService, {
DownloadedMediaService? downloadedMediaService,
}) : _downloadedMediaService = downloadedMediaService;
SettingsService? settingsService,
}) : _downloadedMediaService = downloadedMediaService,
_settingsService = settingsService;
final DownloadService _downloadService;
final DownloadedMediaService? _downloadedMediaService;
final SettingsService? _settingsService;
final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {};
final Set<String> _runningTaskIds = {};
@@ -100,9 +105,17 @@ class DownloadManagerService {
if (!_runningTaskIds.add(task.id)) return;
_set(task.copyWith(status: DownloadTaskStatus.running));
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
? await _downloadService.downloadPost(
task.post!,
folderTemplate: folderTemplate,
onProgress: (received, total) {
_updateProgress(task.id, received, total);
},
+92 -6
View File
@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -16,7 +15,54 @@ class DownloadService {
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, {
void Function(int received, int total)? onProgress,
}) async {
@@ -25,6 +71,38 @@ class DownloadService {
throw StateError('No downloadable URL for this post.');
}
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) {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, fileName);
@@ -38,19 +116,27 @@ class DownloadService {
'path': tempPath,
'fileName': fileName,
'mimeType': _mimeType(fileName, post.fileType),
if (subDir != null && subDir.isNotEmpty) 'subDir': subDir,
});
return saved ?? fileName;
}
final location = await getSaveLocation(suggestedName: fileName);
if (location == null) return null;
final downloads =
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(
url,
location.path,
savePath,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
return location.path;
return savePath;
}
Future<String?> downloadUrl(
@@ -46,4 +46,6 @@ class DownloadedMediaService {
}
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
Future<Result<int>> count() => _repository.count();
}
@@ -53,6 +53,11 @@ class AppSettings {
this.lastUpdateCheckAt,
this.defaultRatingFilter,
this.lastFeedRating,
this.amoledMode = false,
this.useDynamicColor = false,
this.gridMode = 'masonry',
this.searchPresets = const [],
this.downloadPathTemplate = '{Artist}/{ID}',
});
final List<String> favoriteArtists;
@@ -96,6 +101,11 @@ class AppSettings {
final String? lastUpdateCheckAt;
final String? defaultRatingFilter;
final String? lastFeedRating;
final bool amoledMode;
final bool useDynamicColor;
final String gridMode;
final List<String> searchPresets;
final String downloadPathTemplate;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
@@ -184,6 +194,11 @@ class AppSettings {
String? lastFeedRating,
bool clearLastFeedRating = false,
List<String>? favoriteArtists,
bool? amoledMode,
bool? useDynamicColor,
String? gridMode,
List<String>? searchPresets,
String? downloadPathTemplate,
}) {
return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
@@ -234,6 +249,11 @@ class AppSettings {
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
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,
'defaultRatingFilter': defaultRatingFilter,
'lastFeedRating': lastFeedRating,
'amoledMode': amoledMode,
'useDynamicColor': useDynamicColor,
'gridMode': gridMode,
'searchPresets': searchPresets,
'downloadPathTemplate': downloadPathTemplate,
};
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -378,6 +403,14 @@ class AppSettings {
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
defaultRatingFilter: json['defaultRatingFilter'] 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}',
);
}