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:
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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}',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user