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