diff --git a/README.md b/README.md index 3ea2df8..6028a91 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,56 @@ flutter run - [x] 导出功能(JSON) - [ ] 云同步 + +## 双向同步规则 + +双向同步的规则如下: + +**1. 数据库文件同步规则:** + +| 情况 | 操作 | +| :---------------- | :----------------------- | +| 远程数据库不存在 | 上传本地数据库 | +| 本地较新(>10秒) | 上传本地数据库 | +| 远程较新(>10秒) | 下载远程数据库 | +| 时间相近(±10秒) | 不传输数据库,仅同步图片 | + +**2. 图片同步规则:** + +| 情况 | 操作 | +| :--------------- | :------------------------------------- | +| 本地有,远程没有 | 上传到远程 | +| 远程有,本地没有 | 下载到本地 | +| 两边都有 | 不处理(暂不支持基于时间戳的图片同步) | + +**3. 同步方向选项:** + +- **双向同步** - 按上述规则自动判断上传/下载 +- **仅上传** - 只上传本地数据库和所有本地图片 +- **仅下载** - 只下载远程数据库和所有远程图片 + +**4. 时间戳比较:** + +```dart +// 10秒误差范围内视为相同 +if (timeDiff > 10) { + // 本地较新,上传 +} else if (timeDiff < -10) +{ + // 远程较新,下载 +} else +{ + // 时间相近,仅同步图片 +} +``` + +**5. 文件路径:** + +- 数据库:`/mooknote/mooknote.db` +- 图片:`/mooknote/images/图片文件名` + +**注意:** + +- 目前图片同步是基于文件存在性判断,不是基于修改时间 +- 下载新数据库后,应用会自动重新加载数据(调用 Provider 的 load 方法) +- 首次同步会创建远程目录结构 diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 214e866..ff901e1 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart index 05c411a..a260bfc 100644 --- a/lib/pages/backup_page.dart +++ b/lib/pages/backup_page.dart @@ -30,7 +30,7 @@ class _BackupPageState extends State { // 导出数据 _buildSection( title: '导出数据', - description: '将所有数据导出为 JSON 文件,可用于备份或迁移到其他设备', + description: '将所有数据导出为 zip 文件,可用于备份或迁移到其他设备', icon: Icons.upload_outlined, buttonText: '导出', isLoading: _isExporting, diff --git a/lib/pages/cloud_sync_page.dart b/lib/pages/cloud_sync_page.dart new file mode 100644 index 0000000..d698602 --- /dev/null +++ b/lib/pages/cloud_sync_page.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'webdav_sync_page.dart'; + +/// 云同步主页面 - 选择同步方式 +class CloudSyncPage extends StatelessWidget { + const CloudSyncPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('云同步'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // WebDAV 同步选项 + _buildSyncOption( + context, + icon: Icons.storage_outlined, + title: 'WebDAV 同步', + subtitle: '通过 WebDAV 协议同步到个人云盘(如坚果云、Nextcloud 等)', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const WebDAVSyncPage()), + ); + }, + ), + + const SizedBox(height: 16), + + // 服务器同步选项(暂未开放) + _buildSyncOption( + context, + icon: Icons.cloud_outlined, + title: '服务器同步', + subtitle: '通过自建服务器同步数据(开发中)', + enabled: false, + onTap: () { + // 暂未开放 + }, + ), + + const SizedBox(height: 32), + + // 说明文字 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '关于云同步', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + SizedBox(height: 8), + Text( + '• 云同步可以将您的数据备份到远程服务器\n' + '• 支持多台设备之间的数据同步\n' + '• 建议定期进行云同步以确保数据安全\n' + '• 首次同步可能需要较长时间,请保持网络连接', + style: TextStyle( + fontSize: 13, + color: Color(0xFF666666), + height: 1.6, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSyncOption( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + bool enabled = true, + }) { + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: enabled ? const Color(0xFFE5E5E5) : const Color(0xFFEEEEEE), + ), + color: enabled ? Colors.white : const Color(0xFFF5F5F5), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: enabled + ? const Color(0xFFF5F5F5) + : const Color(0xFFEEEEEE), + ), + child: Icon( + icon, + color: enabled + ? const Color(0xFF1A1A1A) + : const Color(0xFF999999), + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: enabled + ? const Color(0xFF1A1A1A) + : const Color(0xFF999999), + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 13, + color: enabled + ? const Color(0xFF666666) + : const Color(0xFF999999), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: enabled + ? const Color(0xFF999999) + : const Color(0xFFCCCCCC), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index 0cc40a2..7a44f2d 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -5,6 +5,15 @@ import 'movie_tab_page.dart'; import 'book_tab_page.dart'; import 'note_tab_page.dart'; import 'search_page.dart'; +import 'webdav_sync_page.dart'; +import '../utils/webdav_service.dart'; + +/// 云同步模式 +enum SyncMode { + bidirectional, // 双向同步 + uploadOnly, // 仅上传 + downloadOnly, // 仅下载 +} /// 主内容页 - 观影/阅读/笔记标签页 class MainContentPage extends StatelessWidget { @@ -35,6 +44,13 @@ class MainContentPage extends StatelessWidget { return AppBar( title: Text(_getAppBarTitle(provider)), actions: [ + // 云同步按钮 + IconButton( + icon: const Icon(Icons.cloud_sync_outlined), + onPressed: () => _showCloudSyncDialog(context, provider), + tooltip: '云同步', + ), + // 搜索按钮 IconButton( icon: const Icon(Icons.search), onPressed: () { @@ -167,6 +183,249 @@ class MainContentPage extends StatelessWidget { ); } + /// 显示云同步对话框 + Future _showCloudSyncDialog(BuildContext context, AppProvider provider) async { + // 检查是否已配置 WebDAV + final config = await WebDAVService.instance.getConfig(); + + if (!context.mounted) return; + + // 如果没有配置,直接跳转到配置页面 + if (config == null || config.isEmpty) { + // 关闭云同步对话框 + if (Navigator.canPop(context)) { + Navigator.pop(context); + } + + // 等待对话框关闭完成 + await Future.delayed(const Duration(milliseconds: 100)); + + if (!context.mounted) return; + + // 跳转到配置页面 + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const WebDAVSyncPage()), + ); + + // 配置页面返回后,重新检查配置 + if (!context.mounted) return; + + final newConfig = await WebDAVService.instance.getConfig(); + if (newConfig != null && newConfig.isNotEmpty) { + // 配置成功,显示同步选项 + _showSyncOptionsDialog(context, provider); + } + return; + } + + // 已配置,显示同步选项 + _showSyncOptionsDialog(context, provider); + } + + /// 显示同步选项对话框 + void _showSyncOptionsDialog(BuildContext context, AppProvider provider) { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 标题 + Container( + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), + ), + ), + child: const Text( + '云同步', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ), + + // 同步选项 + Column( + children: [ + _buildSyncOption( + context, + icon: Icons.sync, + iconColor: const Color(0xFF1A1A1A), + title: '双向同步', + subtitle: '本地和云端数据合并,冲突时以最新为准', + onTap: () { + Navigator.pop(context); + _navigateToSync(context, SyncMode.bidirectional); + }, + ), + const Divider(height: 0.5, indent: 56, color: Color(0xFFE5E5E5)), + _buildSyncOption( + context, + icon: Icons.cloud_upload, + iconColor: const Color(0xFF1A1A1A), + title: '仅上传', + subtitle: '将本地数据上传到云端,覆盖云端数据', + onTap: () { + Navigator.pop(context); + _navigateToSync(context, SyncMode.uploadOnly); + }, + ), + const Divider(height: 0.5, indent: 56, color: Color(0xFFE5E5E5)), + _buildSyncOption( + context, + icon: Icons.cloud_download, + iconColor: const Color(0xFF1A1A1A), + title: '仅下载', + subtitle: '从云端下载数据到本地,覆盖本地数据', + onTap: () { + Navigator.pop(context); + _navigateToSync(context, SyncMode.downloadOnly); + }, + ), + ], + ), + + // 取消按钮 + Container( + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), + ), + ), + child: InkWell( + onTap: () => Navigator.pop(context), + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), + child: Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.center, + child: const Text( + '取消', + style: TextStyle( + fontSize: 15, + color: Color(0xFF999999), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + /// 构建同步选项 + Widget _buildSyncOption( + BuildContext context, { + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // 图标 + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: const Color(0xFF1A1A1A), + size: 20, + ), + ), + const SizedBox(width: 16), + // 文字 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + // 箭头 + const Icon( + Icons.chevron_right, + color: Color(0xFFCCCCCC), + size: 20, + ), + ], + ), + ), + ); + } + + /// 导航到同步页面 + void _navigateToSync(BuildContext context, SyncMode mode) { + // TODO: 打开 WebDAV 同步页面并传递同步模式 + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => WebDAVSyncPage(syncMode: mode), + // ), + // ); + + // 暂时显示提示 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('即将开始${_getSyncModeText(mode)}...'), + duration: const Duration(seconds: 2), + ), + ); + } + + /// 获取同步模式文本 + String _getSyncModeText(SyncMode mode) { + switch (mode) { + case SyncMode.bidirectional: + return '双向同步'; + case SyncMode.uploadOnly: + return '上传'; + case SyncMode.downloadOnly: + return '下载'; + } + } + /// 显示添加对话框 void _showAddDialog(BuildContext context, AppProvider provider) { showModalBottomSheet( diff --git a/lib/pages/movie_posters_page.dart b/lib/pages/movie_posters_page.dart index f690294..44d573e 100644 --- a/lib/pages/movie_posters_page.dart +++ b/lib/pages/movie_posters_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; import '../utils/toast_util.dart'; +import 'poster_gallery_page.dart'; /// 影视海报墙页面 class MoviePostersPage extends StatefulWidget { @@ -116,6 +117,7 @@ class _MoviePostersPageState extends State { return GestureDetector( onTap: () => _showPosterDetail(poster), + onLongPress: () => _showDeleteDialog(poster), child: Container( height: height, decoration: BoxDecoration( @@ -163,26 +165,6 @@ class _MoviePostersPageState extends State { ), ), ), - // 删除按钮 - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () => _showDeleteDialog(poster), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.9), - borderRadius: BorderRadius.circular(4), - ), - child: const Icon( - Icons.close, - size: 16, - color: Colors.red, - ), - ), - ), - ), ], ), ), @@ -191,21 +173,15 @@ class _MoviePostersPageState extends State { } void _showPosterDetail(MoviePoster poster) { - showDialog( - context: context, - builder: (context) => Dialog( - backgroundColor: Colors.transparent, - insetPadding: const EdgeInsets.all(16), - child: GestureDetector( - onTap: () => Navigator.pop(context), - child: InteractiveViewer( - minScale: 0.5, - maxScale: 3.0, - child: Image.file( - File(poster.posterPath), - fit: BoxFit.contain, - ), - ), + // 找到当前海报的索引 + final initialIndex = _posters.indexWhere((p) => p.id == poster.id); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PosterGalleryPage( + posters: _posters, + initialIndex: initialIndex >= 0 ? initialIndex : 0, ), ), ); diff --git a/lib/pages/poster_gallery_page.dart b/lib/pages/poster_gallery_page.dart new file mode 100644 index 0000000..102e936 --- /dev/null +++ b/lib/pages/poster_gallery_page.dart @@ -0,0 +1,146 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../models/data_models.dart'; + +/// 海报画廊页面 - 支持左右滑动浏览 +class PosterGalleryPage extends StatefulWidget { + final List posters; + final int initialIndex; + + const PosterGalleryPage({ + super.key, + required this.posters, + required this.initialIndex, + }); + + @override + State createState() => _PosterGalleryPageState(); +} + +class _PosterGalleryPageState extends State { + late PageController _pageController; + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // 页面视图 - 支持左右滑动 + PageView.builder( + controller: _pageController, + itemCount: widget.posters.length, + onPageChanged: (index) { + setState(() => _currentIndex = index); + }, + itemBuilder: (context, index) { + final poster = widget.posters[index]; + return InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Center( + child: Image.file( + File(poster.posterPath), + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Center( + child: Icon( + Icons.broken_image, + color: Colors.white54, + size: 64, + ), + ), + ), + ), + ); + }, + ), + + // 顶部导航栏 + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.7), + Colors.transparent, + ], + ), + ), + child: Row( + children: [ + // 返回按钮 + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + const Spacer(), + // 页码指示器 + Text( + '${_currentIndex + 1} / ${widget.posters.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + // 占位,保持对称 + const SizedBox(width: 48), + ], + ), + ), + ), + ), + + // 底部指示器(点状) + if (widget.posters.length > 1) + Positioned( + bottom: 20, + left: 0, + right: 0, + child: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + widget.posters.length, + (index) => Container( + width: 8, + height: 8, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == _currentIndex + ? Colors.white + : Colors.white.withOpacity(0.4), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 9a75033..7a5f296 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -10,6 +10,7 @@ import '../utils/toast_util.dart'; import 'recycle_bin_page.dart'; import 'backup_page.dart'; import 'statistics_page.dart'; +import 'cloud_sync_page.dart'; /// 个人中心页面 - 极简主义设计 class ProfilePage extends StatefulWidget { @@ -99,7 +100,7 @@ class _ProfilePageState extends State { // 版本信息 const Center( child: Text( - 'MookNote v1.0.0', + 'MookNote v0.1.5', style: TextStyle( fontSize: 12, color: Color(0xFF999999), @@ -393,19 +394,6 @@ class _ProfilePageState extends State { ), const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), - - _buildMenuItem( - icon: Icons.delete_outline, - title: '回收站', - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const RecycleBinPage()), - ); - }, - ), - const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), - _buildMenuItem( icon: Icons.backup_outlined, title: '数据备份', @@ -416,6 +404,30 @@ class _ProfilePageState extends State { ); }, ), + const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), + + _buildMenuItem( + icon: Icons.cloud_sync_outlined, + title: '云同步', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const CloudSyncPage()), + ); + }, + ), + const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)), + + _buildMenuItem( + icon: Icons.delete_outline, + title: '回收站', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const RecycleBinPage()), + ); + }, + ), ], ); } diff --git a/lib/pages/webdav_sync_page.dart b/lib/pages/webdav_sync_page.dart new file mode 100644 index 0000000..b4cbd20 --- /dev/null +++ b/lib/pages/webdav_sync_page.dart @@ -0,0 +1,537 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../utils/toast_util.dart'; +import '../utils/webdav_service.dart'; +import '../providers/app_provider.dart'; + +/// WebDAV 同步页面 +class WebDAVSyncPage extends StatefulWidget { + const WebDAVSyncPage({super.key}); + + @override + State createState() => _WebDAVSyncPageState(); +} + +class _WebDAVSyncPageState extends State { + final _urlController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _pathController = TextEditingController(text: '/mooknote'); + + bool _isLoading = false; + bool _isConfigured = false; + bool _obscurePassword = true; + SyncDirection _syncDirection = SyncDirection.bidirectional; + SyncResult? _lastSyncResult; + + @override + void initState() { + super.initState(); + _loadConfig(); + } + + @override + void dispose() { + _urlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _pathController.dispose(); + super.dispose(); + } + + /// 加载已保存的配置 + Future _loadConfig() async { + final config = await WebDAVService.instance.getConfig(); + if (config != null) { + setState(() { + _urlController.text = config['url'] ?? ''; + _usernameController.text = config['username'] ?? ''; + _passwordController.text = config['password'] ?? ''; + _pathController.text = config['path'] ?? '/mooknote'; + _isConfigured = true; + }); + } + } + + /// 保存配置 + Future _saveConfig() async { + final url = _urlController.text.trim(); + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final path = _pathController.text.trim(); + + if (url.isEmpty) { + ToastUtil.show(context, '请输入服务器地址'); + return; + } + if (username.isEmpty) { + ToastUtil.show(context, '请输入用户名'); + return; + } + if (password.isEmpty) { + ToastUtil.show(context, '请输入密码'); + return; + } + + setState(() => _isLoading = true); + + try { + // 测试连接 + final result = await WebDAVService.instance.testConnection( + url: url, + username: username, + password: password, + path: path, + ); + + if (!mounted) return; + + if (result['success'] == true) { + // 保存配置 + await WebDAVService.instance.saveConfig( + url: url, + username: username, + password: password, + path: path, + ); + + setState(() => _isConfigured = true); + ToastUtil.show(context, result['message'] ?? '连接成功,配置已保存'); + + // 延迟一下再返回,确保 Toast 显示出来 + await Future.delayed(const Duration(milliseconds: 500)); + + // 如果是首次配置成功,返回 true 给调用方 + if (mounted) { + Navigator.maybePop(context, true); + } + } else { + ToastUtil.show(context, result['message'] ?? '连接失败,请检查配置'); + } + } catch (e) { + if (mounted) { + ToastUtil.show(context, '连接失败: $e'); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// 执行同步 + Future _syncData() async { + setState(() => _isLoading = true); + + try { + final result = await WebDAVService.instance.syncData(direction: _syncDirection); + + if (!mounted) return; + + setState(() => _lastSyncResult = result); + + if (result.success) { + final details = '上传: ${result.uploadedFiles} 文件, ${result.uploadedImages} 图片\n' + '下载: ${result.downloadedFiles} 文件, ${result.downloadedImages} 图片'; + + // 如果需要重新加载数据(下载了数据库) + if (result.needReload) { + // 显示提示 + ToastUtil.show(context, '数据已更新,正在重新加载...'); + + // 重新加载所有数据 + final provider = context.read(); + await provider.loadMovies(); + await provider.loadBooks(); + await provider.loadNotes(); + + if (mounted) { + _showSyncResultDialog('同步成功(数据已刷新)', details); + } + } else { + _showSyncResultDialog('同步成功', details); + } + } else { + ToastUtil.show(context, result.message); + } + } catch (e) { + if (mounted) { + ToastUtil.show(context, '同步失败: $e'); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// 显示同步结果对话框 + void _showSyncResultDialog(String title, String content) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + ); + } + + /// 清除配置 + Future _clearConfig() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('清除配置'), + content: const Text('确定要清除 WebDAV 配置吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('清除', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed == true) { + await WebDAVService.instance.clearConfig(); + setState(() { + _urlController.clear(); + _usernameController.clear(); + _passwordController.clear(); + _pathController.text = '/mooknote'; + _isConfigured = false; + }); + if (mounted) { + ToastUtil.show(context, '配置已清除'); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('WebDAV 同步'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.maybePop(context), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 状态提示 + if (_isConfigured) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: const BoxDecoration( + color: Color(0xFFF5F5F5), + border: Border( + left: BorderSide(color: Color(0xFF1A1A1A), width: 4), + ), + ), + child: const Row( + children: [ + Icon(Icons.check_circle, color: Color(0xFF1A1A1A), size: 20), + SizedBox(width: 8), + Text( + '已配置 WebDAV', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + ], + ), + ), + + // 服务器地址 + _buildTextField( + controller: _urlController, + label: '服务器地址', + hint: 'https://dav.example.com 或 http://192.168.1.1:5244', + icon: Icons.link, + ), + const SizedBox(height: 16), + + // 用户名 + _buildTextField( + controller: _usernameController, + label: '用户名', + hint: '请输入用户名', + icon: Icons.person_outline, + ), + const SizedBox(height: 16), + + // 密码 + _buildTextField( + controller: _passwordController, + label: '密码', + hint: '请输入密码', + icon: Icons.lock_outline, + obscureText: _obscurePassword, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + color: const Color(0xFF999999), + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + const SizedBox(height: 16), + + // 同步路径 + _buildTextField( + controller: _pathController, + label: '同步路径', + hint: '/mooknote', + icon: Icons.folder_outlined, + ), + const SizedBox(height: 24), + + // 保存配置按钮 + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _saveConfig, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A1A1A), + foregroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text( + '测试并保存', + style: TextStyle(fontSize: 16), + ), + ), + ), + + if (_isConfigured) ...[ + const SizedBox(height: 24), + + // 同步方向选择 + const Text( + '同步方向', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildDirectionButton( + '双向同步', + SyncDirection.bidirectional, + Icons.sync, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildDirectionButton( + '仅上传', + SyncDirection.upload, + Icons.upload, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildDirectionButton( + '仅下载', + SyncDirection.download, + Icons.download, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 同步按钮 + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _syncData, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A1A), + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + side: const BorderSide(color: Color(0xFF1A1A1A)), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text( + '立即同步', + style: TextStyle(fontSize: 16), + ), + ), + ), + const SizedBox(height: 16), + + // 清除配置按钮 + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: _isLoading ? null : _clearConfig, + style: TextButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text('清除配置'), + ), + ), + ], + + const SizedBox(height: 32), + + // 说明 + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '使用说明', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + SizedBox(height: 8), + Text( + '• 支持坚果云、Nextcloud、AList 等 WebDAV 服务\n' + '• 服务器地址需包含协议(http:// 或 https://)\n' + '• 同步前请确保服务器可用且空间充足\n' + '• 首次同步将上传所有数据,后续只同步变更', + style: TextStyle( + fontSize: 13, + color: Color(0xFF666666), + height: 1.6, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required String hint, + required IconData icon, + bool obscureText = false, + Widget? suffixIcon, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + obscureText: obscureText, + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFFCCCCCC)), + prefixIcon: Icon(icon, color: const Color(0xFF999999)), + suffixIcon: suffixIcon, + border: const OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFFE5E5E5)), + ), + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFFE5E5E5)), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ], + ); + } + + Widget _buildDirectionButton(String label, SyncDirection direction, IconData icon) { + final isSelected = _syncDirection == direction; + return GestureDetector( + onTap: () => setState(() => _syncDirection = direction), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), + border: Border.all( + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE5E5E5), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 20, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/book_dao.dart b/lib/utils/book_dao.dart index 731c0fc..cd41465 100644 --- a/lib/utils/book_dao.dart +++ b/lib/utils/book_dao.dart @@ -13,7 +13,7 @@ class BookDao { 'books', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Book.fromJson(maps[i])); @@ -26,7 +26,7 @@ class BookDao { 'books', where: 'status = ? AND is_deleted = ?', whereArgs: [status, 0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Book.fromJson(maps[i])); @@ -93,7 +93,7 @@ class BookDao { 'books', where: '(title LIKE ? OR alternate_titles LIKE ?) AND is_deleted = ?', whereArgs: ['%$query%', '%$query%', 0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Book.fromJson(maps[i])); @@ -106,7 +106,7 @@ class BookDao { 'books', where: 'authors LIKE ? AND is_deleted = ?', whereArgs: ['%$author%', 0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Book.fromJson(maps[i])); @@ -119,7 +119,7 @@ class BookDao { 'books', where: 'genres LIKE ? AND is_deleted = ?', whereArgs: ['%$genre%', 0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Book.fromJson(maps[i])); @@ -134,7 +134,7 @@ class BookDao { 'books', where: 'is_deleted = ?', whereArgs: [1], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Book.fromJson(maps[i])); diff --git a/lib/utils/movie_dao.dart b/lib/utils/movie_dao.dart index d6e323c..e774ef7 100644 --- a/lib/utils/movie_dao.dart +++ b/lib/utils/movie_dao.dart @@ -13,7 +13,7 @@ class MovieDao { 'movies', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); @@ -26,7 +26,7 @@ class MovieDao { 'movies', where: 'status = ? AND is_deleted = ?', whereArgs: [status, 0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); @@ -39,7 +39,7 @@ class MovieDao { 'movies', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])) @@ -54,7 +54,7 @@ class MovieDao { 'movies', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])) @@ -69,7 +69,7 @@ class MovieDao { 'movies', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])) @@ -84,7 +84,7 @@ class MovieDao { 'movies', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])) @@ -99,7 +99,7 @@ class MovieDao { 'movies', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); final lowerKeyword = keyword.toLowerCase(); @@ -187,7 +187,7 @@ class MovieDao { 'movies', where: 'is_deleted = ?', whereArgs: [1], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); diff --git a/lib/utils/note_dao.dart b/lib/utils/note_dao.dart index 89ff32b..84a3064 100644 --- a/lib/utils/note_dao.dart +++ b/lib/utils/note_dao.dart @@ -13,7 +13,7 @@ class NoteDao { 'notes', where: 'is_deleted = ?', whereArgs: [0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Note.fromJson(maps[i])); @@ -69,7 +69,7 @@ class NoteDao { 'notes', where: 'is_deleted = ?', whereArgs: [1], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Note.fromJson(maps[i])); @@ -103,7 +103,7 @@ class NoteDao { 'notes', where: '(content LIKE ? OR tags LIKE ?) AND is_deleted = ?', whereArgs: ['%$query%', '%$query%', 0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Note.fromJson(maps[i])); @@ -116,7 +116,7 @@ class NoteDao { 'notes', where: 'tags LIKE ? AND is_deleted = ?', whereArgs: ['%$tag%', 0], - orderBy: 'updated_at DESC', + orderBy: 'created_at DESC', ); return List.generate(maps.length, (i) => Note.fromJson(maps[i])); diff --git a/lib/utils/webdav_service.dart b/lib/utils/webdav_service.dart new file mode 100644 index 0000000..4126e72 --- /dev/null +++ b/lib/utils/webdav_service.dart @@ -0,0 +1,631 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as p; + +/// WebDAV 同步结果 +class SyncResult { + final bool success; + final String message; + final DateTime? lastSyncTime; + final int uploadedFiles; + final int downloadedFiles; + final int uploadedImages; + final int downloadedImages; + final bool needReload; // 是否需要重新加载数据 + + SyncResult({ + required this.success, + required this.message, + this.lastSyncTime, + this.uploadedFiles = 0, + this.downloadedFiles = 0, + this.uploadedImages = 0, + this.downloadedImages = 0, + this.needReload = false, + }); +} + +/// 同步方向 +enum SyncDirection { + upload, // 仅上传 + download, // 仅下载 + bidirectional, // 双向同步 +} + +/// 图片同步结果 +class _ImageSyncResult { + final int uploaded; + final int downloaded; + _ImageSyncResult({required this.uploaded, required this.downloaded}); +} + +/// WebDAV 服务类 +class WebDAVService { + static final WebDAVService _instance = WebDAVService._internal(); + static WebDAVService get instance => _instance; + + WebDAVService._internal(); + + static const String _configKey = 'webdav_config'; + static const String _lastSyncKey = 'webdav_last_sync'; + + Map? _cachedConfig; + + /// 获取配置 + Future?> getConfig() async { + if (_cachedConfig != null) { + return _cachedConfig; + } + + final prefs = await SharedPreferences.getInstance(); + final configJson = prefs.getString(_configKey); + if (configJson != null) { + try { + final config = Map.from(jsonDecode(configJson)); + _cachedConfig = config; + return config; + } catch (e) { + return null; + } + } + return null; + } + + /// 保存配置 + Future saveConfig({ + required String url, + required String username, + required String password, + required String path, + }) async { + final config = { + 'url': url, + 'username': username, + 'password': password, + 'path': path, + }; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_configKey, jsonEncode(config)); + _cachedConfig = config; + } + + /// 清除配置 + Future clearConfig() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_configKey); + await prefs.remove(_lastSyncKey); + _cachedConfig = null; + } + + /// 测试连接 + Future> testConnection({ + required String url, + required String username, + required String password, + required String path, + }) async { + try { + // 构建 WebDAV URL + final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + var davUrl = '$baseUrl$path'; + + print('WebDAV: Testing connection to $davUrl'); + + // 先尝试 PROPFIND 请求(更通用的测试方式) + final client = http.Client(); + try { + var propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl)); + propfindRequest.headers['Authorization'] = _basicAuth(username, password); + propfindRequest.headers['Depth'] = '0'; + propfindRequest.body = ''' + + + + +'''; + + var propfindResponse = await client.send(propfindRequest); + print('WebDAV: PROPFIND status ${propfindResponse.statusCode}'); + + // 处理重定向 (301, 302, 307, 308) + if (propfindResponse.statusCode == 301 || + propfindResponse.statusCode == 302 || + propfindResponse.statusCode == 307 || + propfindResponse.statusCode == 308) { + final location = propfindResponse.headers['location']; + if (location != null) { + print('WebDAV: Redirecting to $location'); + // 使用重定向后的 URL 重新请求 + davUrl = location; + propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl)); + propfindRequest.headers['Authorization'] = _basicAuth(username, password); + propfindRequest.headers['Depth'] = '0'; + propfindRequest.body = ''' + + + + +'''; + propfindResponse = await client.send(propfindRequest); + print('WebDAV: PROPFIND after redirect status ${propfindResponse.statusCode}'); + } + } + + if (propfindResponse.statusCode == 207) { + return {'success': true, 'message': '连接成功'}; + } else if (propfindResponse.statusCode == 401) { + return {'success': false, 'message': '认证失败,请检查用户名和密码'}; + } else if (propfindResponse.statusCode == 404) { + // 目录不存在,尝试创建 + print('WebDAV: Directory not found, trying to create...'); + } else if (propfindResponse.statusCode == 301 || + propfindResponse.statusCode == 302 || + propfindResponse.statusCode == 307 || + propfindResponse.statusCode == 308) { + return {'success': false, 'message': '服务器重定向,请尝试使用 ${propfindResponse.headers["location"] ?? "其他地址"}'}; + } else { + return {'success': false, 'message': '服务器返回错误: ${propfindResponse.statusCode}'}; + } + } catch (e) { + print('WebDAV: PROPFIND error: $e'); + } + + // 尝试创建目录 + try { + final mkcolRequest = http.Request('MKCOL', Uri.parse(davUrl)); + mkcolRequest.headers['Authorization'] = _basicAuth(username, password); + + final mkcolResponse = await client.send(mkcolRequest); + print('WebDAV: MKCOL status ${mkcolResponse.statusCode}'); + + if (mkcolResponse.statusCode == 201) { + return {'success': true, 'message': '连接成功,已创建目录'}; + } else if (mkcolResponse.statusCode == 405) { + return {'success': true, 'message': '连接成功,目录已存在'}; + } else if (mkcolResponse.statusCode == 401) { + return {'success': false, 'message': '认证失败,请检查用户名和密码'}; + } else if (mkcolResponse.statusCode == 409) { + return {'success': false, 'message': '父目录不存在,请检查路径'}; + } else { + return {'success': false, 'message': '创建目录失败: ${mkcolResponse.statusCode}'}; + } + } catch (e) { + print('WebDAV: MKCOL error: $e'); + return {'success': false, 'message': '连接失败: $e'}; + } finally { + client.close(); + } + } catch (e) { + print('WebDAV test connection error: $e'); + return {'success': false, 'message': '连接失败: $e'}; + } + } + + /// 同步数据(双向同步) + Future syncData({SyncDirection direction = SyncDirection.bidirectional}) async { + final config = await getConfig(); + if (config == null) { + return SyncResult(success: false, message: '未配置 WebDAV'); + } + + try { + final url = config['url']!; + final username = config['username']!; + final password = config['password']!; + final path = config['path']!; + + // 获取本地数据库文件路径 + final dbPath = await getDatabasesPath(); + final dbFile = File(p.join(dbPath, 'mooknote.db')); + + print('WebDAV: Looking for database at ${dbFile.path}'); + + if (!await dbFile.exists()) { + return SyncResult(success: false, message: '本地数据库不存在'); + } + + // 构建 WebDAV URL + final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + var davUrl = '$baseUrl$path/mooknote.db'; + final davImagesUrl = '$baseUrl$path/images'; + + final client = http.Client(); + int uploadedFiles = 0; + int downloadedFiles = 0; + int uploadedImages = 0; + int downloadedImages = 0; + + try { + // 1. 检查远程数据库是否存在 + final remoteDbInfo = await _getRemoteFileInfo(client, davUrl, username, password); + + if (direction == SyncDirection.upload) { + // 仅上传模式 + print('WebDAV: Upload only mode'); + final result = await _uploadFile(client, davUrl, username, password, dbFile); + if (result) { + uploadedFiles++; + // 同步图片 + final imageResult = await _syncImages(client, davImagesUrl, username, password, SyncDirection.upload); + uploadedImages = imageResult.uploaded; + downloadedImages = imageResult.downloaded; + } + } else if (direction == SyncDirection.download) { + // 仅下载模式 + print('WebDAV: Download only mode'); + if (remoteDbInfo != null) { + final result = await _downloadFile(client, davUrl, username, password, dbFile); + if (result) { + downloadedFiles++; + // 同步图片 + final imageResult = await _syncImages(client, davImagesUrl, username, password, SyncDirection.download); + uploadedImages = imageResult.uploaded; + downloadedImages = imageResult.downloaded; + } + } else { + return SyncResult(success: false, message: '远程数据库不存在'); + } + } else { + // 双向同步模式 + print('WebDAV: Bidirectional sync mode'); + + if (remoteDbInfo == null) { + // 远程不存在,直接上传 + print('WebDAV: Remote DB not found, uploading...'); + final result = await _uploadFile(client, davUrl, username, password, dbFile); + if (result) { + uploadedFiles++; + // 上传所有图片 + final imageResult = await _syncImages(client, davImagesUrl, username, password, SyncDirection.upload); + uploadedImages = imageResult.uploaded; + } + } else { + // 远程存在,比较修改时间 + final localModified = await dbFile.lastModified(); + final remoteModified = remoteDbInfo['modified'] as DateTime; + + print('WebDAV: Local modified: $localModified'); + print('WebDAV: Remote modified: $remoteModified'); + + final timeDiff = localModified.difference(remoteModified).inSeconds; + + if (timeDiff > 10) { + // 本地较新,上传 + print('WebDAV: Local is newer, uploading...'); + final result = await _uploadFile(client, davUrl, username, password, dbFile); + if (result) { + uploadedFiles++; + // 同步图片 + final imageResult = await _syncImages(client, davImagesUrl, username, password, SyncDirection.bidirectional); + uploadedImages = imageResult.uploaded; + downloadedImages = imageResult.downloaded; + } + } else if (timeDiff < -10) { + // 远程较新,下载 + print('WebDAV: Remote is newer, downloading...'); + final result = await _downloadFile(client, davUrl, username, password, dbFile); + if (result) { + downloadedFiles++; + // 同步图片 + final imageResult = await _syncImages(client, davImagesUrl, username, password, SyncDirection.bidirectional); + uploadedImages = imageResult.uploaded; + downloadedImages = imageResult.downloaded; + } + } else { + // 时间相近,视为相同 + print('WebDAV: Local and remote are similar, syncing images only...'); + final imageResult = await _syncImages(client, davImagesUrl, username, password, SyncDirection.bidirectional); + uploadedImages = imageResult.uploaded; + downloadedImages = imageResult.downloaded; + } + } + } + + // 保存同步时间 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String()); + + // 如果下载了数据库文件,需要重新加载 + final needReload = downloadedFiles > 0; + + return SyncResult( + success: true, + message: '同步完成', + lastSyncTime: DateTime.now(), + uploadedFiles: uploadedFiles, + downloadedFiles: downloadedFiles, + uploadedImages: uploadedImages, + downloadedImages: downloadedImages, + needReload: needReload, + ); + } finally { + client.close(); + } + } catch (e) { + print('WebDAV sync error: $e'); + return SyncResult(success: false, message: '同步失败: $e'); + } + } + + /// 获取远程文件信息 + Future?> _getRemoteFileInfo( + http.Client client, + String url, + String username, + String password, + ) async { + try { + var request = http.Request('PROPFIND', Uri.parse(url)); + request.headers['Authorization'] = _basicAuth(username, password); + request.headers['Depth'] = '0'; + + var response = await client.send(request); + + // 处理重定向 + if (response.statusCode == 301 || response.statusCode == 302 || + response.statusCode == 307 || response.statusCode == 308) { + final location = response.headers['location']; + if (location != null) { + url = location; + request = http.Request('PROPFIND', Uri.parse(url)); + request.headers['Authorization'] = _basicAuth(username, password); + request.headers['Depth'] = '0'; + response = await client.send(request); + } + } + + if (response.statusCode == 207) { + // 解析 PROPFIND 响应获取修改时间 + final body = await response.stream.bytesToString(); + // 简单解析,提取 getlastmodified + final modifiedMatch = RegExp(r'([^<]+)', caseSensitive: false) + .firstMatch(body); + if (modifiedMatch != null) { + final modifiedStr = modifiedMatch.group(1)!; + final modified = HttpDate.parse(modifiedStr); + return {'modified': modified, 'url': url}; + } + return {'modified': DateTime.now(), 'url': url}; + } + return null; + } catch (e) { + print('WebDAV: Get remote file info error: $e'); + return null; + } + } + + /// 上传文件 + Future _uploadFile( + http.Client client, + String url, + String username, + String password, + File file, + ) async { + try { + final fileBytes = await file.readAsBytes(); + + var request = http.Request('PUT', Uri.parse(url)); + request.headers['Authorization'] = _basicAuth(username, password); + request.headers['Content-Type'] = 'application/octet-stream'; + request.bodyBytes = fileBytes; + + var response = await client.send(request); + + // 处理重定向 + if (response.statusCode == 301 || response.statusCode == 302 || + response.statusCode == 307 || response.statusCode == 308) { + final location = response.headers['location']; + if (location != null) { + request = http.Request('PUT', Uri.parse(location)); + request.headers['Authorization'] = _basicAuth(username, password); + request.headers['Content-Type'] = 'application/octet-stream'; + request.bodyBytes = fileBytes; + response = await client.send(request); + } + } + + return response.statusCode == 201 || response.statusCode == 204; + } catch (e) { + print('WebDAV: Upload error: $e'); + return false; + } + } + + /// 下载文件 + Future _downloadFile( + http.Client client, + String url, + String username, + String password, + File localFile, + ) async { + try { + var request = http.Request('GET', Uri.parse(url)); + request.headers['Authorization'] = _basicAuth(username, password); + + var response = await client.send(request); + + // 处理重定向 + if (response.statusCode == 301 || response.statusCode == 302 || + response.statusCode == 307 || response.statusCode == 308) { + final location = response.headers['location']; + if (location != null) { + request = http.Request('GET', Uri.parse(location)); + request.headers['Authorization'] = _basicAuth(username, password); + response = await client.send(request); + } + } + + if (response.statusCode == 200) { + final bytes = await response.stream.toBytes(); + await localFile.writeAsBytes(bytes); + return true; + } + return false; + } catch (e) { + print('WebDAV: Download error: $e'); + return false; + } + } + + /// 同步图片 + Future<_ImageSyncResult> _syncImages( + http.Client client, + String imagesUrl, + String username, + String password, + SyncDirection direction, + ) async { + int uploaded = 0; + int downloaded = 0; + + try { + // 获取本地图片目录 + final appDir = await getApplicationDocumentsDirectory(); + final localImagesDir = Directory('${appDir.path}/images'); + + if (!await localImagesDir.exists()) { + await localImagesDir.create(recursive: true); + } + + // 获取本地图片列表 + final localImages = {}; + if (await localImagesDir.exists()) { + await for (final entity in localImagesDir.list()) { + if (entity is File) { + final name = p.basename(entity.path); + localImages[name] = entity; + } + } + } + + print('WebDAV: Local images: ${localImages.length}'); + + // 获取远程图片列表 + final remoteImages = await _listRemoteImages(client, imagesUrl, username, password); + print('WebDAV: Remote images: ${remoteImages.length}'); + + if (direction == SyncDirection.upload) { + // 仅上传:上传所有本地图片 + for (final entry in localImages.entries) { + final remoteUrl = '$imagesUrl/${entry.key}'; + final success = await _uploadFile(client, remoteUrl, username, password, entry.value); + if (success) uploaded++; + } + } else if (direction == SyncDirection.download) { + // 仅下载:下载所有远程图片 + for (final name in remoteImages) { + final remoteUrl = '$imagesUrl/$name'; + final localFile = File('${localImagesDir.path}/$name'); + final success = await _downloadFile(client, remoteUrl, username, password, localFile); + if (success) downloaded++; + } + } else { + // 双向同步:比较时间戳 + // 上传本地有但远程没有的 + for (final entry in localImages.entries) { + if (!remoteImages.contains(entry.key)) { + final remoteUrl = '$imagesUrl/${entry.key}'; + final success = await _uploadFile(client, remoteUrl, username, password, entry.value); + if (success) uploaded++; + } + } + + // 下载远程有但本地没有的 + for (final name in remoteImages) { + if (!localImages.containsKey(name)) { + final remoteUrl = '$imagesUrl/$name'; + final localFile = File('${localImagesDir.path}/$name'); + final success = await _downloadFile(client, remoteUrl, username, password, localFile); + if (success) downloaded++; + } + } + } + } catch (e) { + print('WebDAV: Sync images error: $e'); + } + + return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded); + } + + /// 获取远程图片列表 + Future> _listRemoteImages( + http.Client client, + String imagesUrl, + String username, + String password, + ) async { + final images = []; + + try { + // 创建图片目录(如果不存在) + final mkcolRequest = http.Request('MKCOL', Uri.parse(imagesUrl)); + mkcolRequest.headers['Authorization'] = _basicAuth(username, password); + await client.send(mkcolRequest); + + // 列出目录内容 + var request = http.Request('PROPFIND', Uri.parse(imagesUrl)); + request.headers['Authorization'] = _basicAuth(username, password); + request.headers['Depth'] = '1'; + + var response = await client.send(request); + + // 处理重定向 + if (response.statusCode == 301 || response.statusCode == 302 || + response.statusCode == 307 || response.statusCode == 308) { + final location = response.headers['location']; + if (location != null) { + imagesUrl = location; + request = http.Request('PROPFIND', Uri.parse(imagesUrl)); + request.headers['Authorization'] = _basicAuth(username, password); + request.headers['Depth'] = '1'; + response = await client.send(request); + } + } + + if (response.statusCode == 207) { + final body = await response.stream.bytesToString(); + // 解析响应,提取文件名 + final hrefMatches = RegExp(r'([^<]+)', caseSensitive: false) + .allMatches(body); + for (final match in hrefMatches) { + final href = match.group(1)!; + final name = p.basename(href); + if (name.isNotEmpty && name != 'images') { + images.add(name); + } + } + } + } catch (e) { + print('WebDAV: List remote images error: $e'); + } + + return images; + } + + /// 获取上次同步时间 + Future getLastSyncTime() async { + final prefs = await SharedPreferences.getInstance(); + final timeStr = prefs.getString(_lastSyncKey); + if (timeStr != null) { + try { + return DateTime.parse(timeStr); + } catch (e) { + return null; + } + } + return null; + } + + /// Basic Auth 编码 + String _basicAuth(String username, String password) { + final credentials = base64Encode(utf8.encode('$username:$password')); + return 'Basic $credentials'; + } +} diff --git a/lib/widgets/book_status_bar.dart b/lib/widgets/book_status_bar.dart index d6feaa4..34c02a9 100644 --- a/lib/widgets/book_status_bar.dart +++ b/lib/widgets/book_status_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 阅读状态选择栏 - 极简主义设计 +/// 阅读状态选择栏 - 现代胶囊式设计 class BookStatusBar extends StatelessWidget { const BookStatusBar({super.key}); @@ -11,39 +11,41 @@ class BookStatusBar extends StatelessWidget { return Consumer( builder: (context, provider, child) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( color: Colors.white, border: Border( bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), ), ), - child: Row( - children: [ - _buildStatusItem( - context, - '已读', - 0, - provider.bookStatusIndex, - () => provider.setBookStatusIndex(0), - ), - const SizedBox(width: 16), - _buildStatusItem( - context, - '在读', - 1, - provider.bookStatusIndex, - () => provider.setBookStatusIndex(1), - ), - const SizedBox(width: 16), - _buildStatusItem( - context, - '想读', - 2, - provider.bookStatusIndex, - () => provider.setBookStatusIndex(2), - ), - ], + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + _buildStatusItem( + label: '已读', + icon: Icons.check_circle_outline, + isSelected: provider.bookStatusIndex == 0, + onTap: () => provider.setBookStatusIndex(0), + ), + _buildStatusItem( + label: '在读', + icon: Icons.menu_book_outlined, + isSelected: provider.bookStatusIndex == 1, + onTap: () => provider.setBookStatusIndex(1), + ), + _buildStatusItem( + label: '想读', + icon: Icons.bookmark_outline, + isSelected: provider.bookStatusIndex == 2, + onTap: () => provider.setBookStatusIndex(2), + ), + ], + ), ), ); }, @@ -51,47 +53,50 @@ class BookStatusBar extends StatelessWidget { } /// 构建状态项 - Widget _buildStatusItem( - BuildContext context, - String label, - int index, - int currentIndex, - VoidCallback onTap, - ) { - final isSelected = index == currentIndex; - - Color color; - switch (index) { - case 0: - color = const Color(0xFF1A1A1A); - break; - case 1: - color = const Color(0xFF666666); - break; - case 2: - color = const Color(0xFF999999); - break; - default: - color = const Color(0xFFCCCCCC); - } - + Widget _buildStatusItem({ + required String label, + required IconData icon, + required bool isSelected, + required VoidCallback onTap, + }) { return Expanded( - child: InkWell( + child: GestureDetector( onTap: onTap, - child: Container( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( - color: isSelected ? color : Colors.transparent, - border: Border.all(color: color), + color: isSelected ? const Color(0xFF1A1A1A) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, - color: isSelected ? Colors.white : color, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 16, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + ), + ], ), ), ), diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index ac93d8c..0d2ca90 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 自定义底部导航栏 +/// 自定义底部导航栏 - 极简主义设计 class CustomBottomNavBar extends StatelessWidget { const CustomBottomNavBar({super.key}); @@ -10,39 +10,86 @@ class CustomBottomNavBar extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, provider, child) { - return BottomNavigationBar( - currentIndex: provider.bottomNavIndex, - onTap: (index) { - if (index == 1) { - // 新增按钮 - 显示选择对话框 - _showAddDialog(context, provider); - } else { - // 切换主页/我的页面 - provider.setBottomNavIndex(index); - } - }, - type: BottomNavigationBarType.fixed, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - activeIcon: Icon(Icons.home), - label: '主页', + return Container( + height: 48, + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), ), - BottomNavigationBarItem( - icon: Icon(Icons.add_circle_outline), - activeIcon: Icon(Icons.add_circle), - label: '新增', - ), - BottomNavigationBarItem( - icon: Icon(Icons.person_outline), - activeIcon: Icon(Icons.person), - label: '我的', - ), - ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + // 主页按钮 + _buildNavItem( + icon: Icons.home_outlined, + activeIcon: Icons.home, + isActive: provider.bottomNavIndex == 0, + onTap: () => provider.setBottomNavIndex(0), + ), + + // 中间新增按钮 + _buildAddButton(context, provider), + + // 我的按钮 + _buildNavItem( + icon: Icons.person_outline, + activeIcon: Icons.person, + isActive: provider.bottomNavIndex == 2, + onTap: () => provider.setBottomNavIndex(2), + ), + ], + ), ); }, ); } + + /// 构建导航项 + Widget _buildNavItem({ + required IconData icon, + required IconData activeIcon, + required bool isActive, + required VoidCallback onTap, + }) { + return Expanded( + child: InkWell( + onTap: onTap, + child: Container( + color: Colors.transparent, + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Icon( + isActive ? activeIcon : icon, + color: isActive ? const Color(0xFF1A1A1A) : const Color(0xFF999999), + size: 24, + ), + ), + ), + ), + ); + } + + /// 构建中间新增按钮 + Widget _buildAddButton(BuildContext context, AppProvider provider) { + return InkWell( + onTap: () => _showAddDialog(context, provider), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.add, + color: Colors.white, + size: 24, + ), + ), + ); + } /// 显示新增对话框 void _showAddDialog(BuildContext context, AppProvider provider) { diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 12b3294..f06475e 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -32,7 +32,7 @@ class CustomDrawer extends StatelessWidget { Container( padding: const EdgeInsets.all(24), child: const Text( - 'MookNote v1.0.0', + 'MookNote v0.1.5', style: TextStyle( fontSize: 12, color: Color(0xFF999999), diff --git a/lib/widgets/movie_status_bar.dart b/lib/widgets/movie_status_bar.dart index e565e7a..7777612 100644 --- a/lib/widgets/movie_status_bar.dart +++ b/lib/widgets/movie_status_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 观影状态选择栏 - 极简主义设计 +/// 观影状态选择栏 - 现代胶囊式设计 class MovieStatusBar extends StatelessWidget { const MovieStatusBar({super.key}); @@ -11,39 +11,41 @@ class MovieStatusBar extends StatelessWidget { return Consumer( builder: (context, provider, child) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( color: Colors.white, border: Border( bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), ), ), - child: Row( - children: [ - _buildStatusItem( - context, - '已看', - 0, - provider.movieStatusIndex, - () => provider.setMovieStatusIndex(0), - ), - const SizedBox(width: 16), - _buildStatusItem( - context, - '在看', - 1, - provider.movieStatusIndex, - () => provider.setMovieStatusIndex(1), - ), - const SizedBox(width: 16), - _buildStatusItem( - context, - '想看', - 2, - provider.movieStatusIndex, - () => provider.setMovieStatusIndex(2), - ), - ], + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + _buildStatusItem( + label: '已看', + icon: Icons.check_circle_outline, + isSelected: provider.movieStatusIndex == 0, + onTap: () => provider.setMovieStatusIndex(0), + ), + _buildStatusItem( + label: '在看', + icon: Icons.play_circle_outline, + isSelected: provider.movieStatusIndex == 1, + onTap: () => provider.setMovieStatusIndex(1), + ), + _buildStatusItem( + label: '想看', + icon: Icons.bookmark_outline, + isSelected: provider.movieStatusIndex == 2, + onTap: () => provider.setMovieStatusIndex(2), + ), + ], + ), ), ); }, @@ -51,48 +53,50 @@ class MovieStatusBar extends StatelessWidget { } /// 构建状态项 - Widget _buildStatusItem( - BuildContext context, - String label, - int index, - int currentIndex, - VoidCallback onTap, - ) { - final isSelected = index == currentIndex; - - Color color; - // 0:已看(深色), 1:在看(中灰), 2:想看(浅灰) - switch (index) { - case 0: - color = const Color(0xFF1A1A1A); - break; - case 1: - color = const Color(0xFF666666); - break; - case 2: - color = const Color(0xFF999999); - break; - default: - color = const Color(0xFFCCCCCC); - } - + Widget _buildStatusItem({ + required String label, + required IconData icon, + required bool isSelected, + required VoidCallback onTap, + }) { return Expanded( - child: InkWell( + child: GestureDetector( onTap: onTap, - child: Container( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( - color: isSelected ? color : Colors.transparent, - border: Border.all(color: color), + color: isSelected ? const Color(0xFF1A1A1A) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, - color: isSelected ? Colors.white : color, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 16, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + ), + ], ), ), ), diff --git a/pubspec.lock b/pubspec.lock index 952c9a3..fbe9a48 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,7 +241,7 @@ packages: source: hosted version: "1.0.1" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 832c934..6f7c85b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mooknote description: "A Flutter application for tracking movies, books, and notes" publish_to: 'none' -version: 1.0.0+1 +version: 0.1.5+1 environment: sdk: ^3.5.0 @@ -24,6 +24,7 @@ dependencies: archive: ^3.6.0 permission_handler: ^11.3.0 flutter_staggered_grid_view: ^0.7.0 + http: ^1.2.0 dev_dependencies: flutter_test: