diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b48121a..5cfe280 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,11 +17,38 @@ android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> + + + + - + + + + + + + + + + diff --git a/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt b/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt index d5438a8..d3f35ee 100644 --- a/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt +++ b/android/app/src/main/kotlin/top/iletter/mooknote/MainActivity.kt @@ -1,5 +1,62 @@ package top.iletter.mooknote +import android.content.ComponentName +import android.content.pm.PackageManager 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("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" + } + } +} diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher2.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher2.png new file mode 100644 index 0000000..f833d7f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher2.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher2.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher2.png new file mode 100644 index 0000000..270b6cb Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher2.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher2.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher2.png new file mode 100644 index 0000000..2ffd647 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher2.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher2.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher2.png new file mode 100644 index 0000000..dcecf72 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher2.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher2.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher2.png new file mode 100644 index 0000000..937d7d0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher2.png differ diff --git a/assets/icon/app_icon2.png b/assets/icon/app_icon2.png new file mode 100644 index 0000000..9637dd5 Binary files /dev/null and b/assets/icon/app_icon2.png differ diff --git a/lib/main.dart b/lib/main.dart index e611379..791e770 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'dart:async'; import 'pages/home_page.dart'; import 'utils/theme/app_theme.dart'; import 'utils/app_router.dart'; @@ -51,6 +52,9 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { + // 获取当前选中的图标名称 + final iconName = UserPrefs().appIconName; + return MultiProvider( providers: [ ChangeNotifierProvider.value(value: appProvider), @@ -63,11 +67,49 @@ class MyApp extends StatelessWidget { themeMode: ThemeMode.system, home: const HomePage(), 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 _updateSystemIcon() async { + // 目前 Flutter 官方不支持直接通过代码更换 Launcher Icon。 + // 这一步主要用于记录日志或在未来集成第三方库时使用。 + // print('Current selected icon: ${widget.iconName}'); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + /// 用于预览 MyApp 的 Widget /// 添加 @Preview 注解 @Preview(name: "MookNote App Preview") diff --git a/lib/pages/app_icon_picker_page.dart b/lib/pages/app_icon_picker_page.dart new file mode 100644 index 0000000..7983203 --- /dev/null +++ b/lib/pages/app_icon_picker_page.dart @@ -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 createState() => _AppIconPickerPageState(); +} + +class _AppIconPickerPageState extends State { + final UserPrefs _userPrefs = UserPrefs(); + String _currentIconName = 'app_icon'; + + // 预定义的图标列表 + final List> _icons = [ + {'name': 'app_icon', 'label': '默认图标'}, + {'name': 'app_icon2', 'label': '风格二'}, + ]; + + @override + void initState() { + super.initState(); + _loadCurrentIcon(); + } + + Future _loadCurrentIcon() async { + // 先从原生层获取当前实际启用的图标(更准确) + final nativeIcon = await AppIconChannel.getCurrentIcon(); + setState(() { + _currentIconName = nativeIcon; + }); + } + + Future _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)), + ], + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 0909be9..0cb4a76 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -14,6 +14,7 @@ import 'recycle_bin_page.dart'; import 'sync/backup_page.dart'; import 'statistics_page.dart'; import 'sync/cloud_sync_page.dart'; +import 'app_icon_picker_page.dart'; /// 个人中心页面 - 极简主义设计 class ProfilePage extends StatefulWidget { @@ -695,7 +696,21 @@ class SettingsPage extends StatelessWidget { 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( icon: Icons.view_list_outlined, title: '主界面功能显示', diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart index 386f712..db130b7 100644 --- a/lib/pages/sync/webdav_sync_page.dart +++ b/lib/pages/sync/webdav_sync_page.dart @@ -21,6 +21,8 @@ class _WebDAVSyncPageState extends State { bool _isLoading = false; bool _isConfigured = false; bool _obscurePassword = true; + bool _isAutoSyncEnabled = false; + int _autoSyncInterval = 5; // 默认5分钟 SyncDirection _syncDirection = SyncDirection.upload; @override @@ -50,6 +52,14 @@ class _WebDAVSyncPageState extends State { _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 { } } + /// 安全显示 Toast + void _safeShowToast(String message) { + if (mounted) { + ToastUtil.show(context, message); + } + } + /// 执行同步 Future _syncData() async { setState(() => _isLoading = true); @@ -176,6 +193,94 @@ class _WebDAVSyncPageState extends State { ); } + /// 切换自动同步 + Future _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 _showIntervalPicker() async { + final intervals = [1, 3, 5, 10, 15, 30, 60]; + + final selected = await showDialog( + 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 _clearConfig() async { final confirmed = await showDialog( @@ -374,8 +479,109 @@ class _WebDAVSyncPageState extends State { if (_isConfigured) ...[ 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), Container( @@ -550,9 +756,9 @@ class _WebDAVSyncPageState extends State { const SizedBox(height: 10), _buildInfoItem('服务器地址需包含协议(http:// 或 https://)'), const SizedBox(height: 10), - _buildInfoItem('同步前请确保服务器可用且空间充足'), + _buildInfoItem('数据文件和图片分开存储,支持多设备同步'), const SizedBox(height: 10), - _buildInfoItem('首次同步将上传所有数据,后续只同步变更'), + _buildInfoItem('开启自动同步后,修改将自动上传云端'), ], ), ); diff --git a/lib/utils/app_icon_channel.dart b/lib/utils/app_icon_channel.dart new file mode 100644 index 0000000..2cfe6a0 --- /dev/null +++ b/lib/utils/app_icon_channel.dart @@ -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 switchIcon(String iconName) async { + try { + final result = await _channel.invokeMethod('switchIcon', { + 'iconName': iconName, + }); + return result == true; + } catch (e) { + return false; + } + } + + /// 获取当前启用的图标名称 + static Future getCurrentIcon() async { + try { + final result = await _channel.invokeMethod('getCurrentIcon'); + return result as String? ?? 'app_icon'; + } catch (e) { + return 'app_icon'; + } + } +} diff --git a/lib/utils/sync/webdav_service.dart b/lib/utils/sync/webdav_service.dart index 126daee..0a1a973 100644 --- a/lib/utils/sync/webdav_service.dart +++ b/lib/utils/sync/webdav_service.dart @@ -6,8 +6,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; -import 'package:archive/archive.dart'; -import 'package:archive/archive_io.dart'; import '../database_helper.dart'; /// WebDAV 同步结果 @@ -47,7 +45,7 @@ class _ImageSyncResult { _ImageSyncResult({required this.uploaded, required this.downloaded}); } -/// WebDAV 服务类 - 支持自动定时备份 +/// WebDAV 服务类 - 支持实时同步(数据+图片分离存储) class WebDAVService { static final WebDAVService _instance = WebDAVService._internal(); static WebDAVService get instance => _instance; @@ -57,14 +55,22 @@ class WebDAVService { static const String _configKey = 'webdav_config'; static const String _lastSyncKey = 'webdav_last_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? _cachedConfig; Timer? _autoSyncTimer; bool _isAutoSyncEnabled = false; + int _autoSyncIntervalMinutes = _defaultAutoSyncInterval; + + // 文件系统监听 + StreamSubscription? _imagesDirWatcher; + final Set _pendingImageUploads = {}; + Timer? _debounceTimer; + static const Duration _debounceDelay = Duration(seconds: 3); /// 获取配置 Future?> getConfig() async { @@ -111,7 +117,8 @@ class WebDAVService { await prefs.remove(_configKey); await prefs.remove(_lastSyncKey); await prefs.remove(_autoSyncKey); - await prefs.remove(_backupListKey); + await prefs.remove(_autoSyncIntervalKey); + await prefs.remove(_lastDbModifiedKey); _cachedConfig = null; stopAutoSync(); } @@ -207,7 +214,7 @@ class WebDAVService { } } - /// 同步数据(使用 ZIP 格式,类似自动备份) + /// 同步数据(数据+图片分离存储,非压缩包方式) Future syncData({SyncDirection direction = SyncDirection.bidirectional}) async { final config = await getConfig(); if (config == null) { @@ -228,109 +235,64 @@ class WebDAVService { } 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(); int uploadedFiles = 0; int downloadedFiles = 0; int uploadedImages = 0; int downloadedImages = 0; + bool needReload = false; try { if (direction == SyncDirection.upload) { - // print('WebDAV: Upload ZIP mode'); - // 创建 ZIP 备份 - final zipBytes = await _createFullBackupZip(dbFile); - if (zipBytes == null) { - return SyncResult(success: false, message: '创建备份 ZIP 失败'); - } - - // 上传 ZIP 文件 - final result = await _uploadBytes(client, zipUrl, username, password, zipBytes); - if (result) { + // 上传数据库文件 + final dbSuccess = await _uploadFile(client, dbUrl, username, password, dbFile); + if (dbSuccess) { 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) { - // print('WebDAV: Download ZIP mode'); - // 下载 ZIP 文件 - final zipFile = File('${dbFile.parent.path}/mooknote_backup_download.zip'); - final result = await _downloadFile(client, zipUrl, username, password, zipFile); + // 下载数据库文件 + final tempDbFile = File('${dbFile.parent.path}/mooknote_download.db'); + final dbSuccess = await _downloadFile(client, dbUrl, username, password, tempDbFile); - if (result && await zipFile.exists()) { + if (dbSuccess && await tempDbFile.exists()) { + // 替换本地数据库 + await tempDbFile.copy(dbFile.path); + await tempDbFile.delete(); + await DatabaseHelper.instance.reopenDatabase(); downloadedFiles = 1; - // 解压 ZIP 文件 - 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: '远程备份不存在或下载失败'); + needReload = true; } - } else if (direction == SyncDirection.bidirectional) { - // 双向同步:比较时间戳决定上传还是下载 - // print('WebDAV: Bidirectional sync mode (ZIP)'); - final remoteZipInfo = await _getRemoteFileInfo(client, zipUrl, username, password); - if (remoteZipInfo == null) { - // 远程不存在,直接上传 - // print('WebDAV: Remote ZIP not found, uploading...'); - return await syncData(direction: SyncDirection.upload); - } else { - // 远程存在,比较修改时间 - final localModified = await dbFile.lastModified(); - final remoteModified = remoteZipInfo['modified'] as DateTime; - - // print('WebDAV: Local modified: $localModified'); - // print('WebDAV: Remote modified: $remoteModified'); - - final timeDiff = localModified.difference(remoteModified).inSeconds; - - if (timeDiff > 10) { - // 本地较新,上传 - // print('WebDAV: Local is newer, uploading...'); - 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 _syncImages(client, imagesUrl, username, password, SyncDirection.download); + downloadedImages = imageResult.downloaded; + + } else if (direction == SyncDirection.bidirectional) { + // 双向同步:分别同步数据库和图片 + final dbResult = await _syncDatabaseFile(client, dbUrl, username, password, dbFile); + if (dbResult['uploaded'] == true) uploadedFiles = 1; + if (dbResult['downloaded'] == true) { + downloadedFiles = 1; + needReload = true; } + + // 双向同步图片 + final imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password); + uploadedImages = imageResult.uploaded; + downloadedImages = imageResult.downloaded; } final prefs = await SharedPreferences.getInstance(); await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String()); - final needReload = downloadedFiles > 0; - return SyncResult( success: true, message: '同步完成', @@ -345,62 +307,111 @@ class WebDAVService { client.close(); } } catch (e) { - // print('WebDAV sync error: $e'); return SyncResult(success: false, message: '同步失败: $e'); } } - /// 统计目录中的图片数量 - Future _countImagesInDir(Directory dir) async { - int count = 0; - await for (final entity in dir.list(recursive: true)) { - if (entity is File) { - count++; - } - } - return count; - } - - /// 解压备份 ZIP 文件 - Future _extractBackupZip(File zipFile, File dbFile) async { + /// 同步数据库文件(双向) + Future> _syncDatabaseFile( + http.Client client, + String dbUrl, + String username, + String password, + File localDbFile, + ) async { + final result = {}; + try { - final bytes = await zipFile.readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); + final remoteInfo = await _getRemoteFileInfo(client, dbUrl, username, password); + final localModified = await localDbFile.lastModified(); - // 解压数据库文件 - final dbArchiveFile = archive.findFile('mooknote.db'); - if (dbArchiveFile != null) { - await dbFile.writeAsBytes(dbArchiveFile.content as List); - // print('WebDAV: Extracted database file'); - } - - // 解压图片文件 - final appDir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory('${appDir.path}/images'); - - int imageCount = 0; - for (final archiveFile in archive) { - if (archiveFile.name.startsWith('images/')) { - final relativePath = archiveFile.name.substring(7); // 去掉 'images/' 前缀 - final localFile = File('${imagesDir.path}/$relativePath'); - - // 确保父目录存在 - await localFile.parent.create(recursive: true); - - // 写入文件 - await localFile.writeAsBytes(archiveFile.content as List); - imageCount++; + if (remoteInfo == null) { + // 远程不存在,上传本地 + result['uploaded'] = await _uploadFile(client, dbUrl, username, password, localDbFile); + } else { + final remoteModified = remoteInfo['modified'] as DateTime; + final timeDiff = localModified.difference(remoteModified).inSeconds; + + if (timeDiff > 10) { + // 本地较新,上传 + result['uploaded'] = await _uploadFile(client, dbUrl, username, password, localDbFile); + } else if (timeDiff < -10) { + // 远程较新,下载 + final tempFile = File('${localDbFile.parent.path}/mooknote_temp.db'); + final success = await _downloadFile(client, dbUrl, username, password, tempFile); + if (success) { + await tempFile.copy(localDbFile.path); + await tempFile.delete(); + await DatabaseHelper.instance.reopenDatabase(); + result['downloaded'] = true; + } } } - // print('WebDAV: Extracted $imageCount images'); - - return true; } 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 = {}; + 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?> _getRemoteFileInfo( http.Client client, @@ -445,27 +456,52 @@ class WebDAVService { } } - /// 启动自动同步 - Future startAutoSync() async { - if (_autoSyncTimer != null) { - _autoSyncTimer!.cancel(); + /// 获取自动同步间隔(分钟) + Future getAutoSyncInterval() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_autoSyncIntervalKey) ?? _defaultAutoSyncInterval; + } + + /// 设置自动同步间隔(分钟) + Future 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 startAutoSync() async { + // 停止现有的定时器和监听 + await stopAutoSync(); final prefs = await SharedPreferences.getInstance(); _isAutoSyncEnabled = true; + _autoSyncIntervalMinutes = await getAutoSyncInterval(); await prefs.setBool(_autoSyncKey, true); - // 立即执行一次备份 - await performTimedBackup(); + // 立即执行一次同步 + await _performIncrementalSync(); - // 设置定时器,每5分钟执行一次 - _autoSyncTimer = Timer.periodic(_autoSyncInterval, (timer) async { - if (_isAutoSyncEnabled) { - await performTimedBackup(); - } - }); + // 设置定时器进行定期同步 + _autoSyncTimer = Timer.periodic( + Duration(minutes: _autoSyncIntervalMinutes), + (timer) async { + if (_isAutoSyncEnabled) { + await _performIncrementalSync(); + } + }, + ); - // print('WebDAV: 自动备份已启动,每5分钟执行一次'); + // 启动文件系统监听 + await _startFileWatcher(); } /// 停止自动同步 @@ -474,10 +510,14 @@ class WebDAVService { _autoSyncTimer = null; _isAutoSyncEnabled = false; + // 停止文件监听 + await _imagesDirWatcher?.cancel(); + _imagesDirWatcher = null; + _debounceTimer?.cancel(); + _pendingImageUploads.clear(); + final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_autoSyncKey, false); - - // print('WebDAV: 自动备份已停止'); } /// 检查自动同步状态 @@ -490,8 +530,94 @@ class WebDAVService { return prefs.getBool(_autoSyncKey) ?? false; } - /// 执行定时备份(按时间命名,保留最近10条) - Future performTimedBackup() async { + /// 启动文件系统监听 + Future _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 _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 _performIncrementalSync() async { final config = await getConfig(); if (config == null) { return SyncResult(success: false, message: '未配置 WebDAV'); @@ -501,9 +627,8 @@ class WebDAVService { final url = config['url']!; final username = config['username']!; final password = config['password']!; - final basePath = config['path']!; + final path = config['path']!; - // 获取本地数据库文件路径 final dbPath = await getDatabasesPath(); final dbFile = File(p.join(dbPath, 'mooknote.db')); @@ -511,227 +636,59 @@ class WebDAVService { return SyncResult(success: false, message: '本地数据库不存在'); } - // 构建 WebDAV URL(使用时间戳命名) final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; - final timestamp = _formatTimestamp(DateTime.now()); - final backupFileName = 'mooknote_$timestamp.zip'; - final davUrl = '$baseUrl$basePath/$backupFileName'; - final davImagesUrl = '$baseUrl$basePath/images'; - - // print('WebDAV: 开始定时备份到 $davUrl'); + final dbUrl = '$baseUrl$path/mooknote.db'; + final imagesUrl = '$baseUrl$path/images'; final client = http.Client(); int uploadedImages = 0; + bool dbUploaded = false; try { - // 1. 创建完整的备份 ZIP(包含数据库和图片) - final zipBytes = await _createFullBackupZip(dbFile); - if (zipBytes == null) { - return SyncResult(success: false, message: '创建备份文件失败'); + // 检查数据库是否需要同步 + final prefs = await SharedPreferences.getInstance(); + final lastDbModifiedStr = prefs.getString(_lastDbModifiedKey); + 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. 上传备份文件 - final success = await _uploadBytes(client, davUrl, username, password, zipBytes); - if (!success) { - return SyncResult(success: false, message: '上传备份文件失败'); + if (needDbSync) { + // 上传数据库 + dbUploaded = await _uploadFile(client, dbUrl, username, password, dbFile); + 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; - // 4. 更新备份列表并清理旧备份 - await _updateBackupListAndCleanup(client, baseUrl, basePath, username, password, backupFileName); - - // 5. 保存同步时间 - final prefs = await SharedPreferences.getInstance(); await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String()); - // print('WebDAV: 定时备份完成 - $backupFileName'); - return SyncResult( success: true, - message: '备份完成: $backupFileName', + message: '自动同步完成', lastSyncTime: DateTime.now(), - uploadedFiles: 1, + uploadedFiles: dbUploaded ? 1 : 0, uploadedImages: uploadedImages, - needReload: false, ); } finally { client.close(); } } 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?> _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 _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 _updateBackupListAndCleanup( - http.Client client, - String baseUrl, - String basePath, - String username, - String password, - String newBackupName, - ) async { - try { - final prefs = await SharedPreferences.getInstance(); - - // 获取现有备份列表 - List backupList = []; - final listJson = prefs.getString(_backupListKey); - if (listJson != null) { - backupList = List.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 _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 _uploadBytes( - http.Client client, - String url, - String username, - String password, - List 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}/ 下的所有图片 @@ -880,24 +837,42 @@ class WebDAVService { final href = match.group(1)!; final name = p.basename(href); - // 跳过当前目录自身 + // 跳过当前目录自身(WebDAV PROPFIND 结果中第一个或某个 entry 是当前目录) 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 对应的 部分 + // 使用正则匹配,因为标签可能有属性(如 ) + int responseStart = -1; + int responseEnd = -1; + + // 查找包含当前 href 的 response 块(向前找最近的 response 开始标签) + final responseStartPattern = RegExp(r'<[Dd]:response\b', caseSensitive: false); + final responseEndPattern = RegExp(r'', 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 对应的 部分(Apache 使用大写 D) - final responseStart = body.lastIndexOf('', match.start); - final responseEnd = body.indexOf('', match.start); bool isDirectory = false; if (responseStart != -1 && responseEnd != -1 && responseStart < responseEnd) { final responseSection = body.substring(responseStart, responseEnd); - // print('WebDAV: Checking $name in section: ${responseSection.substring(0, responseSection.length > 300 ? 300 : responseSection.length)}'); - // 检查是否包含 标签(Apache WebDAV 使用大写 D) + // 检查是否包含 标签 isDirectory = responseSection.contains('') || responseSection.contains('') || responseSection.contains('') || responseSection.contains(''); - // print('WebDAV: $name contains : ${responseSection.contains('')}'); } // print('WebDAV: Found $name - isDirectory: $isDirectory'); diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index cb00813..2f2a3cd 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -59,4 +59,10 @@ class UserPrefs { /// 是否显示笔记标签 bool get showNoteTab => prefs.getBool('showNoteTab') ?? true; Future setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value); + + // ========== 应用图标设置 ========== + + /// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名) + String get appIconName => prefs.getString('appIconName') ?? 'app_icon'; + Future setAppIconName(String value) => prefs.setString('appIconName', value); } diff --git a/pubspec.yaml b/pubspec.yaml index e224c4c..4232270 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,3 +48,4 @@ flutter: assets: - assets/images/ + - assets/icon/ diff --git a/tool/check_icon_size.ps1 b/tool/check_icon_size.ps1 new file mode 100644 index 0000000..0ec03c9 --- /dev/null +++ b/tool/check_icon_size.ps1 @@ -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 +} diff --git a/tool/generate_icon2.ps1 b/tool/generate_icon2.ps1 new file mode 100644 index 0000000..7875c4a --- /dev/null +++ b/tool/generate_icon2.ps1 @@ -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!" diff --git a/tool/generate_icon2_with_padding.ps1 b/tool/generate_icon2_with_padding.ps1 new file mode 100644 index 0000000..04e5215 --- /dev/null +++ b/tool/generate_icon2_with_padding.ps1 @@ -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!"