From 4cdfd9e35b3a55226818b8e95b74213022e8e521 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Mon, 3 Aug 2026 22:19:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=8A=A8=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E5=92=8C=E4=BF=AE=E5=A4=8D=E6=9C=AC=E5=9C=B0=E5=A4=87?= =?UTF-8?q?=E4=BB=BDbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 99 ++++++++ lib/pages/sync/backup_page.dart | 333 +++++++++++++++++++++++--- lib/services/sync/backup_service.dart | 123 +++++++++- lib/utils/user_prefs.dart | 14 ++ pubspec.yaml | 2 +- 5 files changed, 534 insertions(+), 37 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e6e3263..6613d6e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,12 +11,14 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'pages/home/home_page.dart'; import 'utils/theme/app_theme.dart'; import 'utils/app_router.dart'; import 'utils/user_prefs.dart'; import 'services/changelog_service.dart'; import 'services/usage_stats_service.dart'; +import 'services/sync/backup_service.dart'; import 'providers/app_provider.dart'; import 'widgets/app_shell.dart'; @@ -74,6 +76,7 @@ Future _bootstrap(AppProvider appProvider) async { appProvider.initMainTabIndex(); unawaited(_initUsageStats()); + unawaited(_startAutoBackupAfterDbReady()); } Future _initUsageStats() async { @@ -84,6 +87,26 @@ Future _initUsageStats() async { } } +/// 数据库就绪后启动自动备份(延迟执行,不阻塞开屏) +Future _startAutoBackupAfterDbReady() async { + if (!Platform.isAndroid) return; + // 等 UI 渲染完成后再执行,避免开屏卡顿 + await Future.delayed(const Duration(seconds: 3)); + final userPrefs = UserPrefs(); + if (!userPrefs.localAutoBackupEnabled) return; + // 启动时立即执行一次(后台不阻塞) + try { + final result = await BackupService.instance.performLocalAutoBackup(); + if (result.success) { + debugPrint('[AutoBackup] 启动自动备份完成'); + } else { + debugPrint('[AutoBackup] 启动自动备份失败: ${result.errorMessage}'); + } + } catch (e) { + debugPrint('[AutoBackup] 启动自动备份异常: $e'); + } +} + class MyApp extends StatefulWidget { final AppProvider appProvider; const MyApp({super.key, required this.appProvider}); @@ -96,6 +119,7 @@ class _MyAppState extends State with WidgetsBindingObserver { ThemeMode? _lastAppliedTheme; bool _updateCheckDone = false; final GlobalKey _navigatorKey = GlobalKey(); + Timer? _autoBackupTimer; @override void initState() { @@ -105,10 +129,83 @@ class _MyAppState extends State with WidgetsBindingObserver { widget.appProvider.addListener(_onThemeChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _applySystemUI(); + _requestStoragePermissionIfNeeded(); _checkUpdate(); // 不阻塞,完成后自行弹窗 }); } + @override + void dispose() { + _autoBackupTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// Android 启动时请求存储权限(备份导出需要写入 Download 目录) + Future _requestStoragePermissionIfNeeded() async { + if (!Platform.isAndroid) return; + var status = await Permission.manageExternalStorage.status; + if (status.isGranted) return; + status = await Permission.manageExternalStorage.request(); + if (status.isGranted) return; + // Android 11 以下回退到 storage 权限 + status = await Permission.storage.status; + if (status.isGranted) return; + await Permission.storage.request(); + } + + /// 启动本地自动备份定时器(仅启动定时器,不立即执行备份) + void _startAutoBackupTimer() { + _autoBackupTimer?.cancel(); + if (!Platform.isAndroid) return; + final userPrefs = UserPrefs(); + if (!userPrefs.localAutoBackupEnabled) return; + + final intervalHours = userPrefs.localAutoBackupIntervalHours; + // 定时器:每小时检查一次是否到了备份时间 + _autoBackupTimer = Timer.periodic(const Duration(hours: 1), (_) { + _checkAndRunAutoBackup(); + }); + debugPrint('[AutoBackup] 定时器已启动,间隔 $intervalHours 小时'); + } + + /// 检查是否需要执行自动备份(定时器调用,走间隔判断) + Future _checkAndRunAutoBackup() async { + final userPrefs = UserPrefs(); + if (!userPrefs.localAutoBackupEnabled) { + _autoBackupTimer?.cancel(); + return; + } + final intervalHours = userPrefs.localAutoBackupIntervalHours; + final lastTime = userPrefs.lastLocalAutoBackupTime; + + bool shouldRun = false; + if (lastTime == null) { + shouldRun = true; + } else { + final last = DateTime.tryParse(lastTime)?.toLocal(); + if (last == null) { + shouldRun = true; + } else { + final now = DateTime.now(); + shouldRun = now.difference(last).inHours >= intervalHours; + } + } + + if (!shouldRun) return; + + try { + final result = await BackupService.instance.performLocalAutoBackup(); + if (result.success) { + debugPrint('[AutoBackup] 定时自动备份完成'); + } else { + debugPrint('[AutoBackup] 定时自动备份失败: ${result.errorMessage}'); + } + } catch (e) { + debugPrint('[AutoBackup] 定时自动备份异常: $e'); + } + } + /// 延迟到首页渲染后再检查版本更新,确保 context 已就绪 Future _checkUpdate() async { if (_updateCheckDone) return; @@ -262,6 +359,8 @@ class _MyAppState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _applySystemUI(); + // 从备份页返回后可能改了自动备份设置,重新启动定时器 + _startAutoBackupTimer(); } } diff --git a/lib/pages/sync/backup_page.dart b/lib/pages/sync/backup_page.dart index 7528591..584f514 100644 --- a/lib/pages/sync/backup_page.dart +++ b/lib/pages/sync/backup_page.dart @@ -1,8 +1,10 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../services/sync/backup_service.dart'; import '../../utils/toast_util.dart'; +import '../../utils/user_prefs.dart'; /// 本地备份页面 class BackupPage extends StatefulWidget { @@ -15,6 +17,22 @@ class BackupPage extends StatefulWidget { class _BackupPageState extends State { bool _isExporting = false; bool _isImporting = false; + bool _isRunningAutoBackup = false; + List _autoBackupFiles = []; + + @override + void initState() { + super.initState(); + _loadAutoBackupFiles(); + } + + Future _loadAutoBackupFiles() async { + if (!Platform.isAndroid) return; + final files = await BackupService.instance.listLocalAutoBackups(); + if (mounted) { + setState(() => _autoBackupFiles = files); + } + } @override Widget build(BuildContext context) { @@ -25,38 +43,47 @@ class _BackupPageState extends State { title: const Text('本地备份'), ), body: ListView( - padding: const EdgeInsets.all(20), - children: [ - // 手动备份 - _buildSectionTitle(colors, '手动备份'), - const SizedBox(height: 10), - _buildActionCard( - colors: colors, - title: '导出数据', - description: '将所有数据导出为 zip 文件,可用于备份或迁移到其他设备', - icon: Icons.upload_outlined, - buttonText: '导出', - isLoading: _isExporting, - onTap: _exportData, - ), - const SizedBox(height: 8), - _buildActionCard( - colors: colors, - title: '导入数据', - description: '从备份文件导入数据,将覆盖当前所有数据', - icon: Icons.download_outlined, - buttonText: '导入', - isLoading: _isImporting, - onTap: _importData, - isDestructive: true, - ), + padding: const EdgeInsets.all(20), + children: [ + // 手动备份 + _buildSectionTitle(colors, '手动备份'), + const SizedBox(height: 10), + _buildActionCard( + colors: colors, + title: '导出数据', + description: '将所有数据导出为 zip 文件,可用于备份或迁移到其他设备', + icon: Icons.upload_outlined, + buttonText: '导出', + isLoading: _isExporting, + onTap: _exportData, + ), + const SizedBox(height: 8), + _buildActionCard( + colors: colors, + title: '导入数据', + description: '从备份文件导入数据,将覆盖当前所有数据', + icon: Icons.download_outlined, + buttonText: '导入', + isLoading: _isImporting, + onTap: _importData, + isDestructive: true, + ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // 使用说明 - _buildInfoSection(colors), - ], - ), + // 自动备份 + if (Platform.isAndroid) ...[ + _buildSectionTitle(colors, '自动备份'), + const SizedBox(height: 10), + _buildAutoBackupSection(colors), + ], + + const SizedBox(height: 24), + + // 使用说明 + _buildInfoSection(colors), + ], + ), ); } @@ -183,6 +210,215 @@ class _BackupPageState extends State { ); } + /// 构建自动备份区域 + Widget _buildAutoBackupSection(ColorScheme colors) { + final userPrefs = UserPrefs(); + final isEnabled = userPrefs.localAutoBackupEnabled; + final intervalHours = userPrefs.localAutoBackupIntervalHours; + final lastTime = userPrefs.lastLocalAutoBackupTime; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 开关行 + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.sync_outlined, + size: 18, + color: colors.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '自动备份', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface, + ), + ), + const SizedBox(height: 1), + Text( + isEnabled + ? '每 $intervalHours 小时自动备份一次,保留最新 5 个' + : '开启后自动定期备份数据', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4), + height: 1.3, + ), + ), + ], + ), + ), + Switch( + value: isEnabled, + onChanged: (value) async { + await userPrefs.setLocalAutoBackupEnabled(value); + setState(() {}); + }, + ), + ], + ), + + if (isEnabled) ...[ + const SizedBox(height: 12), + // 间隔选择 + _buildIntervalSelector(colors, intervalHours), + const SizedBox(height: 10), + // 立即备份按钮 + GestureDetector( + onTap: _isRunningAutoBackup ? null : _runAutoBackupNow, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: _isRunningAutoBackup + ? colors.onSurface.withValues(alpha: 0.25) + : colors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: _isRunningAutoBackup + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colors.onPrimary), + ), + ) + : Text( + '立即备份', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onPrimary, + ), + ), + ), + ), + ), + // 上次备份时间 + if (lastTime != null) ...[ + const SizedBox(height: 8), + Text( + '上次备份: ${_formatBackupTime(lastTime)}', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.35), + ), + ), + ], + // 备份文件列表 + if (_autoBackupFiles.isNotEmpty) ...[ + const SizedBox(height: 12), + ..._autoBackupFiles.map((f) => _buildBackupFileItem(colors, f)), + ], + ], + ], + ), + ); + } + + /// 构建间隔选择器 + Widget _buildIntervalSelector(ColorScheme colors, int currentHours) { + const options = [6, 12, 24, 48]; + return Row( + children: options.map((h) { + final selected = h == currentHours; + return Expanded( + child: GestureDetector( + onTap: () async { + await UserPrefs().setLocalAutoBackupIntervalHours(h); + setState(() {}); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: selected + ? colors.primary + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + h < 24 ? '$h小时' : '${h ~/ 24}天', + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ), + ), + ); + }).toList(), + ); + } + + /// 构建备份文件条目 + Widget _buildBackupFileItem(ColorScheme colors, FileSystemEntity file) { + final name = file.path.split('/').last; + final stat = file.statSync(); + final size = _formatFileSize(stat.size); + final time = _formatBackupTime(stat.modified.toIso8601String()); + + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Icon(Icons.description_outlined, + size: 16, color: colors.onSurface.withValues(alpha: 0.3)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '$time · $size', + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withValues(alpha: 0.3), + ), + ), + ], + ), + ), + ], + ), + ); + } + /// 构建信息说明区域 Widget _buildInfoSection(ColorScheme colors) { return Container( @@ -209,7 +445,7 @@ class _BackupPageState extends State { color: colors.onSurface.withValues(alpha: 0.6), ), ), - const SizedBox(width: 10), + const SizedBox(height: 10), Text( '使用说明', style: TextStyle( @@ -463,4 +699,39 @@ class _BackupPageState extends State { } } } + + /// 立即执行一次自动备份 + Future _runAutoBackupNow() async { + setState(() => _isRunningAutoBackup = true); + try { + final result = await BackupService.instance.performLocalAutoBackup(); + if (!mounted) return; + if (result.success) { + ToastUtil.show(context, '自动备份完成'); + await _loadAutoBackupFiles(); + } else { + ToastUtil.show(context, result.errorMessage ?? '自动备份失败'); + } + } catch (e) { + if (mounted) ToastUtil.show(context, '自动备份失败: $e'); + } finally { + if (mounted) setState(() => _isRunningAutoBackup = false); + } + } + + /// 格式化备份时间 + String _formatBackupTime(String isoTime) { + final dt = DateTime.tryParse(isoTime)?.toLocal(); + if (dt == null) return isoTime; + return '${dt.year}-${_pad(dt.month)}-${_pad(dt.day)} ${_pad(dt.hour)}:${_pad(dt.minute)}'; + } + + /// 格式化文件大小 + 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 _pad(int n) => n.toString().padLeft(2, '0'); } diff --git a/lib/services/sync/backup_service.dart b/lib/services/sync/backup_service.dart index 80834c3..9a01a18 100644 --- a/lib/services/sync/backup_service.dart +++ b/lib/services/sync/backup_service.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as path; import 'package:share_plus/share_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../../data/database_helper.dart'; import '../../utils/user_prefs.dart'; import '../../utils/image_path_helper.dart'; @@ -23,6 +24,33 @@ class BackupService { return await ImagePathHelper.getAppDir(); } + /// 请求存储权限,返回是否已获取 + Future requestStoragePermission() async { + if (!Platform.isAndroid) return true; + var status = await Permission.manageExternalStorage.status; + if (status.isGranted) return true; + status = await Permission.manageExternalStorage.request(); + if (status.isGranted) return true; + status = await Permission.storage.status; + if (status.isGranted) return true; + status = await Permission.storage.request(); + return status.isGranted; + } + + /// 获取 Android Download 目录下的 mooknote 备份路径 + Future _getDownloadBackupPath(String fileName) async { + if (Platform.isAndroid) { + final downloadDir = Directory('/sdcard/Download/mooknote'); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + } + return path.join(downloadDir.path, fileName); + } + // 非 Android 平台使用临时目录 + final tempDir = await getTemporaryDirectory(); + return path.join(tempDir.path, fileName); + } + // ─── 共享导出逻辑 ───────────────────────────────────── /// 收集所有表数据和图片,构建 ZIP 文件 @@ -154,11 +182,17 @@ class BackupService { /// 导出所有数据和图片为 ZIP 文件,并选择保存路径 Future exportDataWithImages() async { try { + // Android: 先检查存储权限,没有则请求 + if (Platform.isAndroid) { + final hasPermission = await requestStoragePermission(); + if (!hasPermission) { + return ExportResult.error('需要存储权限才能导出备份文件,请在设置中授予"所有文件访问权限"'); + } + } + final data = await _buildExportData(); final zipFile = File(data.zipPath!); - final tempDir = await getTemporaryDirectory(); final fileName = 'mooknote_backup_${_formatDateTime(DateTime.now())}.zip'; - final tempFilePath = path.join(tempDir.path, fileName); String? finalPath; try { @@ -175,9 +209,10 @@ class BackupService { await zipFile.copy(outputPath); finalPath = outputPath; } catch (e) { - // FilePicker 不可用时,复制到临时目录 - await zipFile.copy(tempFilePath); - finalPath = tempFilePath; + // FilePicker 不可用时,复制到 /sdcard/Download/mooknote/ + final downloadPath = await _getDownloadBackupPath(fileName); + await zipFile.copy(downloadPath); + finalPath = downloadPath; } // 清理原始临时 zip @@ -220,6 +255,84 @@ class BackupService { } } + /// 执行本地自动备份:导出到 /sdcard/Download/mooknote/autoBackUp/,保留最新 maxKeep 个 + Future performLocalAutoBackup({int maxKeep = 5}) async { + try { + if (Platform.isAndroid) { + final hasPermission = await requestStoragePermission(); + if (!hasPermission) { + return AutoBackupExportResult.error('需要存储权限才能自动备份'); + } + } + + final data = await _buildExportData(); + final zipFile = File(data.zipPath!); + final fileName = 'mooknote_backup_${_formatDateTime(DateTime.now())}.zip'; + + // 目标目录 + final backupDir = Directory('/sdcard/Download/mooknote/autoBackUp'); + if (!await backupDir.exists()) { + await backupDir.create(recursive: true); + } + + // 复制到目标路径 + final destPath = path.join(backupDir.path, fileName); + await zipFile.copy(destPath); + + // 清理原始临时 zip + try { await zipFile.delete(); } catch (_) {} + + // 清理旧备份,保留最新 maxKeep 个 + await _cleanOldBackups(backupDir, maxKeep); + + // 记录备份时间 + final userPrefs = UserPrefs(); + await userPrefs.setLastLocalAutoBackupTime(DateTime.now().toIso8601String()); + + return AutoBackupExportResult.success( + zipPath: destPath, + movieCount: data.movieCount, + bookCount: data.bookCount, + noteCount: data.noteCount, + imageCount: data.imageCount, + epubCount: data.epubCount, + ); + } catch (e) { + return AutoBackupExportResult.error('自动备份失败: $e'); + } + } + + /// 清理旧备份文件,只保留最新的 maxKeep 个 + Future _cleanOldBackups(Directory backupDir, int maxKeep) async { + final files = []; + await for (final entity in backupDir.list()) { + if (entity is File && entity.path.endsWith('.zip')) { + files.add(entity); + } + } + if (files.length <= maxKeep) return; + // 按修改时间排序,旧的在前 + files.sort((a, b) => a.lastModifiedSync().compareTo(b.lastModifiedSync())); + final toDelete = files.sublist(0, files.length - maxKeep); + for (final f in toDelete) { + try { await f.delete(); } catch (_) {} + } + } + + /// 获取本地自动备份目录下的备份文件列表(按时间倒序) + Future> listLocalAutoBackups() async { + final backupDir = Directory('/sdcard/Download/mooknote/autoBackUp'); + if (!await backupDir.exists()) return []; + final files = []; + await for (final entity in backupDir.list()) { + if (entity is File && entity.path.endsWith('.zip')) { + files.add(entity); + } + } + files.sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified)); + return files; + } + // ─── 导入 ───────────────────────────────────────────── /// 选择并导入备份文件(支持 ZIP 和旧版 JSON) diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 2fde908..4f54bf6 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -300,4 +300,18 @@ class UserPrefs { /// 字体选择器上次使用的目录路径 String? get lastFontDir => prefs.getString('lastFontDir'); Future setLastFontDir(String value) => prefs.setString('lastFontDir', value); + + // ========== 本地自动备份 ========== + + /// 是否启用本地自动备份 + bool get localAutoBackupEnabled => prefs.getBool('localAutoBackupEnabled') ?? false; + Future setLocalAutoBackupEnabled(bool value) => prefs.setBool('localAutoBackupEnabled', value); + + /// 本地自动备份间隔(小时),默认 24 + int get localAutoBackupIntervalHours => prefs.getInt('localAutoBackupIntervalHours') ?? 24; + Future setLocalAutoBackupIntervalHours(int value) => prefs.setInt('localAutoBackupIntervalHours', value); + + /// 上次本地自动备份时间(ISO8601) + String? get lastLocalAutoBackupTime => prefs.getString('lastLocalAutoBackupTime'); + Future setLastLocalAutoBackupTime(String value) => prefs.setString('lastLocalAutoBackupTime', value); } diff --git a/pubspec.yaml b/pubspec.yaml index 38c6309..d4b1491 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mooknote description: "app for tracking movies, books, and notes" publish_to: 'none' -version: 0.2.7 +version: 0.2.8 environment: sdk: ^3.5.0