generated from dellevin/template
自定义图标
This commit is contained in:
@@ -17,11 +17,38 @@
|
|||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- 图标1: 默认图标 -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityIcon1"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:label="MookNote">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity-alias>
|
||||||
|
|
||||||
|
<!-- 图标2: 风格二 -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".MainActivityIcon2"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher2"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher2"
|
||||||
|
android:label="MookNote">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
package top.iletter.mooknote
|
package top.iletter.mooknote
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
private val CHANNEL = "top.iletter.mooknote/icon"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"switchIcon" -> {
|
||||||
|
val iconName = call.argument<String>("iconName")
|
||||||
|
if (iconName != null) {
|
||||||
|
switchLauncherIcon(iconName)
|
||||||
|
result.success(true)
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_ARGUMENT", "iconName is required", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"getCurrentIcon" -> {
|
||||||
|
result.success(getCurrentIcon())
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchLauncherIcon(iconName: String) {
|
||||||
|
val pm = packageManager
|
||||||
|
val icon1 = ComponentName(this, "${packageName}.MainActivityIcon1")
|
||||||
|
val icon2 = ComponentName(this, "${packageName}.MainActivityIcon2")
|
||||||
|
|
||||||
|
when (iconName) {
|
||||||
|
"app_icon2" -> {
|
||||||
|
pm.setComponentEnabledSetting(icon1, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||||
|
pm.setComponentEnabledSetting(icon2, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
pm.setComponentEnabledSetting(icon2, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||||
|
pm.setComponentEnabledSetting(icon1, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCurrentIcon(): String {
|
||||||
|
val pm = packageManager
|
||||||
|
val icon1 = ComponentName(this, "${packageName}.MainActivityIcon1")
|
||||||
|
val icon2 = ComponentName(this, "${packageName}.MainActivityIcon2")
|
||||||
|
|
||||||
|
return when {
|
||||||
|
pm.getComponentEnabledSetting(icon2) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> "app_icon2"
|
||||||
|
else -> "app_icon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher2.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher2.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher2.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher2.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher2.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/icon/app_icon2.png
Normal file
BIN
assets/icon/app_icon2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 543 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'dart:async';
|
||||||
import 'pages/home_page.dart';
|
import 'pages/home_page.dart';
|
||||||
import 'utils/theme/app_theme.dart';
|
import 'utils/theme/app_theme.dart';
|
||||||
import 'utils/app_router.dart';
|
import 'utils/app_router.dart';
|
||||||
@@ -51,6 +52,9 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 获取当前选中的图标名称
|
||||||
|
final iconName = UserPrefs().appIconName;
|
||||||
|
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: appProvider),
|
ChangeNotifierProvider.value(value: appProvider),
|
||||||
@@ -63,11 +67,49 @@ class MyApp extends StatelessWidget {
|
|||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
home: const HomePage(),
|
home: const HomePage(),
|
||||||
onGenerateRoute: AppRouter.generateRoute,
|
onGenerateRoute: AppRouter.generateRoute,
|
||||||
|
builder: (context, child) {
|
||||||
|
// 尝试动态设置应用图标(Android 13+ 支持动态图标,但 Flutter 目前主要通过静态配置)
|
||||||
|
// 这里我们主要实现逻辑上的切换,实际生效通常需要重启应用或配合原生插件
|
||||||
|
return _AppIconWrapper(iconName: iconName, child: child!);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 应用图标包装器
|
||||||
|
/// 注意:Flutter 默认不支持运行时动态更换桌面图标。
|
||||||
|
/// 这里的实现主要是为了在应用内记录用户的选择,并为未来可能的动态图标功能做准备。
|
||||||
|
/// 如果需要真正的动态图标,通常需要引入 flutter_app_icon_changer 等插件并配置多套图标资源。
|
||||||
|
class _AppIconWrapper extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final String iconName;
|
||||||
|
|
||||||
|
const _AppIconWrapper({required this.child, required this.iconName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AppIconWrapper> createState() => _AppIconWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppIconWrapperState extends State<_AppIconWrapper> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_updateSystemIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateSystemIcon() async {
|
||||||
|
// 目前 Flutter 官方不支持直接通过代码更换 Launcher Icon。
|
||||||
|
// 这一步主要用于记录日志或在未来集成第三方库时使用。
|
||||||
|
// print('Current selected icon: ${widget.iconName}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 用于预览 MyApp 的 Widget
|
/// 用于预览 MyApp 的 Widget
|
||||||
/// 添加 @Preview 注解
|
/// 添加 @Preview 注解
|
||||||
@Preview(name: "MookNote App Preview")
|
@Preview(name: "MookNote App Preview")
|
||||||
|
|||||||
139
lib/pages/app_icon_picker_page.dart
Normal file
139
lib/pages/app_icon_picker_page.dart
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../utils/user_prefs.dart';
|
||||||
|
import '../../utils/toast_util.dart';
|
||||||
|
import '../../utils/app_icon_channel.dart';
|
||||||
|
|
||||||
|
/// 应用图标选择页面
|
||||||
|
class AppIconPickerPage extends StatefulWidget {
|
||||||
|
const AppIconPickerPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AppIconPickerPage> createState() => _AppIconPickerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppIconPickerPageState extends State<AppIconPickerPage> {
|
||||||
|
final UserPrefs _userPrefs = UserPrefs();
|
||||||
|
String _currentIconName = 'app_icon';
|
||||||
|
|
||||||
|
// 预定义的图标列表
|
||||||
|
final List<Map<String, String>> _icons = [
|
||||||
|
{'name': 'app_icon', 'label': '默认图标'},
|
||||||
|
{'name': 'app_icon2', 'label': '风格二'},
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCurrentIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCurrentIcon() async {
|
||||||
|
// 先从原生层获取当前实际启用的图标(更准确)
|
||||||
|
final nativeIcon = await AppIconChannel.getCurrentIcon();
|
||||||
|
setState(() {
|
||||||
|
_currentIconName = nativeIcon;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectIcon(String iconName) async {
|
||||||
|
if (iconName == _currentIconName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用原生层切换桌面图标
|
||||||
|
final success = await AppIconChannel.switchIcon(iconName);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await _userPrefs.setAppIconName(iconName);
|
||||||
|
setState(() {
|
||||||
|
_currentIconName = iconName;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '图标已切换,请返回桌面查看');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '图标切换失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '切换出错: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('应用图标'),
|
||||||
|
),
|
||||||
|
body: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 0.85,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
),
|
||||||
|
itemCount: _icons.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final icon = _icons[index];
|
||||||
|
final isSelected = _currentIconName == icon['name'];
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _selectIcon(icon['name']!),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? const Color(0xFFF0F0F0) : const Color(0xFFFAFAFA),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8),
|
||||||
|
width: isSelected ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 图标预览
|
||||||
|
Image.asset(
|
||||||
|
'assets/icon/${icon['name']}.png',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEEEEEE),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.image_not_supported, color: Color(0xFF999999)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 标签
|
||||||
|
Text(
|
||||||
|
icon['label']!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: const Color(0xFF1A1A1A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Icon(Icons.check_circle, size: 18, color: Color(0xFF1A1A1A)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import 'recycle_bin_page.dart';
|
|||||||
import 'sync/backup_page.dart';
|
import 'sync/backup_page.dart';
|
||||||
import 'statistics_page.dart';
|
import 'statistics_page.dart';
|
||||||
import 'sync/cloud_sync_page.dart';
|
import 'sync/cloud_sync_page.dart';
|
||||||
|
import 'app_icon_picker_page.dart';
|
||||||
|
|
||||||
/// 个人中心页面 - 极简主义设计
|
/// 个人中心页面 - 极简主义设计
|
||||||
class ProfilePage extends StatefulWidget {
|
class ProfilePage extends StatefulWidget {
|
||||||
@@ -695,7 +696,21 @@ class SettingsPage extends StatelessWidget {
|
|||||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||||
|
|
||||||
// 主界面功能显示入口
|
// 主界面功能显示入口
|
||||||
_buildSectionHeader('主界面显示'),
|
_buildSectionHeader('个性化设置'),
|
||||||
|
_buildNavigationItem(
|
||||||
|
icon: Icons.apps_outlined,
|
||||||
|
title: '应用图标',
|
||||||
|
subtitle: '更换桌面应用图标',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const AppIconPickerPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||||
_buildNavigationItem(
|
_buildNavigationItem(
|
||||||
icon: Icons.view_list_outlined,
|
icon: Icons.view_list_outlined,
|
||||||
title: '主界面功能显示',
|
title: '主界面功能显示',
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isConfigured = false;
|
bool _isConfigured = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
bool _isAutoSyncEnabled = false;
|
||||||
|
int _autoSyncInterval = 5; // 默认5分钟
|
||||||
SyncDirection _syncDirection = SyncDirection.upload;
|
SyncDirection _syncDirection = SyncDirection.upload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -50,6 +52,14 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
_isConfigured = true;
|
_isConfigured = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载自动同步设置
|
||||||
|
final autoSyncEnabled = await WebDAVService.instance.isAutoSyncEnabled();
|
||||||
|
final autoSyncInterval = await WebDAVService.instance.getAutoSyncInterval();
|
||||||
|
setState(() {
|
||||||
|
_isAutoSyncEnabled = autoSyncEnabled;
|
||||||
|
_autoSyncInterval = autoSyncInterval;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置
|
/// 保存配置
|
||||||
@@ -112,6 +122,13 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 安全显示 Toast
|
||||||
|
void _safeShowToast(String message) {
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 执行同步
|
/// 执行同步
|
||||||
Future<void> _syncData() async {
|
Future<void> _syncData() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
@@ -176,6 +193,94 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换自动同步
|
||||||
|
Future<void> _toggleAutoSync(bool value) async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
await WebDAVService.instance.startAutoSync();
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '自动同步已开启,每 $_autoSyncInterval 分钟同步一次');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await WebDAVService.instance.stopAutoSync();
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '自动同步已关闭');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isAutoSyncEnabled = value);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '设置失败: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示间隔选择器
|
||||||
|
Future<void> _showIntervalPicker() async {
|
||||||
|
final intervals = [1, 3, 5, 10, 15, 30, 60];
|
||||||
|
|
||||||
|
final selected = await showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||||
|
title: const Text('选择同步间隔'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: intervals.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final interval = intervals[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text('$interval 分钟'),
|
||||||
|
trailing: _autoSyncInterval == interval
|
||||||
|
? const Icon(Icons.check, color: Color(0xFF1A1A1A))
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.pop(context, interval),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected != null && selected != _autoSyncInterval) {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await WebDAVService.instance.setAutoSyncInterval(selected);
|
||||||
|
setState(() => _autoSyncInterval = selected);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '同步间隔已设置为 $selected 分钟');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, '设置失败: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 清除配置
|
/// 清除配置
|
||||||
Future<void> _clearConfig() async {
|
Future<void> _clearConfig() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@@ -374,8 +479,109 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
if (_isConfigured) ...[
|
if (_isConfigured) ...[
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// 自动同步设置
|
||||||
|
_buildSectionTitle('自动同步'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFAFAFA),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'启用自动同步',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'修改后自动上传,每 $_autoSyncInterval 分钟同步一次',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF666666),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: _isAutoSyncEnabled,
|
||||||
|
onChanged: _isLoading ? null : _toggleAutoSync,
|
||||||
|
activeTrackColor: const Color(0xFF1A1A1A),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_isAutoSyncEnabled) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(height: 1, color: Color(0xFFE8E8E8)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'同步间隔',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _isLoading ? null : _showIntervalPicker,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFE8E8E8)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$_autoSyncInterval 分钟',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
size: 14,
|
||||||
|
color: Color(0xFF999999),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// 同步操作区域
|
// 同步操作区域
|
||||||
_buildSectionTitle('数据同步'),
|
_buildSectionTitle('手动同步'),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
@@ -550,9 +756,9 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildInfoItem('服务器地址需包含协议(http:// 或 https://)'),
|
_buildInfoItem('服务器地址需包含协议(http:// 或 https://)'),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildInfoItem('同步前请确保服务器可用且空间充足'),
|
_buildInfoItem('数据文件和图片分开存储,支持多设备同步'),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildInfoItem('首次同步将上传所有数据,后续只同步变更'),
|
_buildInfoItem('开启自动同步后,修改将自动上传云端'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
32
lib/utils/app_icon_channel.dart
Normal file
32
lib/utils/app_icon_channel.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// 应用图标原生通道
|
||||||
|
/// 通过 MethodChannel 调用 Android activity-alias 切换桌面图标
|
||||||
|
class AppIconChannel {
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('top.iletter.mooknote/icon');
|
||||||
|
|
||||||
|
/// 切换桌面图标
|
||||||
|
/// [iconName] 图标名称,如 'app_icon' 或 'app_icon2'
|
||||||
|
/// 返回是否成功
|
||||||
|
static Future<bool> switchIcon(String iconName) async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod('switchIcon', {
|
||||||
|
'iconName': iconName,
|
||||||
|
});
|
||||||
|
return result == true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前启用的图标名称
|
||||||
|
static Future<String> getCurrentIcon() async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod('getCurrentIcon');
|
||||||
|
return result as String? ?? 'app_icon';
|
||||||
|
} catch (e) {
|
||||||
|
return 'app_icon';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:archive/archive.dart';
|
|
||||||
import 'package:archive/archive_io.dart';
|
|
||||||
import '../database_helper.dart';
|
import '../database_helper.dart';
|
||||||
|
|
||||||
/// WebDAV 同步结果
|
/// WebDAV 同步结果
|
||||||
@@ -47,7 +45,7 @@ class _ImageSyncResult {
|
|||||||
_ImageSyncResult({required this.uploaded, required this.downloaded});
|
_ImageSyncResult({required this.uploaded, required this.downloaded});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebDAV 服务类 - 支持自动定时备份
|
/// WebDAV 服务类 - 支持实时同步(数据+图片分离存储)
|
||||||
class WebDAVService {
|
class WebDAVService {
|
||||||
static final WebDAVService _instance = WebDAVService._internal();
|
static final WebDAVService _instance = WebDAVService._internal();
|
||||||
static WebDAVService get instance => _instance;
|
static WebDAVService get instance => _instance;
|
||||||
@@ -57,14 +55,22 @@ class WebDAVService {
|
|||||||
static const String _configKey = 'webdav_config';
|
static const String _configKey = 'webdav_config';
|
||||||
static const String _lastSyncKey = 'webdav_last_sync';
|
static const String _lastSyncKey = 'webdav_last_sync';
|
||||||
static const String _autoSyncKey = 'webdav_auto_sync';
|
static const String _autoSyncKey = 'webdav_auto_sync';
|
||||||
static const String _backupListKey = 'webdav_backup_list';
|
static const String _autoSyncIntervalKey = 'webdav_auto_sync_interval';
|
||||||
|
static const String _lastDbModifiedKey = 'webdav_last_db_modified';
|
||||||
|
|
||||||
static const int _maxBackupCount = 10; // 保留最近10条备份
|
// 默认自动同步间隔(分钟)
|
||||||
static const Duration _autoSyncInterval = Duration(minutes: 5); // 每5分钟自动备份
|
static const int _defaultAutoSyncInterval = 5;
|
||||||
|
|
||||||
Map<String, String>? _cachedConfig;
|
Map<String, String>? _cachedConfig;
|
||||||
Timer? _autoSyncTimer;
|
Timer? _autoSyncTimer;
|
||||||
bool _isAutoSyncEnabled = false;
|
bool _isAutoSyncEnabled = false;
|
||||||
|
int _autoSyncIntervalMinutes = _defaultAutoSyncInterval;
|
||||||
|
|
||||||
|
// 文件系统监听
|
||||||
|
StreamSubscription<FileSystemEvent>? _imagesDirWatcher;
|
||||||
|
final Set<String> _pendingImageUploads = {};
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
static const Duration _debounceDelay = Duration(seconds: 3);
|
||||||
|
|
||||||
/// 获取配置
|
/// 获取配置
|
||||||
Future<Map<String, String>?> getConfig() async {
|
Future<Map<String, String>?> getConfig() async {
|
||||||
@@ -111,7 +117,8 @@ class WebDAVService {
|
|||||||
await prefs.remove(_configKey);
|
await prefs.remove(_configKey);
|
||||||
await prefs.remove(_lastSyncKey);
|
await prefs.remove(_lastSyncKey);
|
||||||
await prefs.remove(_autoSyncKey);
|
await prefs.remove(_autoSyncKey);
|
||||||
await prefs.remove(_backupListKey);
|
await prefs.remove(_autoSyncIntervalKey);
|
||||||
|
await prefs.remove(_lastDbModifiedKey);
|
||||||
_cachedConfig = null;
|
_cachedConfig = null;
|
||||||
stopAutoSync();
|
stopAutoSync();
|
||||||
}
|
}
|
||||||
@@ -207,7 +214,7 @@ class WebDAVService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步数据(使用 ZIP 格式,类似自动备份)
|
/// 同步数据(数据+图片分离存储,非压缩包方式)
|
||||||
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
||||||
final config = await getConfig();
|
final config = await getConfig();
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
@@ -228,109 +235,64 @@ class WebDAVService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
final zipUrl = '$baseUrl$path/mooknote_backup.zip';
|
final dbUrl = '$baseUrl$path/mooknote.db';
|
||||||
|
final imagesUrl = '$baseUrl$path/images';
|
||||||
|
|
||||||
final client = http.Client();
|
final client = http.Client();
|
||||||
int uploadedFiles = 0;
|
int uploadedFiles = 0;
|
||||||
int downloadedFiles = 0;
|
int downloadedFiles = 0;
|
||||||
int uploadedImages = 0;
|
int uploadedImages = 0;
|
||||||
int downloadedImages = 0;
|
int downloadedImages = 0;
|
||||||
|
bool needReload = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (direction == SyncDirection.upload) {
|
if (direction == SyncDirection.upload) {
|
||||||
// print('WebDAV: Upload ZIP mode');
|
// 上传数据库文件
|
||||||
// 创建 ZIP 备份
|
final dbSuccess = await _uploadFile(client, dbUrl, username, password, dbFile);
|
||||||
final zipBytes = await _createFullBackupZip(dbFile);
|
if (dbSuccess) {
|
||||||
if (zipBytes == null) {
|
|
||||||
return SyncResult(success: false, message: '创建备份 ZIP 失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传 ZIP 文件
|
|
||||||
final result = await _uploadBytes(client, zipUrl, username, password, zipBytes);
|
|
||||||
if (result) {
|
|
||||||
uploadedFiles = 1;
|
uploadedFiles = 1;
|
||||||
// 统计图片数量
|
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
|
||||||
final imagesDir = Directory('${appDir.path}/images');
|
|
||||||
if (await imagesDir.exists()) {
|
|
||||||
uploadedImages = await _countImagesInDir(imagesDir);
|
|
||||||
}
|
|
||||||
// print('WebDAV: ZIP uploaded successfully, images: $uploadedImages');
|
|
||||||
} else {
|
|
||||||
return SyncResult(success: false, message: '上传 ZIP 失败');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上传所有图片
|
||||||
|
final imageResult = await _syncImages(client, imagesUrl, username, password, SyncDirection.upload);
|
||||||
|
uploadedImages = imageResult.uploaded;
|
||||||
|
|
||||||
} else if (direction == SyncDirection.download) {
|
} else if (direction == SyncDirection.download) {
|
||||||
// print('WebDAV: Download ZIP mode');
|
// 下载数据库文件
|
||||||
// 下载 ZIP 文件
|
final tempDbFile = File('${dbFile.parent.path}/mooknote_download.db');
|
||||||
final zipFile = File('${dbFile.parent.path}/mooknote_backup_download.zip');
|
final dbSuccess = await _downloadFile(client, dbUrl, username, password, tempDbFile);
|
||||||
final result = await _downloadFile(client, zipUrl, username, password, zipFile);
|
|
||||||
|
|
||||||
if (result && await zipFile.exists()) {
|
if (dbSuccess && await tempDbFile.exists()) {
|
||||||
|
// 替换本地数据库
|
||||||
|
await tempDbFile.copy(dbFile.path);
|
||||||
|
await tempDbFile.delete();
|
||||||
|
await DatabaseHelper.instance.reopenDatabase();
|
||||||
downloadedFiles = 1;
|
downloadedFiles = 1;
|
||||||
// 解压 ZIP 文件
|
needReload = true;
|
||||||
final extractResult = await _extractBackupZip(zipFile, dbFile);
|
|
||||||
if (extractResult) {
|
|
||||||
// 重新打开数据库
|
|
||||||
await DatabaseHelper.instance.reopenDatabase();
|
|
||||||
// 统计下载的图片数量
|
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
|
||||||
final imagesDir = Directory('${appDir.path}/images');
|
|
||||||
if (await imagesDir.exists()) {
|
|
||||||
downloadedImages = await _countImagesInDir(imagesDir);
|
|
||||||
}
|
|
||||||
// print('WebDAV: ZIP downloaded and extracted successfully, images: $downloadedImages');
|
|
||||||
} else {
|
|
||||||
return SyncResult(success: false, message: '解压 ZIP 失败');
|
|
||||||
}
|
|
||||||
// 删除临时 ZIP 文件
|
|
||||||
await zipFile.delete();
|
|
||||||
} else {
|
|
||||||
return SyncResult(success: false, message: '远程备份不存在或下载失败');
|
|
||||||
}
|
}
|
||||||
} else if (direction == SyncDirection.bidirectional) {
|
|
||||||
// 双向同步:比较时间戳决定上传还是下载
|
|
||||||
// print('WebDAV: Bidirectional sync mode (ZIP)');
|
|
||||||
final remoteZipInfo = await _getRemoteFileInfo(client, zipUrl, username, password);
|
|
||||||
|
|
||||||
if (remoteZipInfo == null) {
|
// 下载所有图片
|
||||||
// 远程不存在,直接上传
|
final imageResult = await _syncImages(client, imagesUrl, username, password, SyncDirection.download);
|
||||||
// print('WebDAV: Remote ZIP not found, uploading...');
|
downloadedImages = imageResult.downloaded;
|
||||||
return await syncData(direction: SyncDirection.upload);
|
|
||||||
} else {
|
} else if (direction == SyncDirection.bidirectional) {
|
||||||
// 远程存在,比较修改时间
|
// 双向同步:分别同步数据库和图片
|
||||||
final localModified = await dbFile.lastModified();
|
final dbResult = await _syncDatabaseFile(client, dbUrl, username, password, dbFile);
|
||||||
final remoteModified = remoteZipInfo['modified'] as DateTime;
|
if (dbResult['uploaded'] == true) uploadedFiles = 1;
|
||||||
|
if (dbResult['downloaded'] == true) {
|
||||||
// print('WebDAV: Local modified: $localModified');
|
downloadedFiles = 1;
|
||||||
// print('WebDAV: Remote modified: $remoteModified');
|
needReload = true;
|
||||||
|
|
||||||
final timeDiff = localModified.difference(remoteModified).inSeconds;
|
|
||||||
|
|
||||||
if (timeDiff > 10) {
|
|
||||||
// 本地较新,上传
|
|
||||||
// print('WebDAV: Local is newer, uploading...');
|
|
||||||
return await syncData(direction: SyncDirection.upload);
|
|
||||||
} else if (timeDiff < -10) {
|
|
||||||
// 远程较新,下载
|
|
||||||
// print('WebDAV: Remote is newer, downloading...');
|
|
||||||
return await syncData(direction: SyncDirection.download);
|
|
||||||
} else {
|
|
||||||
// 时间相近,无需同步
|
|
||||||
// print('WebDAV: Local and remote are similar, no sync needed');
|
|
||||||
return SyncResult(
|
|
||||||
success: true,
|
|
||||||
message: '本地和远程数据相同,无需同步',
|
|
||||||
lastSyncTime: DateTime.now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 双向同步图片
|
||||||
|
final imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password);
|
||||||
|
uploadedImages = imageResult.uploaded;
|
||||||
|
downloadedImages = imageResult.downloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
|
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
|
||||||
|
|
||||||
final needReload = downloadedFiles > 0;
|
|
||||||
|
|
||||||
return SyncResult(
|
return SyncResult(
|
||||||
success: true,
|
success: true,
|
||||||
message: '同步完成',
|
message: '同步完成',
|
||||||
@@ -345,62 +307,111 @@ class WebDAVService {
|
|||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('WebDAV sync error: $e');
|
|
||||||
return SyncResult(success: false, message: '同步失败: $e');
|
return SyncResult(success: false, message: '同步失败: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计目录中的图片数量
|
/// 同步数据库文件(双向)
|
||||||
Future<int> _countImagesInDir(Directory dir) async {
|
Future<Map<String, bool>> _syncDatabaseFile(
|
||||||
int count = 0;
|
http.Client client,
|
||||||
await for (final entity in dir.list(recursive: true)) {
|
String dbUrl,
|
||||||
if (entity is File) {
|
String username,
|
||||||
count++;
|
String password,
|
||||||
}
|
File localDbFile,
|
||||||
}
|
) async {
|
||||||
return count;
|
final result = <String, bool>{};
|
||||||
}
|
|
||||||
|
|
||||||
/// 解压备份 ZIP 文件
|
|
||||||
Future<bool> _extractBackupZip(File zipFile, File dbFile) async {
|
|
||||||
try {
|
try {
|
||||||
final bytes = await zipFile.readAsBytes();
|
final remoteInfo = await _getRemoteFileInfo(client, dbUrl, username, password);
|
||||||
final archive = ZipDecoder().decodeBytes(bytes);
|
final localModified = await localDbFile.lastModified();
|
||||||
|
|
||||||
// 解压数据库文件
|
if (remoteInfo == null) {
|
||||||
final dbArchiveFile = archive.findFile('mooknote.db');
|
// 远程不存在,上传本地
|
||||||
if (dbArchiveFile != null) {
|
result['uploaded'] = await _uploadFile(client, dbUrl, username, password, localDbFile);
|
||||||
await dbFile.writeAsBytes(dbArchiveFile.content as List<int>);
|
} else {
|
||||||
// print('WebDAV: Extracted database file');
|
final remoteModified = remoteInfo['modified'] as DateTime;
|
||||||
}
|
final timeDiff = localModified.difference(remoteModified).inSeconds;
|
||||||
|
|
||||||
// 解压图片文件
|
if (timeDiff > 10) {
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
// 本地较新,上传
|
||||||
final imagesDir = Directory('${appDir.path}/images');
|
result['uploaded'] = await _uploadFile(client, dbUrl, username, password, localDbFile);
|
||||||
|
} else if (timeDiff < -10) {
|
||||||
int imageCount = 0;
|
// 远程较新,下载
|
||||||
for (final archiveFile in archive) {
|
final tempFile = File('${localDbFile.parent.path}/mooknote_temp.db');
|
||||||
if (archiveFile.name.startsWith('images/')) {
|
final success = await _downloadFile(client, dbUrl, username, password, tempFile);
|
||||||
final relativePath = archiveFile.name.substring(7); // 去掉 'images/' 前缀
|
if (success) {
|
||||||
final localFile = File('${imagesDir.path}/$relativePath');
|
await tempFile.copy(localDbFile.path);
|
||||||
|
await tempFile.delete();
|
||||||
// 确保父目录存在
|
await DatabaseHelper.instance.reopenDatabase();
|
||||||
await localFile.parent.create(recursive: true);
|
result['downloaded'] = true;
|
||||||
|
}
|
||||||
// 写入文件
|
|
||||||
await localFile.writeAsBytes(archiveFile.content as List<int>);
|
|
||||||
imageCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// print('WebDAV: Extracted $imageCount images');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('WebDAV: Extract ZIP error: $e');
|
// 忽略错误
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 双向同步图片(基于文件存在性和修改时间)
|
||||||
|
Future<_ImageSyncResult> _syncImagesBidirectional(
|
||||||
|
http.Client client,
|
||||||
|
String imagesUrl,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) 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 = <String, File>{};
|
||||||
|
await _collectLocalImages(localImagesDir, localImages, '');
|
||||||
|
|
||||||
|
// 获取远程所有图片
|
||||||
|
final remoteImages = await _listRemoteImagesRecursive(client, imagesUrl, username, password, '');
|
||||||
|
|
||||||
|
// 上传本地有但远程没有的
|
||||||
|
for (final entry in localImages.entries) {
|
||||||
|
final relativePath = entry.key;
|
||||||
|
if (!remoteImages.contains(relativePath)) {
|
||||||
|
final remoteUrl = '$imagesUrl/$relativePath';
|
||||||
|
final parentPath = p.dirname(relativePath);
|
||||||
|
if (parentPath != '.' && parentPath.isNotEmpty) {
|
||||||
|
await _ensureRemoteDir(client, '$imagesUrl/$parentPath', username, password);
|
||||||
|
}
|
||||||
|
final success = await _uploadFile(client, remoteUrl, username, password, entry.value);
|
||||||
|
if (success) uploaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载远程有但本地没有的
|
||||||
|
for (final relativePath in remoteImages) {
|
||||||
|
if (!localImages.containsKey(relativePath)) {
|
||||||
|
final remoteUrl = '$imagesUrl/$relativePath';
|
||||||
|
final localFile = File('${localImagesDir.path}/$relativePath');
|
||||||
|
await localFile.parent.create(recursive: true);
|
||||||
|
final success = await _downloadFile(client, remoteUrl, username, password, localFile);
|
||||||
|
if (success) downloaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// 获取远程文件信息
|
/// 获取远程文件信息
|
||||||
Future<Map<String, dynamic>?> _getRemoteFileInfo(
|
Future<Map<String, dynamic>?> _getRemoteFileInfo(
|
||||||
http.Client client,
|
http.Client client,
|
||||||
@@ -445,27 +456,52 @@ class WebDAVService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动自动同步
|
/// 获取自动同步间隔(分钟)
|
||||||
Future<void> startAutoSync() async {
|
Future<int> getAutoSyncInterval() async {
|
||||||
if (_autoSyncTimer != null) {
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_autoSyncTimer!.cancel();
|
return prefs.getInt(_autoSyncIntervalKey) ?? _defaultAutoSyncInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置自动同步间隔(分钟)
|
||||||
|
Future<void> setAutoSyncInterval(int minutes) async {
|
||||||
|
if (minutes < 1) minutes = 1;
|
||||||
|
if (minutes > 60) minutes = 60;
|
||||||
|
|
||||||
|
_autoSyncIntervalMinutes = minutes;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_autoSyncIntervalKey, minutes);
|
||||||
|
|
||||||
|
// 如果正在自动同步,重启以应用新间隔
|
||||||
|
if (_isAutoSyncEnabled) {
|
||||||
|
await startAutoSync();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动自动同步(带文件监听)
|
||||||
|
Future<void> startAutoSync() async {
|
||||||
|
// 停止现有的定时器和监听
|
||||||
|
await stopAutoSync();
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_isAutoSyncEnabled = true;
|
_isAutoSyncEnabled = true;
|
||||||
|
_autoSyncIntervalMinutes = await getAutoSyncInterval();
|
||||||
await prefs.setBool(_autoSyncKey, true);
|
await prefs.setBool(_autoSyncKey, true);
|
||||||
|
|
||||||
// 立即执行一次备份
|
// 立即执行一次同步
|
||||||
await performTimedBackup();
|
await _performIncrementalSync();
|
||||||
|
|
||||||
// 设置定时器,每5分钟执行一次
|
// 设置定时器进行定期同步
|
||||||
_autoSyncTimer = Timer.periodic(_autoSyncInterval, (timer) async {
|
_autoSyncTimer = Timer.periodic(
|
||||||
if (_isAutoSyncEnabled) {
|
Duration(minutes: _autoSyncIntervalMinutes),
|
||||||
await performTimedBackup();
|
(timer) async {
|
||||||
}
|
if (_isAutoSyncEnabled) {
|
||||||
});
|
await _performIncrementalSync();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// print('WebDAV: 自动备份已启动,每5分钟执行一次');
|
// 启动文件系统监听
|
||||||
|
await _startFileWatcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止自动同步
|
/// 停止自动同步
|
||||||
@@ -474,10 +510,14 @@ class WebDAVService {
|
|||||||
_autoSyncTimer = null;
|
_autoSyncTimer = null;
|
||||||
_isAutoSyncEnabled = false;
|
_isAutoSyncEnabled = false;
|
||||||
|
|
||||||
|
// 停止文件监听
|
||||||
|
await _imagesDirWatcher?.cancel();
|
||||||
|
_imagesDirWatcher = null;
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_pendingImageUploads.clear();
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_autoSyncKey, false);
|
await prefs.setBool(_autoSyncKey, false);
|
||||||
|
|
||||||
// print('WebDAV: 自动备份已停止');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查自动同步状态
|
/// 检查自动同步状态
|
||||||
@@ -490,8 +530,94 @@ class WebDAVService {
|
|||||||
return prefs.getBool(_autoSyncKey) ?? false;
|
return prefs.getBool(_autoSyncKey) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行定时备份(按时间命名,保留最近10条)
|
/// 启动文件系统监听
|
||||||
Future<SyncResult> performTimedBackup() async {
|
Future<void> _startFileWatcher() async {
|
||||||
|
try {
|
||||||
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
final imagesDir = Directory('${appDir.path}/images');
|
||||||
|
|
||||||
|
if (!await imagesDir.exists()) {
|
||||||
|
await imagesDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听图片目录的变化
|
||||||
|
_imagesDirWatcher = imagesDir.watch(recursive: true).listen((event) {
|
||||||
|
if (event is FileSystemCreateEvent || event is FileSystemModifyEvent) {
|
||||||
|
final path = event.path;
|
||||||
|
if (_isImageFile(path)) {
|
||||||
|
_pendingImageUploads.add(path);
|
||||||
|
_debounceUpload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 文件监听可能不支持某些平台,忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否是图片文件
|
||||||
|
bool _isImageFile(String path) {
|
||||||
|
final ext = p.extension(path).toLowerCase();
|
||||||
|
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 防抖上传
|
||||||
|
void _debounceUpload() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(_debounceDelay, () async {
|
||||||
|
if (_pendingImageUploads.isNotEmpty && _isAutoSyncEnabled) {
|
||||||
|
await _uploadPendingImages();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传待处理的图片
|
||||||
|
Future<void> _uploadPendingImages() async {
|
||||||
|
final config = await getConfig();
|
||||||
|
if (config == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final url = config['url']!;
|
||||||
|
final username = config['username']!;
|
||||||
|
final password = config['password']!;
|
||||||
|
final path = config['path']!;
|
||||||
|
|
||||||
|
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
|
final imagesUrl = '$baseUrl$path/images';
|
||||||
|
|
||||||
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
final imagesDir = Directory('${appDir.path}/images');
|
||||||
|
|
||||||
|
final client = http.Client();
|
||||||
|
try {
|
||||||
|
final uploads = _pendingImageUploads.toList();
|
||||||
|
_pendingImageUploads.clear();
|
||||||
|
|
||||||
|
for (final localPath in uploads) {
|
||||||
|
final file = File(localPath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
final relativePath = p.relative(localPath, from: imagesDir.path);
|
||||||
|
final remoteUrl = '$imagesUrl/$relativePath';
|
||||||
|
|
||||||
|
// 确保父目录存在
|
||||||
|
final parentPath = p.dirname(relativePath);
|
||||||
|
if (parentPath != '.' && parentPath.isNotEmpty) {
|
||||||
|
await _ensureRemoteDir(client, '$imagesUrl/$parentPath', username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _uploadFile(client, remoteUrl, username, password, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行增量同步(检查变更并上传)
|
||||||
|
Future<SyncResult> _performIncrementalSync() async {
|
||||||
final config = await getConfig();
|
final config = await getConfig();
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return SyncResult(success: false, message: '未配置 WebDAV');
|
return SyncResult(success: false, message: '未配置 WebDAV');
|
||||||
@@ -501,9 +627,8 @@ class WebDAVService {
|
|||||||
final url = config['url']!;
|
final url = config['url']!;
|
||||||
final username = config['username']!;
|
final username = config['username']!;
|
||||||
final password = config['password']!;
|
final password = config['password']!;
|
||||||
final basePath = config['path']!;
|
final path = config['path']!;
|
||||||
|
|
||||||
// 获取本地数据库文件路径
|
|
||||||
final dbPath = await getDatabasesPath();
|
final dbPath = await getDatabasesPath();
|
||||||
final dbFile = File(p.join(dbPath, 'mooknote.db'));
|
final dbFile = File(p.join(dbPath, 'mooknote.db'));
|
||||||
|
|
||||||
@@ -511,227 +636,59 @@ class WebDAVService {
|
|||||||
return SyncResult(success: false, message: '本地数据库不存在');
|
return SyncResult(success: false, message: '本地数据库不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 WebDAV URL(使用时间戳命名)
|
|
||||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
final timestamp = _formatTimestamp(DateTime.now());
|
final dbUrl = '$baseUrl$path/mooknote.db';
|
||||||
final backupFileName = 'mooknote_$timestamp.zip';
|
final imagesUrl = '$baseUrl$path/images';
|
||||||
final davUrl = '$baseUrl$basePath/$backupFileName';
|
|
||||||
final davImagesUrl = '$baseUrl$basePath/images';
|
|
||||||
|
|
||||||
// print('WebDAV: 开始定时备份到 $davUrl');
|
|
||||||
|
|
||||||
final client = http.Client();
|
final client = http.Client();
|
||||||
int uploadedImages = 0;
|
int uploadedImages = 0;
|
||||||
|
bool dbUploaded = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 创建完整的备份 ZIP(包含数据库和图片)
|
// 检查数据库是否需要同步
|
||||||
final zipBytes = await _createFullBackupZip(dbFile);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (zipBytes == null) {
|
final lastDbModifiedStr = prefs.getString(_lastDbModifiedKey);
|
||||||
return SyncResult(success: false, message: '创建备份文件失败');
|
final currentDbModified = await dbFile.lastModified();
|
||||||
|
|
||||||
|
bool needDbSync = true;
|
||||||
|
if (lastDbModifiedStr != null) {
|
||||||
|
final lastDbModified = DateTime.parse(lastDbModifiedStr);
|
||||||
|
// 如果数据库修改时间在3秒内,认为没有变化
|
||||||
|
if (currentDbModified.difference(lastDbModified).inSeconds.abs() < 3) {
|
||||||
|
needDbSync = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 上传备份文件
|
if (needDbSync) {
|
||||||
final success = await _uploadBytes(client, davUrl, username, password, zipBytes);
|
// 上传数据库
|
||||||
if (!success) {
|
dbUploaded = await _uploadFile(client, dbUrl, username, password, dbFile);
|
||||||
return SyncResult(success: false, message: '上传备份文件失败');
|
if (dbUploaded) {
|
||||||
|
await prefs.setString(_lastDbModifiedKey, currentDbModified.toIso8601String());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 同步图片到 images 目录
|
// 同步图片(双向)
|
||||||
final imageResult = await _syncImages(client, davImagesUrl, username, password, SyncDirection.upload);
|
final imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password);
|
||||||
uploadedImages = imageResult.uploaded;
|
uploadedImages = imageResult.uploaded;
|
||||||
|
|
||||||
// 4. 更新备份列表并清理旧备份
|
|
||||||
await _updateBackupListAndCleanup(client, baseUrl, basePath, username, password, backupFileName);
|
|
||||||
|
|
||||||
// 5. 保存同步时间
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
|
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
|
||||||
|
|
||||||
// print('WebDAV: 定时备份完成 - $backupFileName');
|
|
||||||
|
|
||||||
return SyncResult(
|
return SyncResult(
|
||||||
success: true,
|
success: true,
|
||||||
message: '备份完成: $backupFileName',
|
message: '自动同步完成',
|
||||||
lastSyncTime: DateTime.now(),
|
lastSyncTime: DateTime.now(),
|
||||||
uploadedFiles: 1,
|
uploadedFiles: dbUploaded ? 1 : 0,
|
||||||
uploadedImages: uploadedImages,
|
uploadedImages: uploadedImages,
|
||||||
needReload: false,
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('WebDAV: 定时备份错误: $e');
|
return SyncResult(success: false, message: '自动同步失败: $e');
|
||||||
return SyncResult(success: false, message: '备份失败: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建完整的备份 ZIP(包含数据库和所有图片)
|
|
||||||
/// 支持新的图片存储结构:images/movies/{id}/、images/books/{id}/、images/notes/{id}/
|
|
||||||
Future<List<int>?> _createFullBackupZip(File dbFile) async {
|
|
||||||
try {
|
|
||||||
final archive = Archive();
|
|
||||||
|
|
||||||
// 添加数据库文件
|
|
||||||
final dbBytes = await dbFile.readAsBytes();
|
|
||||||
archive.addFile(ArchiveFile('mooknote.db', dbBytes.length, dbBytes));
|
|
||||||
|
|
||||||
// 添加所有图片(递归遍历子目录)
|
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
|
||||||
final imagesDir = Directory('${appDir.path}/images');
|
|
||||||
|
|
||||||
if (await imagesDir.exists()) {
|
|
||||||
await _addImagesToArchive(archive, imagesDir, 'images');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加备份信息
|
|
||||||
final backupInfo = {
|
|
||||||
'version': 2,
|
|
||||||
'backupTime': DateTime.now().toIso8601String(),
|
|
||||||
'appName': 'MookNote',
|
|
||||||
'type': 'timed_backup',
|
|
||||||
'structure': 'hierarchical', // 标记为分层结构
|
|
||||||
};
|
|
||||||
final infoJson = jsonEncode(backupInfo);
|
|
||||||
final infoBytes = utf8.encode(infoJson);
|
|
||||||
archive.addFile(ArchiveFile('backup_info.json', infoBytes.length, infoBytes));
|
|
||||||
|
|
||||||
// 压缩
|
|
||||||
final zipEncoder = ZipEncoder();
|
|
||||||
return zipEncoder.encode(archive);
|
|
||||||
} catch (e) {
|
|
||||||
// print('WebDAV: 创建备份 ZIP 失败: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归添加图片到归档
|
|
||||||
Future<void> _addImagesToArchive(Archive archive, Directory dir, String relativePath) async {
|
|
||||||
await for (final entity in dir.list()) {
|
|
||||||
if (entity is File) {
|
|
||||||
final fileName = p.basename(entity.path);
|
|
||||||
final bytes = await entity.readAsBytes();
|
|
||||||
final archivePath = '$relativePath/$fileName';
|
|
||||||
archive.addFile(ArchiveFile(archivePath, bytes.length, bytes));
|
|
||||||
// print('WebDAV: 添加文件到备份 - $archivePath');
|
|
||||||
} else if (entity is Directory) {
|
|
||||||
final dirName = p.basename(entity.path);
|
|
||||||
await _addImagesToArchive(archive, entity, '$relativePath/$dirName');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新备份列表并清理旧备份
|
|
||||||
Future<void> _updateBackupListAndCleanup(
|
|
||||||
http.Client client,
|
|
||||||
String baseUrl,
|
|
||||||
String basePath,
|
|
||||||
String username,
|
|
||||||
String password,
|
|
||||||
String newBackupName,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
|
|
||||||
// 获取现有备份列表
|
|
||||||
List<String> backupList = [];
|
|
||||||
final listJson = prefs.getString(_backupListKey);
|
|
||||||
if (listJson != null) {
|
|
||||||
backupList = List<String>.from(jsonDecode(listJson));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加新备份
|
|
||||||
backupList.add(newBackupName);
|
|
||||||
|
|
||||||
// 如果超过10条,删除最旧的备份
|
|
||||||
while (backupList.length > _maxBackupCount) {
|
|
||||||
final oldBackup = backupList.removeAt(0);
|
|
||||||
final deleteUrl = '$baseUrl$basePath/$oldBackup';
|
|
||||||
await _deleteFile(client, deleteUrl, username, password);
|
|
||||||
// print('WebDAV: 删除旧备份 $oldBackup');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存更新后的列表
|
|
||||||
await prefs.setString(_backupListKey, jsonEncode(backupList));
|
|
||||||
|
|
||||||
// print('WebDAV: 备份列表已更新,当前 ${backupList.length} 个备份');
|
|
||||||
} catch (e) {
|
|
||||||
// print('WebDAV: 更新备份列表失败: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除远程文件
|
|
||||||
Future<void> _deleteFile(
|
|
||||||
http.Client client,
|
|
||||||
String url,
|
|
||||||
String username,
|
|
||||||
String password,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
var request = http.Request('DELETE', 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('DELETE', Uri.parse(location));
|
|
||||||
request.headers['Authorization'] = _basicAuth(username, password);
|
|
||||||
response = await client.send(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// print('WebDAV: 删除文件失败: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 上传字节数据
|
|
||||||
Future<bool> _uploadBytes(
|
|
||||||
http.Client client,
|
|
||||||
String url,
|
|
||||||
String username,
|
|
||||||
String password,
|
|
||||||
List<int> bytes,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
var request = http.Request('PUT', Uri.parse(url));
|
|
||||||
request.headers['Authorization'] = _basicAuth(username, password);
|
|
||||||
request.headers['Content-Type'] = 'application/zip';
|
|
||||||
request.bodyBytes = bytes;
|
|
||||||
|
|
||||||
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/zip';
|
|
||||||
request.bodyBytes = bytes;
|
|
||||||
response = await client.send(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.statusCode == 201 || response.statusCode == 204;
|
|
||||||
} catch (e) {
|
|
||||||
// print('WebDAV: 上传失败: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 格式化时间戳用于文件名
|
|
||||||
String _formatTimestamp(DateTime dateTime) {
|
|
||||||
return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _pad(int number) {
|
|
||||||
return number.toString().padLeft(2, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同步图片(支持新的目录结构)
|
/// 同步图片(支持新的目录结构)
|
||||||
/// 同步 images/movies/{id}/、images/books/{id}/、images/notes/{id}/ 下的所有图片
|
/// 同步 images/movies/{id}/、images/books/{id}/、images/notes/{id}/ 下的所有图片
|
||||||
@@ -880,24 +837,42 @@ class WebDAVService {
|
|||||||
final href = match.group(1)!;
|
final href = match.group(1)!;
|
||||||
final name = p.basename(href);
|
final name = p.basename(href);
|
||||||
|
|
||||||
// 跳过当前目录自身
|
// 跳过当前目录自身(WebDAV PROPFIND 结果中第一个或某个 entry 是当前目录)
|
||||||
if (name.isEmpty) continue;
|
if (name.isEmpty) continue;
|
||||||
if (relativePath.isEmpty && name == 'images') continue;
|
final currentUrlPath = Uri.parse(currentUrl).path;
|
||||||
|
final currentDirName = p.basename(currentUrlPath);
|
||||||
|
if (name == currentDirName) continue;
|
||||||
|
|
||||||
|
// 检查是文件还是目录 - 查找这个 href 对应的 <D:response> 或 <d:response> 部分
|
||||||
|
// 使用正则匹配,因为标签可能有属性(如 <D:response xmlns:D="DAV:">)
|
||||||
|
int responseStart = -1;
|
||||||
|
int responseEnd = -1;
|
||||||
|
|
||||||
|
// 查找包含当前 href 的 response 块(向前找最近的 response 开始标签)
|
||||||
|
final responseStartPattern = RegExp(r'<[Dd]:response\b', caseSensitive: false);
|
||||||
|
final responseEndPattern = RegExp(r'</[Dd]:response>', caseSensitive: false);
|
||||||
|
|
||||||
|
// 从 match.start 向前找最后一个 response 开始标签
|
||||||
|
final allStarts = responseStartPattern.allMatches(body.substring(0, match.start)).toList();
|
||||||
|
if (allStarts.isNotEmpty) {
|
||||||
|
responseStart = allStarts.last.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 match.start 向后找第一个 response 结束标签
|
||||||
|
final endMatch = responseEndPattern.firstMatch(body.substring(match.start));
|
||||||
|
if (endMatch != null) {
|
||||||
|
responseEnd = match.start + endMatch.end;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是文件还是目录 - 查找这个 href 对应的 <D:response> 部分(Apache 使用大写 D)
|
|
||||||
final responseStart = body.lastIndexOf('<D:response>', match.start);
|
|
||||||
final responseEnd = body.indexOf('</D:response>', match.start);
|
|
||||||
bool isDirectory = false;
|
bool isDirectory = false;
|
||||||
|
|
||||||
if (responseStart != -1 && responseEnd != -1 && responseStart < responseEnd) {
|
if (responseStart != -1 && responseEnd != -1 && responseStart < responseEnd) {
|
||||||
final responseSection = body.substring(responseStart, responseEnd);
|
final responseSection = body.substring(responseStart, responseEnd);
|
||||||
// print('WebDAV: Checking $name in section: ${responseSection.substring(0, responseSection.length > 300 ? 300 : responseSection.length)}');
|
// 检查是否包含 <D:collection/> 或 <d:collection/> 标签
|
||||||
// 检查是否包含 <D:collection/> 或 <d:collection/> 标签(Apache WebDAV 使用大写 D)
|
|
||||||
isDirectory = responseSection.contains('<D:collection/>') ||
|
isDirectory = responseSection.contains('<D:collection/>') ||
|
||||||
responseSection.contains('<d:collection/>') ||
|
responseSection.contains('<d:collection/>') ||
|
||||||
responseSection.contains('<D:collection />') ||
|
responseSection.contains('<D:collection />') ||
|
||||||
responseSection.contains('<d:collection />');
|
responseSection.contains('<d:collection />');
|
||||||
// print('WebDAV: $name contains <D:collection/>: ${responseSection.contains('<D:collection/>')}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// print('WebDAV: Found $name - isDirectory: $isDirectory');
|
// print('WebDAV: Found $name - isDirectory: $isDirectory');
|
||||||
|
|||||||
@@ -59,4 +59,10 @@ class UserPrefs {
|
|||||||
/// 是否显示笔记标签
|
/// 是否显示笔记标签
|
||||||
bool get showNoteTab => prefs.getBool('showNoteTab') ?? true;
|
bool get showNoteTab => prefs.getBool('showNoteTab') ?? true;
|
||||||
Future<bool> setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value);
|
Future<bool> setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value);
|
||||||
|
|
||||||
|
// ========== 应用图标设置 ==========
|
||||||
|
|
||||||
|
/// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名)
|
||||||
|
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
|
||||||
|
Future<bool> setAppIconName(String value) => prefs.setString('appIconName', value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,3 +48,4 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
- assets/icon/
|
||||||
|
|||||||
15
tool/check_icon_size.ps1
Normal file
15
tool/check_icon_size.ps1
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
$img = [System.Drawing.Image]::FromFile('d:\UserData\Desktop\my_proj\mooknote\assets\icon\app_icon.png')
|
||||||
|
Write-Host ('app_icon: ' + $img.Width + 'x' + $img.Height)
|
||||||
|
$img.Dispose()
|
||||||
|
$img2 = [System.Drawing.Image]::FromFile('d:\UserData\Desktop\my_proj\mooknote\assets\icon\app_icon2.png')
|
||||||
|
Write-Host ('app_icon2: ' + $img2.Width + 'x' + $img2.Height)
|
||||||
|
$img2.Dispose()
|
||||||
|
|
||||||
|
$sizes = @(48, 72, 96, 144, 192)
|
||||||
|
foreach ($size in $sizes) {
|
||||||
|
$f = [System.Drawing.Image]::FromFile('d:\UserData\Desktop\my_proj\mooknote\android\app\src\main\res\mipmap-mdpi\ic_launcher2.png')
|
||||||
|
Write-Host ('ic_launcher2 mdpi: ' + $f.Width + 'x' + $f.Height)
|
||||||
|
$f.Dispose()
|
||||||
|
break
|
||||||
|
}
|
||||||
29
tool/generate_icon2.ps1
Normal file
29
tool/generate_icon2.ps1
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
$source = 'd:\UserData\Desktop\my_proj\mooknote\assets\icon\app_icon2.png'
|
||||||
|
$sizes = @{
|
||||||
|
'mipmap-mdpi' = 48
|
||||||
|
'mipmap-hdpi' = 72
|
||||||
|
'mipmap-xhdpi' = 96
|
||||||
|
'mipmap-xxhdpi' = 144
|
||||||
|
'mipmap-xxxhdpi' = 192
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
$original = [System.Drawing.Image]::FromFile($source)
|
||||||
|
|
||||||
|
foreach ($dir in $sizes.Keys) {
|
||||||
|
$size = $sizes[$dir]
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap($size, $size)
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$graphics.DrawImage($original, 0, 0, $size, $size)
|
||||||
|
$graphics.Dispose()
|
||||||
|
|
||||||
|
$output = "d:\UserData\Desktop\my_proj\mooknote\android\app\src\main\res\$dir\ic_launcher2.png"
|
||||||
|
$bitmap.Save($output, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bitmap.Dispose()
|
||||||
|
|
||||||
|
Write-Host "Generated $output (${size}x${size})"
|
||||||
|
}
|
||||||
|
|
||||||
|
$original.Dispose()
|
||||||
|
Write-Host "Done!"
|
||||||
43
tool/generate_icon2_with_padding.ps1
Normal file
43
tool/generate_icon2_with_padding.ps1
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$source = 'd:\UserData\Desktop\my_proj\mooknote\assets\icon\app_icon2.png'
|
||||||
|
$original = [System.Drawing.Image]::FromFile($source)
|
||||||
|
|
||||||
|
# 目标尺寸(和 flutter_launcher_icons 一致)
|
||||||
|
$sizes = @{
|
||||||
|
'mipmap-mdpi' = 48
|
||||||
|
'mipmap-hdpi' = 72
|
||||||
|
'mipmap-xhdpi' = 96
|
||||||
|
'mipmap-xxhdpi' = 144
|
||||||
|
'mipmap-xxxhdpi' = 192
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($dir in $sizes.Keys) {
|
||||||
|
$size = $sizes[$dir]
|
||||||
|
|
||||||
|
# 创建带透明背景的画布
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
|
||||||
|
# 计算缩放比例,保持宽高比并添加内边距(模拟 flutter_launcher_icons 的行为)
|
||||||
|
$padding = [math]::Round($size * 0.1) # 10% padding
|
||||||
|
$drawSize = $size - 2 * $padding
|
||||||
|
|
||||||
|
# 居中绘制
|
||||||
|
$x = $padding
|
||||||
|
$y = $padding
|
||||||
|
$graphics.DrawImage($original, $x, $y, $drawSize, $drawSize)
|
||||||
|
|
||||||
|
$graphics.Dispose()
|
||||||
|
|
||||||
|
$output = "d:\UserData\Desktop\my_proj\mooknote\android\app\src\main\res\$dir\ic_launcher2.png"
|
||||||
|
$bitmap.Save($output, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bitmap.Dispose()
|
||||||
|
|
||||||
|
Write-Host "Generated $output (${size}x${size}) with padding"
|
||||||
|
}
|
||||||
|
|
||||||
|
$original.Dispose()
|
||||||
|
Write-Host "Done!"
|
||||||
Reference in New Issue
Block a user