自定义图标

This commit is contained in:
DelLevin-Home
2026-05-20 00:38:30 +08:00
parent 320fd9f692
commit 9560e37a44
19 changed files with 948 additions and 361 deletions

View File

@@ -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" />

View File

@@ -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"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/icon/app_icon2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

View File

@@ -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")

View 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)),
],
],
),
),
);
},
),
);
}
}

View File

@@ -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: '主界面功能显示',

View File

@@ -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('开启自动同步后,修改将自动上传云端'),
], ],
), ),
); );

View 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';
}
}
}

View File

@@ -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');

View File

@@ -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);
} }

View File

@@ -48,3 +48,4 @@ flutter:
assets: assets:
- assets/images/ - assets/images/
- assets/icon/

15
tool/check_icon_size.ps1 Normal file
View 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
View 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!"

View 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!"