generated from dellevin/template
新增本地自动备份
This commit is contained in:
@@ -5,6 +5,7 @@ import 'utils/app_theme.dart';
|
||||
import 'utils/app_router.dart';
|
||||
import 'utils/user_prefs.dart';
|
||||
import 'utils/webdav_service.dart';
|
||||
import 'utils/auto_backup_service.dart';
|
||||
import 'providers/app_provider.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -27,10 +28,17 @@ void main() async {
|
||||
/// 初始化自动备份
|
||||
Future<void> _initAutoBackup() async {
|
||||
try {
|
||||
final isEnabled = await WebDAVService.instance.isAutoSyncEnabled();
|
||||
if (isEnabled) {
|
||||
// 初始化 WebDAV 自动同步
|
||||
final isWebDAVEnabled = await WebDAVService.instance.isAutoSyncEnabled();
|
||||
if (isWebDAVEnabled) {
|
||||
await WebDAVService.instance.startAutoSync();
|
||||
}
|
||||
|
||||
// 初始化本地自动备份
|
||||
final isLocalAutoBackupEnabled = await AutoBackupService.instance.getEnabled();
|
||||
if (isLocalAutoBackupEnabled) {
|
||||
await AutoBackupService.instance.start();
|
||||
}
|
||||
} catch (e) {
|
||||
print('初始化自动备份失败: $e');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../utils/backup_service.dart';
|
||||
import '../utils/auto_backup_service.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
|
||||
/// 本地备份页面
|
||||
@@ -16,6 +17,30 @@ class BackupPage extends StatefulWidget {
|
||||
class _BackupPageState extends State<BackupPage> {
|
||||
bool _isExporting = false;
|
||||
bool _isImporting = false;
|
||||
bool _autoBackupEnabled = false;
|
||||
bool _isLoading = true;
|
||||
List<File> _autoBackupFiles = [];
|
||||
String? _backupDirPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAutoBackupStatus();
|
||||
}
|
||||
|
||||
Future<void> _loadAutoBackupStatus() async {
|
||||
final enabled = await AutoBackupService.instance.getEnabled();
|
||||
final files = await AutoBackupService.instance.getBackupFiles();
|
||||
final dirPath = await AutoBackupService.instance.getBackupDirectoryPath();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_autoBackupEnabled = enabled;
|
||||
_autoBackupFiles = files;
|
||||
_backupDirPath = dirPath;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,9 +49,22 @@ class _BackupPageState extends State<BackupPage> {
|
||||
appBar: AppBar(
|
||||
title: const Text('本地备份'),
|
||||
),
|
||||
body: ListView(
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
// 自动备份开关
|
||||
_buildAutoBackupSection(),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 自动备份文件列表
|
||||
if (_autoBackupFiles.isNotEmpty) ...[
|
||||
_buildBackupFilesSection(),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
// 导出数据
|
||||
_buildSection(
|
||||
title: '导出数据',
|
||||
@@ -298,4 +336,189 @@ class _BackupPageState extends State<BackupPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建自动备份区域
|
||||
Widget _buildAutoBackupSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 24,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'自动本地备份',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _autoBackupEnabled,
|
||||
onChanged: (value) async {
|
||||
setState(() => _autoBackupEnabled = value);
|
||||
await AutoBackupService.instance.setEnabled(value);
|
||||
if (value) {
|
||||
ToastUtil.show(context, '自动备份已开启,每2分钟备份一次');
|
||||
} else {
|
||||
ToastUtil.show(context, '自动备份已关闭');
|
||||
}
|
||||
// 刷新文件列表
|
||||
await _loadAutoBackupStatus();
|
||||
},
|
||||
activeColor: const Color(0xFF1A1A1A),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'每隔2分钟自动备份到下载目录/mooknote文件夹,最多保留10个备份文件',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF666666),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (_backupDirPath != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'备份位置: $_backupDirPath',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建备份文件列表区域
|
||||
Widget _buildBackupFilesSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 24,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'自动备份文件 (${_autoBackupFiles.length}/10)',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._autoBackupFiles.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final file = entry.value;
|
||||
final fileName = file.path.split('/').last;
|
||||
final stat = file.statSync();
|
||||
final size = _formatFileSize(stat.size);
|
||||
final modified = _formatDateTime(stat.modified);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: index < _autoBackupFiles.length - 1
|
||||
? const BorderSide(color: Color(0xFFE5E5E5))
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.backup,
|
||||
size: 18,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fileName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$modified · $size',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (index == 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: const Text(
|
||||
'最新',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化文件大小
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
/// 格式化日期时间
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,33 +119,60 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
// 图片区域(仅在纯文本模式下显示)
|
||||
if (note.contentType == 'plain_text' && note.images.isNotEmpty)
|
||||
Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: note.images.length,
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
onTap: () => _showImagePreview(context, note.images, index),
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 图片标题
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.image_outlined,
|
||||
size: 14,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
child: Image.file(
|
||||
File(note.images[index]),
|
||||
fit: BoxFit.cover,
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'图片 (${note.images.length})',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 图片列表
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: note.images.length,
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
onTap: () => _showImagePreview(context, note.images, index),
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Image.file(
|
||||
File(note.images[index]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -282,16 +309,17 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
/// 构建纯文本内容
|
||||
Widget _buildPlainTextContent(Note note) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
child: SelectableText(
|
||||
note.content,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.8,
|
||||
height: 1.9,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -316,7 +344,12 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
|
||||
/// 跳转到编辑页面
|
||||
void _navigateToEdit(BuildContext context) {
|
||||
Navigator.pushNamed(context, '/note-form', arguments: widget.note).then((_) {
|
||||
// 从 Provider 获取最新的笔记数据,确保图片等字段是最新的
|
||||
final currentNote = context.read<AppProvider>().notes.firstWhere(
|
||||
(n) => n.id == widget.note.id,
|
||||
orElse: () => widget.note,
|
||||
);
|
||||
Navigator.pushNamed(context, '/note-form', arguments: currentNote).then((_) {
|
||||
context.read<AppProvider>().loadNotes();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,14 +21,12 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
bool _isLoading = false;
|
||||
bool _isConfigured = false;
|
||||
bool _obscurePassword = true;
|
||||
bool _isAutoSyncEnabled = false;
|
||||
SyncDirection _syncDirection = SyncDirection.upload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConfig();
|
||||
_loadAutoSyncStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -40,33 +38,6 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载自动同步状态
|
||||
Future<void> _loadAutoSyncStatus() async {
|
||||
final enabled = await WebDAVService.instance.isAutoSyncEnabled();
|
||||
setState(() => _isAutoSyncEnabled = enabled);
|
||||
}
|
||||
|
||||
/// 切换自动同步
|
||||
Future<void> _toggleAutoSync(bool value) async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
if (value) {
|
||||
await WebDAVService.instance.startAutoSync();
|
||||
ToastUtil.show(context, '自动备份已开启,每5分钟执行一次');
|
||||
} else {
|
||||
await WebDAVService.instance.stopAutoSync();
|
||||
ToastUtil.show(context, '自动备份已关闭');
|
||||
}
|
||||
|
||||
setState(() => _isAutoSyncEnabled = value);
|
||||
} catch (e) {
|
||||
ToastUtil.show(context, '操作失败: $e');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载已保存的配置
|
||||
Future<void> _loadConfig() async {
|
||||
final config = await WebDAVService.instance.getConfig();
|
||||
@@ -357,56 +328,6 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
if (_isConfigured) ...[
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 自动备份开关
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 20,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'自动备份',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'每5分钟自动备份一次,保留最近10个备份',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _isAutoSyncEnabled,
|
||||
onChanged: _isLoading ? null : _toggleAutoSync,
|
||||
activeColor: const Color(0xFF1A1A1A),
|
||||
inactiveThumbColor: const Color(0xFF999999),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 同步方向选择
|
||||
const Text(
|
||||
'同步方向',
|
||||
|
||||
216
lib/utils/auto_backup_service.dart
Normal file
216
lib/utils/auto_backup_service.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'backup_service.dart';
|
||||
|
||||
/// 自动备份服务 - 定时自动备份到下载目录
|
||||
class AutoBackupService {
|
||||
static final AutoBackupService instance = AutoBackupService._init();
|
||||
|
||||
AutoBackupService._init();
|
||||
|
||||
Timer? _timer;
|
||||
bool _isRunning = false;
|
||||
|
||||
static const String _prefsKey = 'auto_backup_enabled';
|
||||
static const String _backupDirName = 'mooknote';
|
||||
static const int _maxBackups = 10;
|
||||
static const Duration _backupInterval = Duration(minutes: 2);
|
||||
|
||||
/// 是否正在运行
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
/// 获取自动备份状态
|
||||
Future<bool> getEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_prefsKey) ?? false;
|
||||
}
|
||||
|
||||
/// 设置自动备份状态
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKey, enabled);
|
||||
|
||||
if (enabled) {
|
||||
await start();
|
||||
} else {
|
||||
await stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动自动备份
|
||||
Future<void> start() async {
|
||||
if (_isRunning) return;
|
||||
|
||||
// 立即执行一次备份
|
||||
await _performBackup();
|
||||
|
||||
// 启动定时器
|
||||
_timer = Timer.periodic(_backupInterval, (_) async {
|
||||
await _performBackup();
|
||||
});
|
||||
|
||||
_isRunning = true;
|
||||
}
|
||||
|
||||
/// 停止自动备份
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_isRunning = false;
|
||||
}
|
||||
|
||||
/// 执行备份
|
||||
Future<void> _performBackup() async {
|
||||
try {
|
||||
final backupDir = await _getBackupDirectory();
|
||||
if (backupDir == null) {
|
||||
print('AutoBackup: 无法获取备份目录');
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保备份目录存在
|
||||
if (!await backupDir.exists()) {
|
||||
await backupDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
final result = await BackupService.instance.exportDataForAutoBackup();
|
||||
|
||||
if (!result.success) {
|
||||
print('AutoBackup: 导出失败 - ${result.errorMessage}');
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成备份文件名
|
||||
final fileName = 'auto_backup_${_formatDateTime(DateTime.now())}.zip';
|
||||
final backupFile = File(path.join(backupDir.path, fileName));
|
||||
|
||||
// 写入备份文件
|
||||
await backupFile.writeAsBytes(result.zipBytes!);
|
||||
|
||||
print('AutoBackup: 备份成功 - ${backupFile.path}');
|
||||
|
||||
// 清理旧备份,只保留最新的10个
|
||||
await _cleanupOldBackups(backupDir);
|
||||
|
||||
} catch (e) {
|
||||
print('AutoBackup: 备份失败 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取备份目录(下载目录/mooknote)
|
||||
Future<Directory?> _getBackupDirectory() async {
|
||||
try {
|
||||
// 尝试获取下载目录
|
||||
Directory? downloadDir;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
// Android: 使用外部存储的下载目录
|
||||
final externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
// 通常路径是 /storage/emulated/0/Android/data/.../files
|
||||
// 我们需要找到真正的下载目录
|
||||
final path = externalDir.path;
|
||||
final downloadPath = path.replaceAll(
|
||||
'/Android/data/${externalDir.uri.pathSegments[externalDir.uri.pathSegments.length - 3]}/files',
|
||||
'/Download',
|
||||
);
|
||||
downloadDir = Directory('$downloadPath/$_backupDirName');
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS: 使用文档目录
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
downloadDir = Directory('${docDir.path}/$_backupDirName');
|
||||
} else {
|
||||
// 桌面端: 使用下载目录
|
||||
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home != null) {
|
||||
downloadDir = Directory('$home/Downloads/$_backupDirName');
|
||||
}
|
||||
}
|
||||
|
||||
return downloadDir;
|
||||
} catch (e) {
|
||||
print('AutoBackup: 获取备份目录失败 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理旧备份,只保留最新的10个
|
||||
Future<void> _cleanupOldBackups(Directory backupDir) async {
|
||||
try {
|
||||
final files = await backupDir
|
||||
.list()
|
||||
.where((entity) => entity is File && entity.path.endsWith('.zip'))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
files.sort((a, b) {
|
||||
final aStat = a.statSync();
|
||||
final bStat = b.statSync();
|
||||
return bStat.modified.compareTo(aStat.modified);
|
||||
});
|
||||
|
||||
// 删除超过10个的旧备份
|
||||
if (files.length > _maxBackups) {
|
||||
for (var i = _maxBackups; i < files.length; i++) {
|
||||
try {
|
||||
await files[i].delete();
|
||||
print('AutoBackup: 删除旧备份 - ${files[i].path}');
|
||||
} catch (e) {
|
||||
print('AutoBackup: 删除旧备份失败 - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('AutoBackup: 清理旧备份失败 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取备份文件列表
|
||||
Future<List<File>> getBackupFiles() async {
|
||||
try {
|
||||
final backupDir = await _getBackupDirectory();
|
||||
if (backupDir == null || !await backupDir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final files = await backupDir
|
||||
.list()
|
||||
.where((entity) => entity is File && entity.path.endsWith('.zip'))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
files.sort((a, b) {
|
||||
final aStat = a.statSync();
|
||||
final bStat = b.statSync();
|
||||
return bStat.modified.compareTo(aStat.modified);
|
||||
});
|
||||
|
||||
return files;
|
||||
} catch (e) {
|
||||
print('AutoBackup: 获取备份列表失败 - $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取备份目录路径
|
||||
Future<String?> getBackupDirectoryPath() async {
|
||||
final dir = await _getBackupDirectory();
|
||||
return dir?.path;
|
||||
}
|
||||
|
||||
/// 格式化日期时间用于文件名
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}';
|
||||
}
|
||||
|
||||
String _pad(int number) {
|
||||
return number.toString().padLeft(2, '0');
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,126 @@ class BackupService {
|
||||
);
|
||||
}
|
||||
|
||||
/// 导出数据用于自动备份(返回字节数据而不是保存到文件)
|
||||
Future<AutoBackupExportResult> exportDataForAutoBackup() async {
|
||||
try {
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
|
||||
// 导出所有表的数据
|
||||
final movies = await db.query('movies');
|
||||
final books = await db.query('books');
|
||||
final notes = await db.query('notes');
|
||||
final movieReviews = await db.query('movie_reviews');
|
||||
final moviePosters = await db.query('movie_posters');
|
||||
|
||||
// 收集所有图片路径
|
||||
final imagePaths = <String>{};
|
||||
|
||||
// 收集影视海报
|
||||
for (final movie in movies) {
|
||||
final posterPath = movie['poster_path'] as String?;
|
||||
if (posterPath != null && posterPath.isNotEmpty) {
|
||||
imagePaths.add(posterPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集书籍封面
|
||||
for (final book in books) {
|
||||
final coverPath = book['cover_path'] as String?;
|
||||
if (coverPath != null && coverPath.isNotEmpty) {
|
||||
imagePaths.add(coverPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集海报墙图片
|
||||
for (final poster in moviePosters) {
|
||||
final posterPath = poster['poster_path'] as String?;
|
||||
if (posterPath != null && posterPath.isNotEmpty) {
|
||||
imagePaths.add(posterPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集笔记图片
|
||||
for (final note in notes) {
|
||||
final imagesJson = note['images'] as String?;
|
||||
if (imagesJson != null && imagesJson.isNotEmpty) {
|
||||
try {
|
||||
final images = jsonDecode(imagesJson) as List<dynamic>;
|
||||
for (final imagePath in images) {
|
||||
if (imagePath is String && imagePath.isNotEmpty) {
|
||||
imagePaths.add(imagePath);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建备份数据
|
||||
final backupData = {
|
||||
'version': 2,
|
||||
'exportTime': DateTime.now().toIso8601String(),
|
||||
'appName': 'MookNote',
|
||||
'hasImages': true,
|
||||
'data': {
|
||||
'movies': movies,
|
||||
'books': books,
|
||||
'notes': notes,
|
||||
'movie_reviews': movieReviews,
|
||||
'movie_posters': moviePosters,
|
||||
},
|
||||
};
|
||||
|
||||
// 创建 ZIP 文件
|
||||
final archive = Archive();
|
||||
|
||||
// 添加 JSON 数据
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(backupData);
|
||||
final jsonBytes = Uint8List.fromList(utf8.encode(jsonString));
|
||||
archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes));
|
||||
|
||||
// 添加图片文件,保持目录结构
|
||||
int imageCount = 0;
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesRoot = path.join(appDir.path, 'images');
|
||||
|
||||
for (final imagePath in imagePaths) {
|
||||
final file = File(imagePath);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
// 计算相对路径(如 movies/1/poster.jpg)
|
||||
String relativePath;
|
||||
if (imagePath.startsWith(imagesRoot)) {
|
||||
relativePath = imagePath.substring(imagesRoot.length + 1);
|
||||
} else {
|
||||
relativePath = path.basename(imagePath);
|
||||
}
|
||||
// 使用相对路径存储图片,保持目录结构
|
||||
archive.addFile(ArchiveFile('images/$relativePath', bytes.length, bytes));
|
||||
imageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 压缩 ZIP
|
||||
final zipEncoder = ZipEncoder();
|
||||
final zipBytes = zipEncoder.encode(archive);
|
||||
if (zipBytes == null) {
|
||||
return AutoBackupExportResult.error('压缩备份文件失败');
|
||||
}
|
||||
|
||||
return AutoBackupExportResult.success(
|
||||
zipBytes: Uint8List.fromList(zipBytes),
|
||||
movieCount: movies.length,
|
||||
bookCount: books.length,
|
||||
noteCount: notes.length,
|
||||
imageCount: imageCount,
|
||||
);
|
||||
} catch (e) {
|
||||
return AutoBackupExportResult.error('导出失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择并导入备份文件(支持 ZIP 格式)
|
||||
Future<ImportResult> importData() async {
|
||||
try {
|
||||
@@ -450,6 +570,48 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动备份导出结果
|
||||
class AutoBackupExportResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final Uint8List? zipBytes;
|
||||
final int movieCount;
|
||||
final int bookCount;
|
||||
final int noteCount;
|
||||
final int imageCount;
|
||||
|
||||
AutoBackupExportResult._({
|
||||
required this.success,
|
||||
this.errorMessage,
|
||||
this.zipBytes,
|
||||
this.movieCount = 0,
|
||||
this.bookCount = 0,
|
||||
this.noteCount = 0,
|
||||
this.imageCount = 0,
|
||||
});
|
||||
|
||||
factory AutoBackupExportResult.success({
|
||||
required Uint8List zipBytes,
|
||||
required int movieCount,
|
||||
required int bookCount,
|
||||
required int noteCount,
|
||||
required int imageCount,
|
||||
}) {
|
||||
return AutoBackupExportResult._(
|
||||
success: true,
|
||||
zipBytes: zipBytes,
|
||||
movieCount: movieCount,
|
||||
bookCount: bookCount,
|
||||
noteCount: noteCount,
|
||||
imageCount: imageCount,
|
||||
);
|
||||
}
|
||||
|
||||
factory AutoBackupExportResult.error(String message) {
|
||||
return AutoBackupExportResult._(success: false, errorMessage: message);
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出结果
|
||||
class ExportResult {
|
||||
final bool success;
|
||||
|
||||
@@ -13,6 +13,8 @@ class NoteListItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPlainText = note.contentType == 'plain_text';
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/note-detail', arguments: note);
|
||||
@@ -28,15 +30,65 @@ class NoteListItem extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 顶部:格式标记 + 时间
|
||||
Row(
|
||||
children: [
|
||||
// 格式标记
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
child: Text(
|
||||
isPlainText ? 'TXT' : 'MD',
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 时间
|
||||
Text(
|
||||
_formatDate(note.updatedAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 图片数量(如果有图片)
|
||||
if (note.images.isNotEmpty) ...[
|
||||
const Icon(
|
||||
Icons.image_outlined,
|
||||
size: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${note.images.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 内容摘要(去除首尾空格)
|
||||
Text(
|
||||
note.summary.trim(),
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.5,
|
||||
color: const Color(0xFF1A1A1A),
|
||||
height: isPlainText ? 1.6 : 1.5,
|
||||
),
|
||||
maxLines: 3,
|
||||
maxLines: isPlainText ? 4 : 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -66,75 +118,30 @@ class NoteListItem extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 底部信息:标签 + 时间
|
||||
Row(
|
||||
children: [
|
||||
// 标签
|
||||
if (note.tags.isNotEmpty) ...[
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: note.tags.take(2).map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
// 底部标签
|
||||
if (note.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: note.tags.take(3).map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
|
||||
// 时间和图片数量
|
||||
Row(
|
||||
children: [
|
||||
// 图片数量(如果有图片)
|
||||
if (note.images.isNotEmpty) ...[
|
||||
const Icon(
|
||||
Icons.image_outlined,
|
||||
size: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${note.images.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
const Icon(
|
||||
Icons.access_time,
|
||||
size: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
_formatDate(note.updatedAt),
|
||||
child: Text(
|
||||
tag,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user