diff --git a/lib/pages/home/main_content_page.dart b/lib/pages/home/main_content_page.dart index 1a453df..989cbca 100644 --- a/lib/pages/home/main_content_page.dart +++ b/lib/pages/home/main_content_page.dart @@ -164,157 +164,17 @@ class _MainContentPageState extends State { void _showCloudSheet(BuildContext context) async { final colors = Theme.of(context).colorScheme; final hasConfig = (await WebDAVService.instance.getConfig()) != null; - Map? remoteInfo; - if (hasConfig) { - remoteInfo = await WebDAVService.instance.getRemoteBackupInfo(); - } if (!mounted) return; showModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (ctx) { - final bc = Theme.of(ctx).colorScheme; - final modifiedTime = remoteInfo?['modifiedTime'] as DateTime?; - final remoteSize = remoteInfo?['size'] as int?; - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Center(child: Container( - width: 36, height: 4, margin: const EdgeInsets.only(bottom: 14), - decoration: BoxDecoration(color: bc.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)), - )), - Text('云备份', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: bc.onSurface)), - const SizedBox(height: 14), - _cloudCard(icon: Icons.cloud_upload_outlined, title: '上传数据', desc: hasConfig ? '将本地数据同步到云端' : '请先配置 WebDAV 服务器', enabled: hasConfig, onTap: hasConfig ? () { Navigator.pop(ctx); _performSync(context, SyncDirection.upload); } : null, colors: bc), - const SizedBox(height: 8), - _cloudCard(icon: Icons.cloud_download_outlined, title: '下载数据', desc: hasConfig ? '从云端恢复数据到本地' : '请先配置 WebDAV 服务器', enabled: hasConfig, onTap: hasConfig ? () { Navigator.pop(ctx); _performSync(context, SyncDirection.download); } : null, colors: bc), - const SizedBox(height: 8), - _cloudCard(icon: Icons.settings_outlined, title: 'WebDAV 设置', desc: '配置服务器地址与认证信息', enabled: true, onTap: () { Navigator.pop(ctx); Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())); }, colors: bc), - if (modifiedTime != null || remoteSize != null) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: bc.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row(children: [ - Icon(Icons.info_outline, size: 14, color: bc.onSurface.withValues(alpha: 0.3)), - const SizedBox(width: 6), - Text('云端备份', style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.4))), - const Spacer(), - if (modifiedTime != null) Text(_formatDateTime(modifiedTime), style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.5))), - if (modifiedTime != null && remoteSize != null) Text(' · ', style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.2))), - if (remoteSize != null) Text(_formatFileSize(remoteSize), style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.5))), - ]), - ), - ], - ]), - ), - ); + return _CloudSheetContent(hasConfig: hasConfig); }, ); } - String _formatDateTime(DateTime dt) { - final now = DateTime.now(); - final diff = now.difference(dt); - if (diff.inMinutes < 1) return '刚刚'; - if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; - if (diff.inDays < 1) return '${diff.inHours}小时前'; - if (diff.inDays < 7) return '${diff.inDays}天前'; - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - } - - 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'; - } - - Widget _cloudCard({required IconData icon, required String title, required String desc, required bool enabled, required VoidCallback? onTap, required ColorScheme colors}) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: enabled ? colors.primary.withValues(alpha: 0.04) : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: enabled ? colors.primary.withValues(alpha: 0.1) : colors.outlineVariant, width: 0.5), - ), - child: Row(children: [ - Container(width: 36, height: 36, decoration: BoxDecoration(color: enabled ? colors.primary.withValues(alpha: 0.08) : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 20, color: enabled ? colors.primary : colors.onSurface.withValues(alpha: 0.18))), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25))), - const SizedBox(height: 1), - Text(desc, style: TextStyle(fontSize: 11, color: enabled ? colors.onSurface.withValues(alpha: 0.4) : colors.onSurface.withValues(alpha: 0.2))), - ])), - Icon(Icons.chevron_right, size: 20, color: enabled ? colors.onSurface.withValues(alpha: 0.15) : colors.onSurface.withValues(alpha: 0.08)), - ]), - ), - ); - } - - Future _performSync(BuildContext context, SyncDirection direction) async { - final colors = Theme.of(context).colorScheme; - final config = await WebDAVService.instance.getConfig(); - if (config == null) { - if (context.mounted) _showResultDialog(context, title: '同步失败', message: '请先配置 WebDAV 服务器', isSuccess: false); - return; - } - if (context.mounted) { - showDialog(context: context, barrierDismissible: false, builder: (_) => Center(child: CircularProgressIndicator(color: colors.primary))); - } - final result = await WebDAVService.instance.syncData(direction: direction); - if (context.mounted) Navigator.pop(context); - if (result.success && result.needReload && context.mounted) { - final provider = context.read(); - await provider.loadMovies(); - await provider.loadBooks(); - await provider.loadNotes(); - await provider.loadGames(); - } - if (context.mounted) { - _showResultDialog(context, title: result.success ? '同步成功' : '同步失败', message: result.message.isNotEmpty ? result.message : (result.success ? '同步成功' : '同步失败'), isSuccess: result.success, details: {'uploaded': result.uploadedFiles + result.uploadedImages, 'downloaded': result.downloadedFiles + result.downloadedImages}); - } - } - - void _showResultDialog(BuildContext context, {required String title, required String message, required bool isSuccess, Map? details}) { - final colors = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (_) => AlertDialog( - backgroundColor: colors.surface, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Row(children: [ - Container(width: 40, height: 40, decoration: BoxDecoration(color: isSuccess ? const Color(0xFFE8F5E9) : const Color(0xFFFFEBEE), borderRadius: BorderRadius.circular(10)), child: Icon(isSuccess ? Icons.check_circle : Icons.error, color: isSuccess ? const Color(0xFF4CAF50) : const Color(0xFFE57373), size: 24)), - const SizedBox(width: 12), - Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - ]), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(message, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), - if (details != null) ...[const SizedBox(height: 16), Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(8)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (details['uploaded'] != null) _detailRow('上传文件', '${details['uploaded']} 个', colors), - if (details['downloaded'] != null) _detailRow('下载文件', '${details['downloaded']} 个', colors), - ]))], - ]), - actions: [ElevatedButton(onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)), child: const Text('确定'))], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ); - } - - Widget _detailRow(String label, String value, ColorScheme colors) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), - Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)), - ]), - ); - // ─── Tab 栏 + 指示条 ───────────────────────────────── Widget _buildTabBar(BuildContext context) { @@ -520,3 +380,254 @@ class _TabItem { final int originalIndex; _TabItem(this.label, this.originalIndex); } + +/// 云备份弹窗内容(异步加载远程信息,避免阻塞弹窗弹出) +class _CloudSheetContent extends StatefulWidget { + final bool hasConfig; + const _CloudSheetContent({required this.hasConfig}); + + @override + State<_CloudSheetContent> createState() => _CloudSheetContentState(); +} + +class _CloudSheetContentState extends State<_CloudSheetContent> { + DateTime? _modifiedTime; + int? _remoteSize; + bool _loading = true; + bool _syncing = false; + String _syncStep = ''; + + @override + void initState() { + super.initState(); + if (widget.hasConfig) { + _loadRemoteInfo(); + } else { + _loading = false; + } + } + + Future _loadRemoteInfo() async { + setState(() => _loading = true); + final info = await WebDAVService.instance.getRemoteBackupInfo(); + if (mounted) { + setState(() { + _modifiedTime = info?['modifiedTime'] as DateTime?; + _remoteSize = info?['size'] as int?; + _loading = false; + }); + } + } + + Future _performSync(SyncDirection direction) async { + if (direction == SyncDirection.upload) { + // 上传:先打包,再上传 + setState(() => _syncStep = '正在打包数据...'); + final exportResult = await WebDAVService.instance.exportLocalData(); + if (!exportResult.success || exportResult.zipPath == null) { + if (mounted) { + setState(() => _syncing = false); + _showResultDialog(title: '同步失败', message: exportResult.errorMessage ?? '创建备份失败', isSuccess: false); + } + return; + } + if (!mounted) return; + setState(() => _syncStep = '正在上传到云端...'); + final result = await WebDAVService.instance.uploadExportedData(exportResult); + if (result.success && result.needReload && mounted) { + final provider = context.read(); + await provider.loadMovies(); + await provider.loadBooks(); + await provider.loadNotes(); + await provider.loadGames(); + } + if (mounted) { + setState(() => _syncing = false); + Navigator.pop(context); + _showResultDialog( + title: result.success ? '同步成功' : '同步失败', + message: result.message.isNotEmpty ? result.message : (result.success ? '同步成功' : '同步失败'), + isSuccess: result.success, + details: {'uploaded': result.uploadedFiles + result.uploadedImages, 'downloaded': result.downloadedFiles + result.downloadedImages}, + ); + } + } else { + // 下载 + setState(() => _syncStep = '正在从云端下载...'); + final config = await WebDAVService.instance.getConfig(); + if (config == null) { + if (mounted) { + setState(() => _syncing = false); + _showResultDialog(title: '同步失败', message: '请先配置 WebDAV 服务器', isSuccess: false); + } + return; + } + final result = await WebDAVService.instance.syncData(direction: SyncDirection.download); + if (result.success && result.needReload && mounted) { + final provider = context.read(); + await provider.loadMovies(); + await provider.loadBooks(); + await provider.loadNotes(); + await provider.loadGames(); + } + if (mounted) { + setState(() => _syncing = false); + Navigator.pop(context); + _showResultDialog( + title: result.success ? '同步成功' : '同步失败', + message: result.message.isNotEmpty ? result.message : (result.success ? '同步成功' : '同步失败'), + isSuccess: result.success, + details: {'uploaded': result.uploadedFiles + result.uploadedImages, 'downloaded': result.downloadedFiles + result.downloadedImages}, + ); + } + } + } + + void _startSync(SyncDirection direction) { + setState(() { + _syncing = true; + _syncStep = ''; + }); + _performSync(direction); + } + + @override + Widget build(BuildContext context) { + final bc = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Center(child: Container( + width: 36, height: 4, margin: const EdgeInsets.only(bottom: 14), + decoration: BoxDecoration(color: bc.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)), + )), + Text('云备份', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: bc.onSurface)), + if (_loading) + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: bc.primary)), + ) + else if (_modifiedTime != null || _remoteSize != null) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: bc.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row(children: [ + Icon(Icons.info_outline, size: 14, color: bc.onSurface.withValues(alpha: 0.3)), + const SizedBox(width: 6), + Text('云端备份', style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.4))), + const Spacer(), + if (_modifiedTime != null) Text(_formatDateTime(_modifiedTime!), style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.5))), + if (_modifiedTime != null && _remoteSize != null) Text(' · ', style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.2))), + if (_remoteSize != null) Text(_formatFileSize(_remoteSize!), style: TextStyle(fontSize: 11, color: bc.onSurface.withValues(alpha: 0.5))), + ]), + ), + ], + const SizedBox(height: 14), + if (_syncing) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column(children: [ + SizedBox( + width: 180, + child: LinearProgressIndicator( + backgroundColor: bc.surfaceContainerHighest, + color: bc.primary, + minHeight: 3, + borderRadius: BorderRadius.circular(1.5), + ), + ), + const SizedBox(height: 12), + Text(_syncStep, style: TextStyle(fontSize: 13, color: bc.onSurface.withValues(alpha: 0.6))), + ]), + ), + ] else ...[ + _cloudCard(icon: Icons.cloud_upload_outlined, title: '上传数据', desc: widget.hasConfig ? '将本地数据同步到云端' : '请先配置 WebDAV 服务器', enabled: widget.hasConfig, onTap: widget.hasConfig ? () => _startSync(SyncDirection.upload) : null, colors: bc), + const SizedBox(height: 8), + _cloudCard(icon: Icons.cloud_download_outlined, title: '下载数据', desc: widget.hasConfig ? '从云端恢复数据到本地' : '请先配置 WebDAV 服务器', enabled: widget.hasConfig, onTap: widget.hasConfig ? () => _startSync(SyncDirection.download) : null, colors: bc), + const SizedBox(height: 8), + _cloudCard(icon: Icons.settings_outlined, title: 'WebDAV 设置', desc: '配置服务器地址与认证信息', enabled: true, onTap: () { Navigator.pop(context); Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())); }, colors: bc), + ], + ]), + ), + ); + } + + String _formatDateTime(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; + if (diff.inDays < 1) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + 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'; + } + + Widget _cloudCard({required IconData icon, required String title, required String desc, required bool enabled, required VoidCallback? onTap, required ColorScheme colors}) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: enabled ? colors.primary.withValues(alpha: 0.04) : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: enabled ? colors.primary.withValues(alpha: 0.1) : colors.outlineVariant, width: 0.5), + ), + child: Row(children: [ + Container(width: 36, height: 36, decoration: BoxDecoration(color: enabled ? colors.primary.withValues(alpha: 0.08) : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 20, color: enabled ? colors.primary : colors.onSurface.withValues(alpha: 0.18))), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 1), + Text(desc, style: TextStyle(fontSize: 11, color: enabled ? colors.onSurface.withValues(alpha: 0.4) : colors.onSurface.withValues(alpha: 0.2))), + ])), + Icon(Icons.chevron_right, size: 20, color: enabled ? colors.onSurface.withValues(alpha: 0.15) : colors.onSurface.withValues(alpha: 0.08)), + ]), + ), + ); + } + + void _showResultDialog({required String title, required String message, required bool isSuccess, Map? details}) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Row(children: [ + Container(width: 40, height: 40, decoration: BoxDecoration(color: isSuccess ? const Color(0xFFE8F5E9) : const Color(0xFFFFEBEE), borderRadius: BorderRadius.circular(10)), child: Icon(isSuccess ? Icons.check_circle : Icons.error, color: isSuccess ? const Color(0xFF4CAF50) : const Color(0xFFE57373), size: 24)), + const SizedBox(width: 12), + Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(message, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + if (details != null) ...[const SizedBox(height: 16), Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(8)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (details['uploaded'] != null) _detailRow('上传文件', '${details['uploaded']} 个', colors), + if (details['downloaded'] != null) _detailRow('下载文件', '${details['downloaded']} 个', colors), + ]))], + ]), + actions: [ElevatedButton(onPressed: () => Navigator.pop(dialogCtx), style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)), child: const Text('确定'))], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } + + Widget _detailRow(String label, String value, ColorScheme colors) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row(children: [ + Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), + const Spacer(), + Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + ]), + ); +} diff --git a/lib/pages/profile/profile_page.dart b/lib/pages/profile/profile_page.dart index 7138b5a..3f021e1 100644 --- a/lib/pages/profile/profile_page.dart +++ b/lib/pages/profile/profile_page.dart @@ -90,11 +90,12 @@ class _ProfilePageState extends State with RouteAware { provider.movies.where((m) => !m.isDeleted).toList(); final books = provider.books.where((b) => !b.isDeleted).toList(); final notes = provider.notes.where((n) => !n.isDeleted).toList(); + final games = provider.games.where((g) => !g.isDeleted).toList(); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHero(movies, books, notes), + _buildHero(movies, books, notes, games), const SizedBox(height: 20), if (_userPrefs.showMovieTab) ...[ _buildModuleHeader('影视'), @@ -127,7 +128,7 @@ class _ProfilePageState extends State with RouteAware { // ─── Hero 区域 ────────────────────────────────────────────────────── - Widget _buildHero(List movies, List books, List notes) { + Widget _buildHero(List movies, List books, List notes, List games) { final colors = Theme.of(context).colorScheme; final coverPaths = [ ...movies @@ -236,9 +237,10 @@ class _ProfilePageState extends State with RouteAware { const SizedBox(height: 20), Row( children: [ - _buildHeroStat(_formatCount(movies.length), '观影', hasData), - _buildHeroStat(_formatCount(books.length), '阅读', hasData), - _buildHeroStat(_formatCount(notes.length), '笔记', hasData), + if (_userPrefs.showMovieTab) _buildHeroStat(_formatCount(movies.length), '观影', hasData), + if (_userPrefs.showBookTab) _buildHeroStat(_formatCount(books.length), '阅读', hasData), + if (_userPrefs.showNoteTab) _buildHeroStat(_formatCount(notes.length), '笔记', hasData), + if (_userPrefs.showGameTab) _buildHeroStat(_formatCount(games.length), '游戏', hasData), ], ), ], diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart index bbe1a2f..dba1d3e 100644 --- a/lib/pages/sync/webdav_sync_page.dart +++ b/lib/pages/sync/webdav_sync_page.dart @@ -22,6 +22,7 @@ class _WebDAVSyncPageState extends State { bool _isConfigured = false; bool _obscurePassword = true; SyncDirection _syncDirection = SyncDirection.upload; + String _syncStep = ''; // 远程备份信息 DateTime? _remoteModifiedTime; @@ -125,7 +126,28 @@ class _WebDAVSyncPageState extends State { setState(() => _isLoading = true); try { - final result = await WebDAVService.instance.syncData(direction: _syncDirection); + SyncResult result; + + if (_syncDirection == SyncDirection.upload) { + // 上传:先打包再上传,分步显示 + setState(() => _syncStep = '正在打包数据...'); + final exportResult = await WebDAVService.instance.exportLocalData(); + if (!exportResult.success || exportResult.zipPath == null) { + if (mounted) { + setState(() { _isLoading = false; _syncStep = ''; }); + ToastUtil.show(context, exportResult.errorMessage ?? '创建备份失败'); + } + return; + } + if (!mounted) return; + setState(() => _syncStep = '正在上传到云端...'); + result = await WebDAVService.instance.uploadExportedData(exportResult); + } else { + // 下载 + setState(() => _syncStep = '正在从云端下载...'); + result = await WebDAVService.instance.syncData(direction: SyncDirection.download); + } + if (!mounted) return; if (result.success) { @@ -151,7 +173,7 @@ class _WebDAVSyncPageState extends State { } catch (e) { if (mounted) ToastUtil.show(context, '同步失败: $e'); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) setState(() { _isLoading = false; _syncStep = ''; }); } } @@ -433,11 +455,6 @@ class _WebDAVSyncPageState extends State { const SizedBox(height: 40), ], ), - if (_isLoading) - Container( - color: colors.surface.withValues(alpha: 0.7), - child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)), - ), ], ), ); @@ -526,7 +543,7 @@ class _WebDAVSyncPageState extends State { ); } - Widget _buildBtn(ColorScheme colors, String text, {VoidCallback? onTap, bool loading = false}) { + Widget _buildBtn(ColorScheme colors, String text, {VoidCallback? onTap}) { final disabled = onTap == null; return GestureDetector( onTap: onTap, @@ -538,15 +555,9 @@ class _WebDAVSyncPageState extends State { borderRadius: BorderRadius.circular(8), ), child: Center( - child: loading - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, valueColor: AlwaysStoppedAnimation(colors.onPrimary))) - : Text(text, - style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)), + child: Text(text, + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)), ), ), ); @@ -622,17 +633,31 @@ class _WebDAVSyncPageState extends State { const SizedBox(height: 16), const Divider(height: 0.5, color: Color(0xFFE0E0E0)), const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _buildBtn(colors, '上传', onTap: _isLoading ? null : () => _showUploadConfirm(), loading: _isLoading), + if (_isLoading) ...[ + SizedBox( + width: double.infinity, + child: LinearProgressIndicator( + backgroundColor: colors.surfaceContainerHighest, + color: colors.primary, + minHeight: 3, + borderRadius: BorderRadius.circular(1.5), ), - const SizedBox(width: 12), - Expanded( - child: _buildBtn(colors, '下载', onTap: _isLoading ? null : () => _showDownloadConfirm(), loading: _isLoading), - ), - ], - ), + ), + const SizedBox(height: 10), + Text(_syncStep, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + ] else ...[ + Row( + children: [ + Expanded( + child: _buildBtn(colors, '上传', onTap: _isLoading ? null : () => _showUploadConfirm()), + ), + const SizedBox(width: 12), + Expanded( + child: _buildBtn(colors, '下载', onTap: _isLoading ? null : () => _showDownloadConfirm()), + ), + ], + ), + ], ], ), ); diff --git a/lib/services/sync/webdav_service.dart b/lib/services/sync/webdav_service.dart index b358e12..b8460d1 100644 --- a/lib/services/sync/webdav_service.dart +++ b/lib/services/sync/webdav_service.dart @@ -35,7 +35,6 @@ class SyncResult { enum SyncDirection { upload, // 仅上传 download, // 仅下载 - bidirectional, // 双向同步 } /// WebDAV 服务类 - 完整备份 zip 同步 @@ -189,8 +188,63 @@ class WebDAVService { } } + /// 打包本地数据(第一步,用于上传前单独调用以显示进度) + Future exportLocalData() async { + return BackupService.instance.exportDataForAutoBackup(); + } + + /// 上传已打包的数据(第二步) + Future uploadExportedData(AutoBackupExportResult exportResult) async { + if (!exportResult.success || exportResult.zipPath == null) { + _isSyncing = false; + return SyncResult(success: false, message: exportResult.errorMessage ?? '创建备份失败'); + } + + final config = await getConfig(); + if (config == null) { + _isSyncing = false; + return SyncResult(success: false, message: '未配置 WebDAV'); + } + + try { + final url = config['url']!; + final username = config['username']!; + final password = config['password']!; + final path = config['path']!; + + final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + final dirUrl = '$baseUrl$path'; + + final client = http.Client(); + try { + final fileName = _generateBackupFileName(); + final zipUrl = '$dirUrl/$fileName'; + final success = await _uploadFile(client, zipUrl, username, password, exportResult.zipPath!); + try { await File(exportResult.zipPath!).delete(); } catch (_) {} + if (success) { + debugPrint('[WebDAV] 备份上传成功: $fileName (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})'); + await _cleanupOldBackups(client, dirUrl, username, password); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String()); + return SyncResult( + success: true, message: '同步完成', + uploadedFiles: 1, uploadedImages: exportResult.imageCount, + ); + } else { + return SyncResult(success: false, message: '上传备份文件失败'); + } + } finally { + client.close(); + } + } catch (e) { + return SyncResult(success: false, message: '上传失败: $e'); + } finally { + _isSyncing = false; + } + } + /// 同步数据 — 完整备份 zip 格式,与本地备份完全一致 - Future syncData({SyncDirection direction = SyncDirection.bidirectional}) async { + Future syncData({SyncDirection direction = SyncDirection.upload}) async { // 防止并发同步 if (_isSyncing) { return SyncResult(success: false, message: '同步正在进行中,请稍后再试'); @@ -256,8 +310,8 @@ class WebDAVService { if (success && await tempZip.exists()) { final bytes = await tempZip.readAsBytes(); + try { await tempZip.delete(); } catch (_) {} final importResult = await BackupService.instance.restoreFromZipBytes(bytes); - await tempZip.delete(); if (importResult.success) { downloadedFiles = 1; @@ -268,67 +322,9 @@ class WebDAVService { return SyncResult(success: false, message: importResult.errorMessage ?? '恢复备份失败'); } } else { + try { await tempZip.delete(); } catch (_) {} return SyncResult(success: false, message: '下载备份文件失败'); } - - } else if (direction == SyncDirection.bidirectional) { - // 获取远程最新备份的修改时间 - final backups = await _listRemoteBackups(client, dirUrl, username, password); - DateTime? remoteModTime; - String? latestRemoteFile; - if (backups.isNotEmpty) { - latestRemoteFile = backups.last; - final latestUrl = '$dirUrl/$latestRemoteFile'; - remoteModTime = await _getRemoteFileModifiedTime(client, latestUrl, username, password); - } - - // 获取上次同步时间 - final syncPrefs = await SharedPreferences.getInstance(); - final lastSyncStr = syncPrefs.getString(_lastSyncKey); - final lastSyncTime = lastSyncStr != null ? DateTime.tryParse(lastSyncStr) : null; - - final bool remoteIsNewer = remoteModTime != null && - (lastSyncTime == null || remoteModTime.isAfter(lastSyncTime)); - - if (remoteIsNewer && latestRemoteFile != null) { - // 远程更新,下载并恢复 - final tempDir = await getTemporaryDirectory(); - final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip')); - - final downloadSuccess = await _downloadFile(client, '$dirUrl/$latestRemoteFile', username, password, tempZip); - if (downloadSuccess && await tempZip.exists()) { - final bytes = await tempZip.readAsBytes(); - final importResult = await BackupService.instance.restoreFromZipBytes(bytes); - await tempZip.delete(); - - if (importResult.success) { - downloadedFiles = 1; - downloadedImages = importResult.stats?['图片'] ?? 0; - needReload = true; - debugPrint('[WebDAV] 远程备份较新,已恢复 ($latestRemoteFile): ${importResult.statsText}'); - } - } else { - try { await tempZip.delete(); } catch (_) {} - } - } else { - debugPrint('[WebDAV] 本地数据已是最新或远程无更新,跳过下载'); - } - - // 上传本地备份(无论是否下载,确保远程有最新数据) - final exportResult = await BackupService.instance.exportDataForAutoBackup(); - if (exportResult.success && exportResult.zipPath != null) { - final fileName = _generateBackupFileName(); - final uploadSuccess = await _uploadFile(client, '$dirUrl/$fileName', username, password, exportResult.zipPath!); - // 清理临时 zip 文件 - try { await File(exportResult.zipPath!).delete(); } catch (_) {} - if (uploadSuccess) { - uploadedFiles = 1; - uploadedImages = exportResult.imageCount; - debugPrint('[WebDAV] 本地备份已上传: $fileName (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})'); - // 清理旧备份 - await _cleanupOldBackups(client, dirUrl, username, password); - } - } } final prefs = await SharedPreferences.getInstance(); @@ -511,42 +507,6 @@ class WebDAVService { client.close(); } } - Future _getRemoteFileModifiedTime( - http.Client client, - String url, - String username, - String password, - ) async { - try { - var request = http.Request('HEAD', Uri.parse(url)); - request.headers['Authorization'] = _basicAuth(username, password); - - var response = await client.send(request).timeout(_shortTimeout); - - // 处理重定向 - if (response.statusCode == 301 || response.statusCode == 302 || - response.statusCode == 307 || response.statusCode == 308) { - final location = response.headers['location']; - await response.stream.drain(); - if (location != null) { - request = http.Request('HEAD', Uri.parse(location)); - request.headers['Authorization'] = _basicAuth(username, password); - response = await client.send(request).timeout(_shortTimeout); - } - } - - if (response.statusCode == 200) { - final lastModified = response.headers['last-modified']; - if (lastModified != null) { - return HttpDate.parse(lastModified).toLocal(); - } - } - return null; - } catch (e) { - debugPrint('[WebDAV] 获取远程文件时间失败: $e'); - return null; - } - } /// 生成带毫秒时间戳的备份文件名 String _generateBackupFileName() { diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 13d0713..5de7e5d 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -140,6 +140,13 @@ class _CustomDrawerState extends State { final noteCount = provider.notes.length; final gameCount = provider.games.where((g) => !g.isDeleted).length; + // 根据功能开关过滤显示的统计项 + final statItems = []; + if (userPrefs.showMovieTab) statItems.add(_buildProfileStatRow(Icons.movie_outlined, movieCount, '观影')); + if (userPrefs.showBookTab) statItems.add(_buildProfileStatRow(Icons.menu_book_outlined, bookCount, '阅读')); + if (userPrefs.showNoteTab) statItems.add(_buildProfileStatRow(Icons.note_outlined, noteCount, '笔记')); + if (userPrefs.showGameTab) statItems.add(_buildProfileStatRow(Icons.sports_esports_outlined, gameCount, '游戏')); + return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.all(20), @@ -181,16 +188,15 @@ class _CustomDrawerState extends State { ), ], ), - const SizedBox(height: 16), - Divider(height: 1, color: colors.outlineVariant), - const SizedBox(height: 14), - _buildProfileStatRow(Icons.movie_outlined, movieCount, '观影'), - const SizedBox(height: 12), - _buildProfileStatRow(Icons.menu_book_outlined, bookCount, '阅读'), - const SizedBox(height: 12), - _buildProfileStatRow(Icons.note_outlined, noteCount, '笔记'), - const SizedBox(height: 12), - _buildProfileStatRow(Icons.sports_esports_outlined, gameCount, '游戏'), + if (statItems.isNotEmpty) ...[ + const SizedBox(height: 16), + Divider(height: 1, color: colors.outlineVariant), + const SizedBox(height: 14), + for (int i = 0; i < statItems.length; i++) ...[ + statItems[i], + if (i < statItems.length - 1) const SizedBox(height: 12), + ], + ], ], ), );