diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 3a2ff70..6e289e0 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -137,7 +137,7 @@ class _NoteTabPageState extends State { backgroundColor: Colors.white, child: ListView.builder( controller: _scrollController, - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 100), itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { if (index >= _displayedNotes.length) { diff --git a/lib/utils/sync/webdav_service.dart b/lib/utils/sync/webdav_service.dart index 0a1a973..df30773 100644 --- a/lib/utils/sync/webdav_service.dart +++ b/lib/utils/sync/webdav_service.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; import '../database_helper.dart'; +import '../user_prefs.dart'; /// WebDAV 同步结果 class SyncResult { @@ -237,6 +238,8 @@ class WebDAVService { final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; final dbUrl = '$baseUrl$path/mooknote.db'; final imagesUrl = '$baseUrl$path/images'; + final avatarsUrl = '$baseUrl$path/avatars'; + final userConfigUrl = '$baseUrl$path/user_config.json'; final client = http.Client(); int uploadedFiles = 0; @@ -257,6 +260,13 @@ class WebDAVService { final imageResult = await _syncImages(client, imagesUrl, username, password, SyncDirection.upload); uploadedImages = imageResult.uploaded; + // 上传头像 + final avatarResult = await _syncAvatars(client, avatarsUrl, username, password, SyncDirection.upload); + uploadedImages += avatarResult.uploaded; + + // 上传用户配置 + await _uploadUserConfig(client, userConfigUrl, username, password); + } else if (direction == SyncDirection.download) { // 下载数据库文件 final tempDbFile = File('${dbFile.parent.path}/mooknote_download.db'); @@ -275,6 +285,13 @@ class WebDAVService { final imageResult = await _syncImages(client, imagesUrl, username, password, SyncDirection.download); downloadedImages = imageResult.downloaded; + // 下载头像 + final avatarResult = await _syncAvatars(client, avatarsUrl, username, password, SyncDirection.download); + downloadedImages += avatarResult.downloaded; + + // 下载并恢复用户配置 + await _downloadUserConfig(client, userConfigUrl, username, password); + } else if (direction == SyncDirection.bidirectional) { // 双向同步:分别同步数据库和图片 final dbResult = await _syncDatabaseFile(client, dbUrl, username, password, dbFile); @@ -288,6 +305,15 @@ class WebDAVService { final imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password); uploadedImages = imageResult.uploaded; downloadedImages = imageResult.downloaded; + + // 双向同步头像 + final avatarResult = await _syncAvatarsBidirectional(client, avatarsUrl, username, password); + uploadedImages += avatarResult.uploaded; + downloadedImages += avatarResult.downloaded; + + // 双向同步用户配置:先下载远程,再上传本地 + await _downloadUserConfig(client, userConfigUrl, username, password); + await _uploadUserConfig(client, userConfigUrl, username, password); } final prefs = await SharedPreferences.getInstance(); @@ -410,7 +436,177 @@ class WebDAVService { return _ImageSyncResult(uploaded: uploaded, downloaded: downloaded); } - + /// 同步头像目录 + Future<_ImageSyncResult> _syncAvatars( + http.Client client, + String avatarsUrl, + String username, + String password, + SyncDirection direction, + ) async { + int uploaded = 0; + int downloaded = 0; + + try { + final appDir = await getApplicationDocumentsDirectory(); + final localAvatarsDir = Directory('${appDir.path}/avatars'); + + if (!await localAvatarsDir.exists()) { + if (direction == SyncDirection.download) { + await localAvatarsDir.create(recursive: true); + } else { + return _ImageSyncResult(uploaded: 0, downloaded: 0); + } + } + + final localAvatars = {}; + await _collectLocalImages(localAvatarsDir, localAvatars, ''); + + final remoteAvatars = await _listRemoteImagesRecursive(client, avatarsUrl, username, password, ''); + + if (direction == SyncDirection.upload) { + for (final entry in localAvatars.entries) { + final remoteUrl = '$avatarsUrl/${entry.key}'; + final parentPath = p.dirname(entry.key); + if (parentPath != '.' && parentPath.isNotEmpty) { + await _ensureRemoteDir(client, '$avatarsUrl/$parentPath', username, password); + } + final success = await _uploadFile(client, remoteUrl, username, password, entry.value); + if (success) uploaded++; + } + } else if (direction == SyncDirection.download) { + for (final relativePath in remoteAvatars) { + final remoteUrl = '$avatarsUrl/$relativePath'; + final localFile = File('${localAvatarsDir.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<_ImageSyncResult> _syncAvatarsBidirectional( + http.Client client, + String avatarsUrl, + String username, + String password, + ) async { + int uploaded = 0; + int downloaded = 0; + + try { + final appDir = await getApplicationDocumentsDirectory(); + final localAvatarsDir = Directory('${appDir.path}/avatars'); + + if (!await localAvatarsDir.exists()) { + await localAvatarsDir.create(recursive: true); + } + + final localAvatars = {}; + await _collectLocalImages(localAvatarsDir, localAvatars, ''); + + final remoteAvatars = await _listRemoteImagesRecursive(client, avatarsUrl, username, password, ''); + + for (final entry in localAvatars.entries) { + if (!remoteAvatars.contains(entry.key)) { + final remoteUrl = '$avatarsUrl/${entry.key}'; + final parentPath = p.dirname(entry.key); + if (parentPath != '.' && parentPath.isNotEmpty) { + await _ensureRemoteDir(client, '$avatarsUrl/$parentPath', username, password); + } + final success = await _uploadFile(client, remoteUrl, username, password, entry.value); + if (success) uploaded++; + } + } + + for (final relativePath in remoteAvatars) { + if (!localAvatars.containsKey(relativePath)) { + final remoteUrl = '$avatarsUrl/$relativePath'; + final localFile = File('${localAvatarsDir.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 _uploadUserConfig( + http.Client client, + String userConfigUrl, + String username, + String password, + ) async { + try { + final userPrefs = UserPrefs(); + final config = { + 'nickname': userPrefs.nickname, + 'motto': userPrefs.motto, + 'avatarPath': userPrefs.avatarPath, + 'updatedAt': DateTime.now().toIso8601String(), + }; + + final request = http.Request('PUT', Uri.parse(userConfigUrl)); + request.headers['Authorization'] = _basicAuth(username, password); + request.headers['Content-Type'] = 'application/json'; + request.body = jsonEncode(config); + + await client.send(request); + } catch (e) { + // 忽略错误 + } + } + + /// 下载并恢复用户配置 + Future _downloadUserConfig( + http.Client client, + String userConfigUrl, + String username, + String password, + ) async { + try { + final request = http.Request('GET', Uri.parse(userConfigUrl)); + request.headers['Authorization'] = _basicAuth(username, password); + + final response = await client.send(request); + if (response.statusCode == 200) { + final body = await response.stream.bytesToString(); + final config = jsonDecode(body) as Map; + + final userPrefs = UserPrefs(); + if (config.containsKey('nickname')) { + await userPrefs.setNickname(config['nickname'] as String); + } + if (config.containsKey('motto')) { + await userPrefs.setMotto(config['motto'] as String); + } + if (config.containsKey('avatarPath')) { + final avatarPath = config['avatarPath'] as String?; + if (avatarPath != null && avatarPath.isNotEmpty) { + final fileName = p.basename(avatarPath); + final appDir = await getApplicationDocumentsDirectory(); + final newAvatarPath = p.join(appDir.path, 'avatars', fileName); + if (await File(newAvatarPath).exists()) { + await userPrefs.setAvatarPath(newAvatarPath); + } + } + } + } + } catch (e) { + // 忽略错误 + } + } /// 获取远程文件信息 Future?> _getRemoteFileInfo( @@ -639,9 +835,12 @@ class WebDAVService { final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; final dbUrl = '$baseUrl$path/mooknote.db'; final imagesUrl = '$baseUrl$path/images'; + final avatarsUrl = '$baseUrl$path/avatars'; + final userConfigUrl = '$baseUrl$path/user_config.json'; final client = http.Client(); int uploadedImages = 0; + int downloadedImages = 0; bool dbUploaded = false; try { @@ -670,6 +869,16 @@ class WebDAVService { // 同步图片(双向) final imageResult = await _syncImagesBidirectional(client, imagesUrl, username, password); uploadedImages = imageResult.uploaded; + downloadedImages = imageResult.downloaded; + + // 同步头像(双向) + final avatarResult = await _syncAvatarsBidirectional(client, avatarsUrl, username, password); + uploadedImages += avatarResult.uploaded; + downloadedImages += avatarResult.downloaded; + + // 同步用户配置:下载远程并上传本地 + await _downloadUserConfig(client, userConfigUrl, username, password); + await _uploadUserConfig(client, userConfigUrl, username, password); await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String()); @@ -679,6 +888,7 @@ class WebDAVService { lastSyncTime: DateTime.now(), uploadedFiles: dbUploaded ? 1 : 0, uploadedImages: uploadedImages, + downloadedImages: downloadedImages, ); } finally { client.close(); diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index f4d8fc8..41579c5 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -39,42 +39,43 @@ class _NoteListItemContent extends StatelessWidget { }, onLongPress: () => _showDeleteDialog(context), child: Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), decoration: BoxDecoration( color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - // 顶部:格式标记 + 时间 + // 顶部:格式标记 + 时间 + 图片数 Row( children: [ // 格式标记 Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(3), border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), child: Text( isPlainText ? 'TXT' : 'MD', style: const TextStyle( - fontSize: 10, + fontSize: 9, fontWeight: FontWeight.w600, color: Color(0xFF999999), ), ), ), - const SizedBox(width: 10), + const SizedBox(width: 8), // 时间 - 使用缓存的格式化结果 Text( _formatDateCached(note.updatedAt), style: const TextStyle( - fontSize: 12, + fontSize: 11, color: Color(0xFF999999), ), ), @@ -83,40 +84,40 @@ class _NoteListItemContent extends StatelessWidget { if (note.images.isNotEmpty) ...[ const Icon( Icons.image_outlined, - size: 14, + size: 12, color: Color(0xFF999999), ), - const SizedBox(width: 4), + const SizedBox(width: 3), Text( '${note.images.length}', style: const TextStyle( - fontSize: 12, + fontSize: 11, color: Color(0xFF999999), ), ), ], ], ), - - const SizedBox(height: 12), - + + const SizedBox(height: 8), + // 内容摘要(去除首尾空格) Text( note.summary.trim(), style: TextStyle( - fontSize: 15, + fontSize: 14, color: const Color(0xFF1A1A1A), - height: isPlainText ? 1.7 : 1.6, + height: isPlainText ? 1.5 : 1.45, ), - maxLines: isPlainText ? 4 : 3, + maxLines: isPlainText ? 3 : 2, overflow: TextOverflow.ellipsis, ), - + // 图片预览区域(显示前4张图片) if (note.images.isNotEmpty) ...[ - const SizedBox(height: 12), + const SizedBox(height: 8), SizedBox( - height: 70, + height: 52, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: note.images.length > 4 ? 4 : note.images.length, @@ -132,25 +133,25 @@ class _NoteListItemContent extends StatelessWidget { ), ), ], - + // 底部标签 if (note.tags.isNotEmpty) ...[ - const SizedBox(height: 12), + const SizedBox(height: 8), Wrap( - spacing: 8, - runSpacing: 8, + spacing: 6, + runSpacing: 4, children: note.tags.take(3).map((tag) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(4), border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), child: Text( tag, style: const TextStyle( - fontSize: 12, + fontSize: 11, color: Color(0xFF666666), ), ), @@ -237,11 +238,11 @@ class _NoteImage extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: 70, - height: 70, - margin: EdgeInsets.only(right: index < 3 ? 10 : 0), + width: 52, + height: 52, + margin: EdgeInsets.only(right: index < 3 ? 6 : 0), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), clipBehavior: Clip.antiAlias, @@ -252,7 +253,7 @@ class _NoteImage extends StatelessWidget { child: Text( '+${totalCount - 4}', style: const TextStyle( - fontSize: 16, + fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF666666), ), @@ -262,11 +263,11 @@ class _NoteImage extends StatelessWidget { : Image.file( File(imagePath), fit: BoxFit.cover, - cacheWidth: 140, - cacheHeight: 140, + cacheWidth: 104, + cacheHeight: 104, errorBuilder: (_, __, ___) => const Icon( Icons.broken_image, - size: 28, + size: 20, color: Color(0xFFCCCCCC), ), ),