diff --git a/lib/main.dart b/lib/main.dart index d56894b..afda4bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'utils/app_theme.dart'; import 'utils/app_router.dart'; import 'utils/user_prefs.dart'; import 'utils/webdav_service.dart'; +import 'utils/auto_backup_service.dart'; import 'providers/app_provider.dart'; void main() async { @@ -27,10 +28,17 @@ void main() async { /// 初始化自动备份 Future _initAutoBackup() async { try { - final isEnabled = await WebDAVService.instance.isAutoSyncEnabled(); - if (isEnabled) { + // 初始化 WebDAV 自动同步 + final isWebDAVEnabled = await WebDAVService.instance.isAutoSyncEnabled(); + if (isWebDAVEnabled) { await WebDAVService.instance.startAutoSync(); } + + // 初始化本地自动备份 + final isLocalAutoBackupEnabled = await AutoBackupService.instance.getEnabled(); + if (isLocalAutoBackupEnabled) { + await AutoBackupService.instance.start(); + } } catch (e) { print('初始化自动备份失败: $e'); } diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart index 0e9db7e..e90178e 100644 --- a/lib/pages/backup_page.dart +++ b/lib/pages/backup_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../utils/backup_service.dart'; +import '../utils/auto_backup_service.dart'; import '../utils/toast_util.dart'; /// 本地备份页面 @@ -16,6 +17,30 @@ class BackupPage extends StatefulWidget { class _BackupPageState extends State { bool _isExporting = false; bool _isImporting = false; + bool _autoBackupEnabled = false; + bool _isLoading = true; + List _autoBackupFiles = []; + String? _backupDirPath; + + @override + void initState() { + super.initState(); + _loadAutoBackupStatus(); + } + + Future _loadAutoBackupStatus() async { + final enabled = await AutoBackupService.instance.getEnabled(); + final files = await AutoBackupService.instance.getBackupFiles(); + final dirPath = await AutoBackupService.instance.getBackupDirectoryPath(); + if (mounted) { + setState(() { + _autoBackupEnabled = enabled; + _autoBackupFiles = files; + _backupDirPath = dirPath; + _isLoading = false; + }); + } + } @override Widget build(BuildContext context) { @@ -24,9 +49,22 @@ class _BackupPageState extends State { appBar: AppBar( title: const Text('本地备份'), ), - body: ListView( + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( padding: const EdgeInsets.all(24), children: [ + // 自动备份开关 + _buildAutoBackupSection(), + + const SizedBox(height: 32), + + // 自动备份文件列表 + if (_autoBackupFiles.isNotEmpty) ...[ + _buildBackupFilesSection(), + const SizedBox(height: 32), + ], + // 导出数据 _buildSection( title: '导出数据', @@ -298,4 +336,189 @@ class _BackupPageState extends State { } } } + + /// 构建自动备份区域 + Widget _buildAutoBackupSection() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.schedule, + size: 24, + color: Color(0xFF1A1A1A), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + '自动本地备份', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + ), + Switch( + value: _autoBackupEnabled, + onChanged: (value) async { + setState(() => _autoBackupEnabled = value); + await AutoBackupService.instance.setEnabled(value); + if (value) { + ToastUtil.show(context, '自动备份已开启,每2分钟备份一次'); + } else { + ToastUtil.show(context, '自动备份已关闭'); + } + // 刷新文件列表 + await _loadAutoBackupStatus(); + }, + activeColor: const Color(0xFF1A1A1A), + ), + ], + ), + const SizedBox(height: 8), + Text( + '每隔2分钟自动备份到下载目录/mooknote文件夹,最多保留10个备份文件', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF666666), + height: 1.5, + ), + ), + if (_backupDirPath != null) ...[ + const SizedBox(height: 8), + Text( + '备份位置: $_backupDirPath', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + ], + ], + ), + ); + } + + /// 构建备份文件列表区域 + Widget _buildBackupFilesSection() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.folder_outlined, + size: 24, + color: Color(0xFF1A1A1A), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '自动备份文件 (${_autoBackupFiles.length}/10)', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ..._autoBackupFiles.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + final fileName = file.path.split('/').last; + final stat = file.statSync(); + final size = _formatFileSize(stat.size); + final modified = _formatDateTime(stat.modified); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + border: Border( + bottom: index < _autoBackupFiles.length - 1 + ? const BorderSide(color: Color(0xFFE5E5E5)) + : BorderSide.none, + ), + ), + child: Row( + children: [ + const Icon( + Icons.backup, + size: 18, + color: Color(0xFF666666), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF1A1A1A), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '$modified · $size', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + if (index == 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: const Text( + '最新', + style: TextStyle( + fontSize: 10, + color: Color(0xFF666666), + ), + ), + ), + ], + ), + ); + }), + ], + ), + ); + } + + /// 格式化文件大小 + String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + /// 格式化日期时间 + String _formatDateTime(DateTime dateTime) { + return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } } diff --git a/lib/pages/note_detail_page.dart b/lib/pages/note_detail_page.dart index afe80c1..6789e46 100644 --- a/lib/pages/note_detail_page.dart +++ b/lib/pages/note_detail_page.dart @@ -119,33 +119,60 @@ class _NoteDetailPageState extends State { // 图片区域(仅在纯文本模式下显示) if (note.contentType == 'plain_text' && note.images.isNotEmpty) Container( - height: 120, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), decoration: const BoxDecoration( border: Border( top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), ), ), - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: note.images.length, - itemBuilder: (context, index) { - return GestureDetector( - onTap: () => _showImagePreview(context, note.images, index), - child: Container( - width: 100, - height: 100, - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE5E5E5)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 图片标题 + Row( + children: [ + const Icon( + Icons.image_outlined, + size: 14, + color: Color(0xFF999999), ), - child: Image.file( - File(note.images[index]), - fit: BoxFit.cover, + const SizedBox(width: 6), + Text( + '图片 (${note.images.length})', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), ), + ], + ), + const SizedBox(height: 12), + // 图片列表 + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: note.images.length, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => _showImagePreview(context, note.images, index), + child: Container( + width: 100, + height: 100, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Image.file( + File(note.images[index]), + fit: BoxFit.cover, + ), + ), + ); + }, ), - ); - }, + ), + ], ), ), @@ -282,16 +309,17 @@ class _NoteDetailPageState extends State { /// 构建纯文本内容 Widget _buildPlainTextContent(Note note) { return SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: SizedBox( width: double.infinity, - child: Text( + child: SelectableText( note.content, textAlign: TextAlign.left, style: const TextStyle( - fontSize: 16, + fontSize: 15, color: Color(0xFF1A1A1A), - height: 1.8, + height: 1.9, + letterSpacing: 0.2, ), ), ), @@ -316,7 +344,12 @@ class _NoteDetailPageState extends State { /// 跳转到编辑页面 void _navigateToEdit(BuildContext context) { - Navigator.pushNamed(context, '/note-form', arguments: widget.note).then((_) { + // 从 Provider 获取最新的笔记数据,确保图片等字段是最新的 + final currentNote = context.read().notes.firstWhere( + (n) => n.id == widget.note.id, + orElse: () => widget.note, + ); + Navigator.pushNamed(context, '/note-form', arguments: currentNote).then((_) { context.read().loadNotes(); }); } diff --git a/lib/pages/webdav_sync_page.dart b/lib/pages/webdav_sync_page.dart index e9aa51d..fc69322 100644 --- a/lib/pages/webdav_sync_page.dart +++ b/lib/pages/webdav_sync_page.dart @@ -21,14 +21,12 @@ class _WebDAVSyncPageState extends State { bool _isLoading = false; bool _isConfigured = false; bool _obscurePassword = true; - bool _isAutoSyncEnabled = false; SyncDirection _syncDirection = SyncDirection.upload; @override void initState() { super.initState(); _loadConfig(); - _loadAutoSyncStatus(); } @override @@ -40,33 +38,6 @@ class _WebDAVSyncPageState extends State { super.dispose(); } - /// 加载自动同步状态 - Future _loadAutoSyncStatus() async { - final enabled = await WebDAVService.instance.isAutoSyncEnabled(); - setState(() => _isAutoSyncEnabled = enabled); - } - - /// 切换自动同步 - Future _toggleAutoSync(bool value) async { - setState(() => _isLoading = true); - - try { - if (value) { - await WebDAVService.instance.startAutoSync(); - ToastUtil.show(context, '自动备份已开启,每5分钟执行一次'); - } else { - await WebDAVService.instance.stopAutoSync(); - ToastUtil.show(context, '自动备份已关闭'); - } - - setState(() => _isAutoSyncEnabled = value); - } catch (e) { - ToastUtil.show(context, '操作失败: $e'); - } finally { - setState(() => _isLoading = false); - } - } - /// 加载已保存的配置 Future _loadConfig() async { final config = await WebDAVService.instance.getConfig(); @@ -357,56 +328,6 @@ class _WebDAVSyncPageState extends State { if (_isConfigured) ...[ const SizedBox(height: 24), - // 自动备份开关 - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: Row( - children: [ - const Icon( - Icons.schedule, - size: 20, - color: Color(0xFF666666), - ), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '自动备份', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), - ), - SizedBox(height: 2), - Text( - '每5分钟自动备份一次,保留最近10个备份', - style: TextStyle( - fontSize: 12, - color: Color(0xFF999999), - ), - ), - ], - ), - ), - Switch( - value: _isAutoSyncEnabled, - onChanged: _isLoading ? null : _toggleAutoSync, - activeColor: const Color(0xFF1A1A1A), - inactiveThumbColor: const Color(0xFF999999), - ), - ], - ), - ), - - const SizedBox(height: 24), - // 同步方向选择 const Text( '同步方向', diff --git a/lib/utils/auto_backup_service.dart b/lib/utils/auto_backup_service.dart new file mode 100644 index 0000000..ea1f0b5 --- /dev/null +++ b/lib/utils/auto_backup_service.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:shared_preferences/shared_preferences.dart'; +import 'backup_service.dart'; + +/// 自动备份服务 - 定时自动备份到下载目录 +class AutoBackupService { + static final AutoBackupService instance = AutoBackupService._init(); + + AutoBackupService._init(); + + Timer? _timer; + bool _isRunning = false; + + static const String _prefsKey = 'auto_backup_enabled'; + static const String _backupDirName = 'mooknote'; + static const int _maxBackups = 10; + static const Duration _backupInterval = Duration(minutes: 2); + + /// 是否正在运行 + bool get isRunning => _isRunning; + + /// 获取自动备份状态 + Future getEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_prefsKey) ?? false; + } + + /// 设置自动备份状态 + Future setEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKey, enabled); + + if (enabled) { + await start(); + } else { + await stop(); + } + } + + /// 启动自动备份 + Future start() async { + if (_isRunning) return; + + // 立即执行一次备份 + await _performBackup(); + + // 启动定时器 + _timer = Timer.periodic(_backupInterval, (_) async { + await _performBackup(); + }); + + _isRunning = true; + } + + /// 停止自动备份 + Future stop() async { + _timer?.cancel(); + _timer = null; + _isRunning = false; + } + + /// 执行备份 + Future _performBackup() async { + try { + final backupDir = await _getBackupDirectory(); + if (backupDir == null) { + print('AutoBackup: 无法获取备份目录'); + return; + } + + // 确保备份目录存在 + if (!await backupDir.exists()) { + await backupDir.create(recursive: true); + } + + // 导出数据 + final result = await BackupService.instance.exportDataForAutoBackup(); + + if (!result.success) { + print('AutoBackup: 导出失败 - ${result.errorMessage}'); + return; + } + + // 生成备份文件名 + final fileName = 'auto_backup_${_formatDateTime(DateTime.now())}.zip'; + final backupFile = File(path.join(backupDir.path, fileName)); + + // 写入备份文件 + await backupFile.writeAsBytes(result.zipBytes!); + + print('AutoBackup: 备份成功 - ${backupFile.path}'); + + // 清理旧备份,只保留最新的10个 + await _cleanupOldBackups(backupDir); + + } catch (e) { + print('AutoBackup: 备份失败 - $e'); + } + } + + /// 获取备份目录(下载目录/mooknote) + Future _getBackupDirectory() async { + try { + // 尝试获取下载目录 + Directory? downloadDir; + + if (Platform.isAndroid) { + // Android: 使用外部存储的下载目录 + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + // 通常路径是 /storage/emulated/0/Android/data/.../files + // 我们需要找到真正的下载目录 + final path = externalDir.path; + final downloadPath = path.replaceAll( + '/Android/data/${externalDir.uri.pathSegments[externalDir.uri.pathSegments.length - 3]}/files', + '/Download', + ); + downloadDir = Directory('$downloadPath/$_backupDirName'); + } + } else if (Platform.isIOS) { + // iOS: 使用文档目录 + final docDir = await getApplicationDocumentsDirectory(); + downloadDir = Directory('${docDir.path}/$_backupDirName'); + } else { + // 桌面端: 使用下载目录 + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home != null) { + downloadDir = Directory('$home/Downloads/$_backupDirName'); + } + } + + return downloadDir; + } catch (e) { + print('AutoBackup: 获取备份目录失败 - $e'); + return null; + } + } + + /// 清理旧备份,只保留最新的10个 + Future _cleanupOldBackups(Directory backupDir) async { + try { + final files = await backupDir + .list() + .where((entity) => entity is File && entity.path.endsWith('.zip')) + .cast() + .toList(); + + // 按修改时间排序(最新的在前) + files.sort((a, b) { + final aStat = a.statSync(); + final bStat = b.statSync(); + return bStat.modified.compareTo(aStat.modified); + }); + + // 删除超过10个的旧备份 + if (files.length > _maxBackups) { + for (var i = _maxBackups; i < files.length; i++) { + try { + await files[i].delete(); + print('AutoBackup: 删除旧备份 - ${files[i].path}'); + } catch (e) { + print('AutoBackup: 删除旧备份失败 - $e'); + } + } + } + } catch (e) { + print('AutoBackup: 清理旧备份失败 - $e'); + } + } + + /// 获取备份文件列表 + Future> getBackupFiles() async { + try { + final backupDir = await _getBackupDirectory(); + if (backupDir == null || !await backupDir.exists()) { + return []; + } + + final files = await backupDir + .list() + .where((entity) => entity is File && entity.path.endsWith('.zip')) + .cast() + .toList(); + + // 按修改时间排序(最新的在前) + files.sort((a, b) { + final aStat = a.statSync(); + final bStat = b.statSync(); + return bStat.modified.compareTo(aStat.modified); + }); + + return files; + } catch (e) { + print('AutoBackup: 获取备份列表失败 - $e'); + return []; + } + } + + /// 获取备份目录路径 + Future getBackupDirectoryPath() async { + final dir = await _getBackupDirectory(); + return dir?.path; + } + + /// 格式化日期时间用于文件名 + String _formatDateTime(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'); + } +} diff --git a/lib/utils/backup_service.dart b/lib/utils/backup_service.dart index 0d55e03..d00822b 100644 --- a/lib/utils/backup_service.dart +++ b/lib/utils/backup_service.dart @@ -182,6 +182,126 @@ class BackupService { ); } + /// 导出数据用于自动备份(返回字节数据而不是保存到文件) + Future exportDataForAutoBackup() async { + try { + final db = await DatabaseHelper.instance.database; + + // 导出所有表的数据 + final movies = await db.query('movies'); + final books = await db.query('books'); + final notes = await db.query('notes'); + final movieReviews = await db.query('movie_reviews'); + final moviePosters = await db.query('movie_posters'); + + // 收集所有图片路径 + final imagePaths = {}; + + // 收集影视海报 + for (final movie in movies) { + final posterPath = movie['poster_path'] as String?; + if (posterPath != null && posterPath.isNotEmpty) { + imagePaths.add(posterPath); + } + } + + // 收集书籍封面 + for (final book in books) { + final coverPath = book['cover_path'] as String?; + if (coverPath != null && coverPath.isNotEmpty) { + imagePaths.add(coverPath); + } + } + + // 收集海报墙图片 + for (final poster in moviePosters) { + final posterPath = poster['poster_path'] as String?; + if (posterPath != null && posterPath.isNotEmpty) { + imagePaths.add(posterPath); + } + } + + // 收集笔记图片 + for (final note in notes) { + final imagesJson = note['images'] as String?; + if (imagesJson != null && imagesJson.isNotEmpty) { + try { + final images = jsonDecode(imagesJson) as List; + for (final imagePath in images) { + if (imagePath is String && imagePath.isNotEmpty) { + imagePaths.add(imagePath); + } + } + } catch (e) { + // 解析失败,跳过 + } + } + } + + // 构建备份数据 + final backupData = { + 'version': 2, + 'exportTime': DateTime.now().toIso8601String(), + 'appName': 'MookNote', + 'hasImages': true, + 'data': { + 'movies': movies, + 'books': books, + 'notes': notes, + 'movie_reviews': movieReviews, + 'movie_posters': moviePosters, + }, + }; + + // 创建 ZIP 文件 + final archive = Archive(); + + // 添加 JSON 数据 + final jsonString = const JsonEncoder.withIndent(' ').convert(backupData); + final jsonBytes = Uint8List.fromList(utf8.encode(jsonString)); + archive.addFile(ArchiveFile('data.json', jsonBytes.length, jsonBytes)); + + // 添加图片文件,保持目录结构 + int imageCount = 0; + final appDir = await getApplicationDocumentsDirectory(); + final imagesRoot = path.join(appDir.path, 'images'); + + for (final imagePath in imagePaths) { + final file = File(imagePath); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + // 计算相对路径(如 movies/1/poster.jpg) + String relativePath; + if (imagePath.startsWith(imagesRoot)) { + relativePath = imagePath.substring(imagesRoot.length + 1); + } else { + relativePath = path.basename(imagePath); + } + // 使用相对路径存储图片,保持目录结构 + archive.addFile(ArchiveFile('images/$relativePath', bytes.length, bytes)); + imageCount++; + } + } + + // 压缩 ZIP + final zipEncoder = ZipEncoder(); + final zipBytes = zipEncoder.encode(archive); + if (zipBytes == null) { + return AutoBackupExportResult.error('压缩备份文件失败'); + } + + return AutoBackupExportResult.success( + zipBytes: Uint8List.fromList(zipBytes), + movieCount: movies.length, + bookCount: books.length, + noteCount: notes.length, + imageCount: imageCount, + ); + } catch (e) { + return AutoBackupExportResult.error('导出失败: $e'); + } + } + /// 选择并导入备份文件(支持 ZIP 格式) Future importData() async { try { @@ -450,6 +570,48 @@ class BackupService { } } +/// 自动备份导出结果 +class AutoBackupExportResult { + final bool success; + final String? errorMessage; + final Uint8List? zipBytes; + final int movieCount; + final int bookCount; + final int noteCount; + final int imageCount; + + AutoBackupExportResult._({ + required this.success, + this.errorMessage, + this.zipBytes, + this.movieCount = 0, + this.bookCount = 0, + this.noteCount = 0, + this.imageCount = 0, + }); + + factory AutoBackupExportResult.success({ + required Uint8List zipBytes, + required int movieCount, + required int bookCount, + required int noteCount, + required int imageCount, + }) { + return AutoBackupExportResult._( + success: true, + zipBytes: zipBytes, + movieCount: movieCount, + bookCount: bookCount, + noteCount: noteCount, + imageCount: imageCount, + ); + } + + factory AutoBackupExportResult.error(String message) { + return AutoBackupExportResult._(success: false, errorMessage: message); + } +} + /// 导出结果 class ExportResult { final bool success; diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index 04633fd..8a5ad83 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -13,6 +13,8 @@ class NoteListItem extends StatelessWidget { @override Widget build(BuildContext context) { + final isPlainText = note.contentType == 'plain_text'; + return InkWell( onTap: () { Navigator.pushNamed(context, '/note-detail', arguments: note); @@ -28,15 +30,65 @@ class NoteListItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 顶部:格式标记 + 时间 + Row( + children: [ + // 格式标记 + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Text( + isPlainText ? 'TXT' : 'MD', + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: Color(0xFF999999), + ), + ), + ), + const SizedBox(width: 8), + // 时间 + Text( + _formatDate(note.updatedAt), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + const Spacer(), + // 图片数量(如果有图片) + if (note.images.isNotEmpty) ...[ + const Icon( + Icons.image_outlined, + size: 11, + color: Color(0xFF999999), + ), + const SizedBox(width: 2), + Text( + '${note.images.length}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + ], + ], + ), + + const SizedBox(height: 8), + // 内容摘要(去除首尾空格) Text( note.summary.trim(), - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: Color(0xFF1A1A1A), - height: 1.5, + color: const Color(0xFF1A1A1A), + height: isPlainText ? 1.6 : 1.5, ), - maxLines: 3, + maxLines: isPlainText ? 4 : 3, overflow: TextOverflow.ellipsis, ), @@ -66,75 +118,30 @@ class NoteListItem extends StatelessWidget { ), ], - const SizedBox(height: 10), - - // 底部信息:标签 + 时间 - Row( - children: [ - // 标签 - if (note.tags.isNotEmpty) ...[ - Expanded( - child: Wrap( - spacing: 4, - runSpacing: 4, - children: note.tags.take(2).map((tag) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(2), - ), - child: Text( - tag, - style: const TextStyle( - fontSize: 10, - color: Color(0xFF666666), - ), - ), - ); - }).toList(), + // 底部标签 + if (note.tags.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: note.tags.take(3).map((tag) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + border: Border.all(color: const Color(0xFFE5E5E5)), ), - ), - ] else - const Spacer(), - - // 时间和图片数量 - Row( - children: [ - // 图片数量(如果有图片) - if (note.images.isNotEmpty) ...[ - const Icon( - Icons.image_outlined, - size: 11, - color: Color(0xFF999999), - ), - const SizedBox(width: 2), - Text( - '${note.images.length}', - style: const TextStyle( - fontSize: 11, - color: Color(0xFF999999), - ), - ), - const SizedBox(width: 6), - ], - const Icon( - Icons.access_time, - size: 11, - color: Color(0xFF999999), - ), - const SizedBox(width: 2), - Text( - _formatDate(note.updatedAt), + child: Text( + tag, style: const TextStyle( fontSize: 11, - color: Color(0xFF999999), + color: Color(0xFF666666), ), ), - ], - ), - ], - ), + ); + }).toList(), + ), + ], ], ), ),