diff --git a/README.md b/README.md index c68b18a..33863bb 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ lib/ - **数据存储位置**:`/mooknote/mooknote.db` - **图片存储位置**:`/mooknote/images/<类别>/<条目ID>/<文件名>` - 类别:`movie` / `book` / `note` +- **书籍存储位置**:`/mooknote/epub_bookx/<条目ID>.epub` ## 环境要求 diff --git a/lib/main.dart b/lib/main.dart index 188629b..04fb973 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -48,7 +48,7 @@ Future _initAutoBackup() async { await AutoBackupService.instance.start(); } } catch (e) { - print('初始化自动备份失败: $e'); + debugPrint('初始化自动备份失败: $e'); } } @@ -56,7 +56,7 @@ Future _initUsageStats() async { try { await UsageStatsService.instance.start(); } catch (e) { - print('初始化用户统计失败: $e'); + debugPrint('初始化用户统计失败: $e'); } } @@ -240,14 +240,6 @@ class _AppIconWrapper extends StatefulWidget { } class _AppIconWrapperState extends State<_AppIconWrapper> { - @override - void initState() { - super.initState(); - _updateSystemIcon(); - } - - Future _updateSystemIcon() async {} - @override Widget build(BuildContext context) { return widget.child; diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index ad88154..6187da3 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -7,6 +7,31 @@ class _CopyWithNullSentinel { } const _copyWithNull = _CopyWithNullSentinel(); +/// 安全解析日期字符串,失败时返回 fallback +DateTime? _safeParseDate(String? str, {DateTime? fallback}) { + if (str == null || str.isEmpty) return fallback; + return DateTime.tryParse(str) ?? fallback; +} + +/// 解析字符串列表(通用工具函数,不限于 Movie) +List parseStringListGeneric(dynamic data) { + if (data == null) return []; + if (data is List) { + return data.map((e) => e.toString()).toList(); + } + if (data is String) { + try { + final decoded = jsonDecode(data); + if (decoded is List) { + return decoded.map((e) => e.toString()).toList(); + } + } catch (e) { + return data.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + } + } + return []; +} + /// 影视条目模型 class Movie { final String id; @@ -52,9 +77,7 @@ class Movie { id: json['id'] ?? '', title: json['title'] ?? '', posterPath: json['poster_path'], - releaseDate: json['release_date'] != null - ? DateTime.parse(json['release_date']) - : null, + releaseDate: _safeParseDate(json['release_date']), directors: _parseStringList(json['directors']), writers: _parseStringList(json['writers']), actors: _parseStringList(json['actors']), @@ -63,15 +86,9 @@ class Movie { summary: json['summary'], rating: json['rating']?.toDouble(), status: json['status'] ?? 'want_to_watch', - watchDate: json['watch_date'] != null - ? DateTime.parse(json['watch_date']) - : null, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) - : DateTime.now(), + watchDate: _safeParseDate(json['watch_date']), + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, coverOffset: (json['cover_offset'] ?? 0.0).toDouble(), ); @@ -105,29 +122,11 @@ class Movie { return File(posterPath!); } - /// 解析字符串列表(公共静态方法,供Book使用) - static List parseStringList(dynamic data) { - if (data == null) return []; - if (data is List) { - return data.map((e) => e.toString()).toList(); - } - if (data is String) { - try { - // 尝试解析JSON字符串 - final decoded = jsonDecode(data); - if (decoded is List) { - return decoded.map((e) => e.toString()).toList(); - } - } catch (e) { - // 如果解析失败,按逗号分割 - return data.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); - } - } - return []; - } - + /// 解析字符串列表(公共静态方法,供外部使用) + static List parseStringList(dynamic data) => parseStringListGeneric(data); + /// 解析字符串列表(私有别名,保持兼容性) - static List _parseStringList(dynamic data) => parseStringList(data); + static List _parseStringList(dynamic data) => parseStringListGeneric(data); /// 复制并修改 Movie copyWith({ @@ -222,15 +221,9 @@ class Book { rating: json['rating']?.toDouble(), status: json['status'] ?? 'want_to_read', isbn: json['isbn'], - publishDate: json['publish_date'] != null - ? DateTime.parse(json['publish_date']) - : null, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) - : DateTime.now(), + publishDate: _safeParseDate(json['publish_date']), + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, coverOffset: (json['cover_offset'] ?? 0.0).toDouble(), ); @@ -337,12 +330,8 @@ class Note { contentType: json['content_type'] ?? 'markdown', tags: Movie.parseStringList(json['tags']), images: Movie.parseStringList(json['images']), - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) - : DateTime.now(), + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, isPinned: json['is_pinned'] == 1 || json['is_pinned'] == true, ); @@ -430,12 +419,8 @@ class MovieReview { source: json['source'] ?? '', reviewType: json['review_type'] ?? 1, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) - : DateTime.now(), + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, ); } @@ -510,9 +495,7 @@ class MoviePoster { movieId: json['movie_id']?.toString() ?? '', posterPath: json['poster_path'] ?? '', isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, ); } @@ -566,12 +549,8 @@ class BookReview { source: json['source'] ?? '', reviewType: json['review_type'] ?? 1, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) - : DateTime.now(), + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, ); } @@ -654,12 +633,8 @@ class BookExcerpt { content: json['content'] ?? '', comment: json['comment'] ?? '', isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) - : DateTime.now(), + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, ); } diff --git a/lib/pages/book/book_share_page.dart b/lib/pages/book/book_share_page.dart index 6de8066..4d0110c 100644 --- a/lib/pages/book/book_share_page.dart +++ b/lib/pages/book/book_share_page.dart @@ -89,7 +89,7 @@ class _BookSharePageState extends State { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10), ), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index aeb18ba..902f4bd 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -104,7 +104,7 @@ class _HomePageState extends State { right: Radius.circular(28)), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.08), + color: Colors.black.withValues(alpha: 0.08), blurRadius: 20, offset: const Offset(0, 4), spreadRadius: 0, diff --git a/lib/pages/media_calendar_page.dart b/lib/pages/media_calendar_page.dart index 428eea3..63c3a7e 100644 --- a/lib/pages/media_calendar_page.dart +++ b/lib/pages/media_calendar_page.dart @@ -91,15 +91,26 @@ class _MediaCalendarPageState extends State { _buildMonthHeader(colors), _buildWeekdayLabels(colors), Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _buildCalendarGrid(colors, today), - if (_selectedDay != null) _buildSelectedDayDetail(colors), - const SizedBox(height: 80), - ], - ), - ), + child: _selectedDay != null && (_dayItems[_selectedDay]?.isNotEmpty ?? false) + ? Column( + children: [ + SingleChildScrollView( + child: _buildCalendarGrid(colors, today), + ), + Expanded( + child: _buildSelectedDayDetail(colors), + ), + ], + ) + : SingleChildScrollView( + child: Column( + children: [ + _buildCalendarGrid(colors, today), + if (_selectedDay != null) _buildSelectedDayDetail(colors), + const SizedBox(height: 80), + ], + ), + ), ), ], ), @@ -299,7 +310,6 @@ class _MediaCalendarPageState extends State { } return Container( - constraints: const BoxConstraints(maxHeight: 200), decoration: BoxDecoration( border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), ), @@ -322,9 +332,8 @@ class _MediaCalendarPageState extends State { ], ), ), - Flexible( + Expanded( child: ListView.separated( - shrinkWrap: true, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: items.length, separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant), diff --git a/lib/pages/movies/douban_webview_page.dart b/lib/pages/movies/douban_webview_page.dart index b67f3f8..f68a2ad 100644 --- a/lib/pages/movies/douban_webview_page.dart +++ b/lib/pages/movies/douban_webview_page.dart @@ -99,7 +99,7 @@ class _DoubanWebViewPageState extends State { return Container( margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.3), + color: Colors.black.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), child: Material( @@ -130,7 +130,7 @@ class _DoubanWebViewPageState extends State { return Container( margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.9), + color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8), ), child: Material( diff --git a/lib/pages/movies/movie_posters_page.dart b/lib/pages/movies/movie_posters_page.dart index 92ea01c..1e01f9b 100644 --- a/lib/pages/movies/movie_posters_page.dart +++ b/lib/pages/movies/movie_posters_page.dart @@ -147,7 +147,7 @@ class _MoviePostersPageState extends State { borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.08), + color: Colors.black.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 2), ), @@ -176,7 +176,7 @@ class _MoviePostersPageState extends State { end: Alignment.bottomCenter, colors: [ Colors.transparent, - Colors.black.withOpacity(0.3), + Colors.black.withValues(alpha: 0.3), ], ), ), diff --git a/lib/pages/movies/poster_gallery_page.dart b/lib/pages/movies/poster_gallery_page.dart index 4181c86..dd5fee5 100644 --- a/lib/pages/movies/poster_gallery_page.dart +++ b/lib/pages/movies/poster_gallery_page.dart @@ -76,7 +76,7 @@ class _PosterGalleryPageState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withOpacity(0.7), + Colors.black.withValues(alpha: 0.7), Colors.transparent, ], ), @@ -126,7 +126,7 @@ class _PosterGalleryPageState extends State { shape: BoxShape.circle, color: index == _currentIndex ? Colors.white - : Colors.white.withOpacity(0.4), + : Colors.white.withValues(alpha: 0.4), ), ), ), diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index 29226f4..5cfd8a4 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -33,6 +33,7 @@ class _NoteFormPageState extends State { String? _tempNoteId; // 新建模式时使用的临时笔记ID String _editorMode = 'edit'; // 'edit' | 'preview' Timer? _autoSaveTimer; + Timer? _saveStatusTimer; String _saveStatus = ''; // '', 'saved' Note? _savedNote; // 新建模式首次自动保存后的笔记引用 @@ -56,6 +57,7 @@ class _NoteFormPageState extends State { @override void dispose() { _autoSaveTimer?.cancel(); + _saveStatusTimer?.cancel(); _titleController.dispose(); _contentController.dispose(); super.dispose(); @@ -116,7 +118,8 @@ class _NoteFormPageState extends State { } if (mounted) { setState(() => _saveStatus = 'saved'); - Timer(const Duration(seconds: 3), () { + _saveStatusTimer?.cancel(); + _saveStatusTimer = Timer(const Duration(seconds: 3), () { if (mounted && _saveStatus == 'saved') setState(() => _saveStatus = ''); }); } @@ -875,7 +878,7 @@ class _NoteFormPageState extends State { builder: (context) => GestureDetector( onTap: () => Navigator.pop(context), child: Container( - color: Colors.black.withOpacity(0.9), + color: Colors.black.withValues(alpha: 0.9), child: Center( child: InteractiveViewer( panEnabled: true, diff --git a/lib/pages/note/note_share_page.dart b/lib/pages/note/note_share_page.dart index 5d81a0c..462d8db 100644 --- a/lib/pages/note/note_share_page.dart +++ b/lib/pages/note/note_share_page.dart @@ -95,7 +95,7 @@ class _NoteSharePageState extends State { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10), ), diff --git a/lib/pages/note_plus/note_plus_detail_page.dart b/lib/pages/note_plus/note_plus_detail_page.dart index b57d497..69adc15 100644 --- a/lib/pages/note_plus/note_plus_detail_page.dart +++ b/lib/pages/note_plus/note_plus_detail_page.dart @@ -24,8 +24,10 @@ class NotePlusDetailPage extends StatelessWidget { builder: (context, provider, _) { final doc = provider.currentDocument; if (doc == null || doc.id != documentId) { - // 加载文档 - provider.loadDocumentById(documentId); + // 加载文档(延迟到 build 完成后执行,避免副作用) + WidgetsBinding.instance.addPostFrameCallback((_) { + provider.loadDocumentById(documentId); + }); return Scaffold( backgroundColor: colors.surface, body: const Center(child: CircularProgressIndicator()), diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index c206ed6..0bb242a 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -1660,7 +1660,7 @@ class _SettingsPageState extends State { value: value, onChanged: onChanged, activeColor: colors.primary, - activeTrackColor: colors.primary.withOpacity(0.3), + activeTrackColor: colors.primary.withValues(alpha: 0.3), inactiveThumbColor: colors.surface, inactiveTrackColor: colors.outline), ], @@ -2110,7 +2110,7 @@ class _MainContentSettingsPageState extends State { value: value, onChanged: onChanged, activeColor: colors.primary, - activeTrackColor: colors.primary.withOpacity(0.3), + activeTrackColor: colors.primary.withValues(alpha: 0.3), inactiveThumbColor: colors.surface, inactiveTrackColor: colors.outline), ); diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 6c5b896..facbd74 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -88,7 +88,7 @@ class AppProvider extends ChangeNotifier { final showBook = userPrefs.showBookTab; final showNote = userPrefs.showNoteTab; final enabled = [showMovie, showBook, showNote]; - if (enabled[defaultIndex]) { + if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) { _mainTabIndex = defaultIndex; } else { // 回退到第一个启用的标签 @@ -568,7 +568,7 @@ class AppProvider extends ChangeNotifier { final movies = await db.query('movies', where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); for (final row in movies) { - for (final genre in Movie.parseStringList(row['genres'])) { + for (final genre in parseStringListGeneric(row['genres'])) { await insertTag(genre, 'movie_genre'); } } @@ -577,7 +577,7 @@ class AppProvider extends ChangeNotifier { final books = await db.query('books', where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); for (final row in books) { - for (final genre in Movie.parseStringList(row['genres'])) { + for (final genre in parseStringListGeneric(row['genres'])) { await insertTag(genre, 'book_genre'); } } @@ -587,7 +587,7 @@ class AppProvider extends ChangeNotifier { where: 'tags IS NOT NULL AND tags != ? AND tags != ?', whereArgs: ['[]', '']); for (final row in notes) { - for (final tag in Movie.parseStringList(row['tags'])) { + for (final tag in parseStringListGeneric(row['tags'])) { await insertTag(tag, 'note_tag'); } } diff --git a/lib/utils/book/book_dao.dart b/lib/utils/book/book_dao.dart index a99fa9a..0687ba5 100644 --- a/lib/utils/book/book_dao.dart +++ b/lib/utils/book/book_dao.dart @@ -174,6 +174,9 @@ class BookDao { // 彻底删除书籍 Future permanentDeleteBook(String id) => _wrap('permanentDeleteBook', () async { final db = await _dbHelper.database; + // 清理子记录,防止孤儿数据 + await db.delete('book_reviews', where: 'book_id = ?', whereArgs: [id]); + await db.delete('book_excerpts', where: 'book_id = ?', whereArgs: [id]); return await db.delete( 'books', where: 'id = ?', diff --git a/lib/utils/book/book_excerpt_dao.dart b/lib/utils/book/book_excerpt_dao.dart index 8340b26..a7de38d 100644 --- a/lib/utils/book/book_excerpt_dao.dart +++ b/lib/utils/book/book_excerpt_dao.dart @@ -67,7 +67,7 @@ class BookExcerptDao { final db = await _dbHelper.database; await db.update( 'book_excerpts', - {'is_deleted': 1}, + {'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()}, where: 'id = ?', whereArgs: [id], ); diff --git a/lib/utils/book/book_review_dao.dart b/lib/utils/book/book_review_dao.dart index 9e887e9..5f441b1 100644 --- a/lib/utils/book/book_review_dao.dart +++ b/lib/utils/book/book_review_dao.dart @@ -67,7 +67,7 @@ class BookReviewDao { final db = await _dbHelper.database; await db.update( 'book_reviews', - {'is_deleted': 1}, + {'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()}, where: 'id = ?', whereArgs: [id], ); diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index ed1ac25..49b7224 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -7,6 +7,7 @@ import '../models/data_models.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); static Database? _database; + static bool _isReopening = false; DatabaseHelper._init(); @@ -18,16 +19,25 @@ class DatabaseHelper { /// 重新打开数据库(用于 WebDAV 同步后) Future reopenDatabase() async { - // 关闭现有连接 - if (_database != null) { - await _database!.close(); - _database = null; + // 防止并发重开 + if (_isReopening) return; + _isReopening = true; + try { + if (_database != null) { + await _database!.close(); + _database = null; + } + _database = await _initDB('mooknote.db'); + } finally { + _isReopening = false; } - // 重新初始化 - _database = await _initDB('mooknote.db'); } Future get database async { + // 等待重开完成 + while (_isReopening) { + await Future.delayed(const Duration(milliseconds: 50)); + } if (_database != null) return _database!; _database = await _initDB('mooknote.db'); return _database!; @@ -157,7 +167,9 @@ class DatabaseHelper { await db.execute("ALTER TABLE note_plus ADD COLUMN parent_id TEXT DEFAULT ''"); } } - } catch (_) {} + } catch (e) { + debugPrint('Migration v20 failed: $e'); + } } if (oldVersion < 21) { try { @@ -165,7 +177,9 @@ class DatabaseHelper { if (!cols.any((col) => col['name'] == 'sort_index')) { await db.execute("ALTER TABLE note_plus ADD COLUMN sort_index INTEGER DEFAULT 0"); } - } catch (_) {} + } catch (e) { + debugPrint('Migration v21 failed: $e'); + } } if (oldVersion < 22) { // 为笔记表添加置顶字段 @@ -285,7 +299,7 @@ class DatabaseHelper { final movies = await db.query('movies', where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); for (final row in movies) { - for (final genre in Movie.parseStringList(row['genres'])) { + for (final genre in parseStringListGeneric(row['genres'])) { await insertTag(genre, 'movie_genre'); } } @@ -294,7 +308,7 @@ class DatabaseHelper { final books = await db.query('books', where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); for (final row in books) { - for (final genre in Movie.parseStringList(row['genres'])) { + for (final genre in parseStringListGeneric(row['genres'])) { await insertTag(genre, 'book_genre'); } } @@ -304,7 +318,7 @@ class DatabaseHelper { where: 'tags IS NOT NULL AND tags != ? AND tags != ?', whereArgs: ['[]', '']); for (final row in notes) { - for (final tag in Movie.parseStringList(row['tags'])) { + for (final tag in parseStringListGeneric(row['tags'])) { await insertTag(tag, 'note_tag'); } } diff --git a/lib/utils/movie/movie_dao.dart b/lib/utils/movie/movie_dao.dart index c6d2590..62e9b9b 100644 --- a/lib/utils/movie/movie_dao.dart +++ b/lib/utils/movie/movie_dao.dart @@ -253,6 +253,9 @@ class MovieDao { // 彻底删除影视 Future permanentDeleteMovie(String id) => _wrap('permanentDeleteMovie', () async { final db = await _dbHelper.database; + // 清理子记录,防止孤儿数据 + await db.delete('movie_reviews', where: 'movie_id = ?', whereArgs: [id]); + await db.delete('movie_posters', where: 'movie_id = ?', whereArgs: [id]); return await db.delete( 'movies', where: 'id = ?', diff --git a/lib/utils/movie/movie_review_dao.dart b/lib/utils/movie/movie_review_dao.dart index 5675787..0c2a9b7 100644 --- a/lib/utils/movie/movie_review_dao.dart +++ b/lib/utils/movie/movie_review_dao.dart @@ -61,7 +61,7 @@ class MovieReviewDao { final db = await _dbHelper.database; return await db.update( 'movie_reviews', - {'is_deleted': 1}, + {'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()}, where: 'id = ?', whereArgs: [id], ); diff --git a/lib/utils/sync/auto_backup_service.dart b/lib/utils/sync/auto_backup_service.dart index 0548775..cfc18f9 100644 --- a/lib/utils/sync/auto_backup_service.dart +++ b/lib/utils/sync/auto_backup_service.dart @@ -44,16 +44,16 @@ class AutoBackupService { /// 启动自动备份 Future start() async { if (_isRunning) return; - + + _isRunning = true; + // 立即执行一次备份 await _performBackup(); - + // 启动定时器 _timer = Timer.periodic(_backupInterval, (_) async { await _performBackup(); }); - - _isRunning = true; } /// 停止自动备份 diff --git a/lib/utils/sync/backup_service.dart b/lib/utils/sync/backup_service.dart index 3af6fd7..e2c95ce 100644 --- a/lib/utils/sync/backup_service.dart +++ b/lib/utils/sync/backup_service.dart @@ -114,6 +114,21 @@ class BackupService { } } + // 收集 epub_books 目录下的 epub 文件 + int epubCount = 0; + final epubRoot = path.join(appDir.path, 'epub_books'); + final epubDir = Directory(epubRoot); + if (await epubDir.exists()) { + await for (final entity in epubDir.list(recursive: true)) { + if (entity is File) { + final bytes = await entity.readAsBytes(); + final relativePath = entity.path.substring(epubRoot.length + 1); + archive.addFile(ArchiveFile('epub_books/$relativePath', bytes.length, bytes)); + epubCount++; + } + } + } + final zipBytes = ZipEncoder().encode(archive); if (zipBytes == null) throw Exception('压缩备份文件失败'); @@ -123,6 +138,7 @@ class BackupService { bookCount: books.length, noteCount: notes.length, imageCount: imageCount, + epubCount: epubCount, ); } @@ -187,6 +203,7 @@ class BackupService { bookCount: data.bookCount, noteCount: data.noteCount, imageCount: data.imageCount, + epubCount: data.epubCount, ); } catch (e) { return AutoBackupExportResult.error('导出失败: $e'); @@ -239,6 +256,13 @@ class BackupService { // 用完整相对路径做 key,避免不同目录下同名文件碰撞 imagePathMap[relativePath] = outputFile.path; imageCount++; + } else if (archiveFile.name.startsWith('epub_books/')) { + final relativePath = archiveFile.name.substring(12); + final epubDir = Directory(path.join(appDir.path, 'epub_books')); + if (!await epubDir.exists()) await epubDir.create(recursive: true); + final outputFile = File(path.join(epubDir.path, relativePath)); + if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true); + await outputFile.writeAsBytes(archiveFile.content as List); } } } else { @@ -354,6 +378,13 @@ class BackupService { await outputFile.writeAsBytes(archiveFile.content as List); imagePathMap[relativePath] = outputFile.path; imageCount++; + } else if (archiveFile.name.startsWith('epub_books/')) { + final relativePath = archiveFile.name.substring(12); + final epubDir = Directory(path.join(appDir.path, 'epub_books')); + if (!await epubDir.exists()) await epubDir.create(recursive: true); + final outputFile = File(path.join(epubDir.path, relativePath)); + if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true); + await outputFile.writeAsBytes(archiveFile.content as List); } } @@ -475,14 +506,21 @@ class BackupService { return map; } - /// 恢复 SharedPreferences(保留当前 avatarPath,因为已在上面用 imagePathMap 更新过) + /// 恢复 SharedPreferences(保留当前设备的同步和路径配置) Future _restoreSharedPrefs(Map data) async { final prefs = await SharedPreferences.getInstance(); + // 这些键是设备特定的,不应从备份恢复 + const skipKeys = { + 'avatarPath', + 'webdav_config', + 'webdav_last_sync', + 'webdav_auto_sync', + 'webdav_auto_sync_interval', + }; for (final entry in data.entries) { final key = entry.key; final value = entry.value; - // avatarPath 已单独处理,跳过 - if (key == 'avatarPath') continue; + if (skipKeys.contains(key)) continue; if (value is String) { await prefs.setString(key, value); } else if (value is int) { @@ -592,6 +630,7 @@ class _ExportData { final int bookCount; final int noteCount; final int imageCount; + final int epubCount; _ExportData({ required this.zipBytes, @@ -599,6 +638,7 @@ class _ExportData { required this.bookCount, required this.noteCount, required this.imageCount, + this.epubCount = 0, }); } @@ -612,6 +652,7 @@ class AutoBackupExportResult { final int bookCount; final int noteCount; final int imageCount; + final int epubCount; AutoBackupExportResult._({ required this.success, @@ -621,6 +662,7 @@ class AutoBackupExportResult { this.bookCount = 0, this.noteCount = 0, this.imageCount = 0, + this.epubCount = 0, }); factory AutoBackupExportResult.success({ @@ -629,11 +671,13 @@ class AutoBackupExportResult { required int bookCount, required int noteCount, required int imageCount, + int epubCount = 0, }) { return AutoBackupExportResult._( success: true, zipBytes: zipBytes, movieCount: movieCount, bookCount: bookCount, noteCount: noteCount, imageCount: imageCount, + epubCount: epubCount, ); } diff --git a/lib/utils/sync/webdav_service.dart b/lib/utils/sync/webdav_service.dart index 50dfe0f..570b3a6 100644 --- a/lib/utils/sync/webdav_service.dart +++ b/lib/utils/sync/webdav_service.dart @@ -57,6 +57,7 @@ class WebDAVService { Timer? _autoSyncTimer; bool _isAutoSyncEnabled = false; int _autoSyncIntervalMinutes = _defaultAutoSyncInterval; + bool _isSyncing = false; /// 获取配置 Future?> getConfig() async { @@ -195,8 +196,15 @@ class WebDAVService { /// 同步数据 — 完整备份 zip 格式,与本地备份完全一致 Future syncData({SyncDirection direction = SyncDirection.bidirectional}) async { + // 防止并发同步 + if (_isSyncing) { + return SyncResult(success: false, message: '同步正在进行中,请稍后再试'); + } + _isSyncing = true; + final config = await getConfig(); if (config == null) { + _isSyncing = false; return SyncResult(success: false, message: '未配置 WebDAV'); } @@ -255,27 +263,42 @@ class WebDAVService { } } else if (direction == SyncDirection.bidirectional) { - final tempDir = await getTemporaryDirectory(); - final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip')); + // 获取远程备份的修改时间 + final remoteModTime = await _getRemoteFileModifiedTime(client, zipUrl, username, password); - final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip); - if (downloadSuccess && await tempZip.exists()) { - final bytes = await tempZip.readAsBytes(); - final importResult = await BackupService.instance.restoreFromZipBytes(bytes); - await tempZip.delete(); + // 获取上次同步时间 + final syncPrefs = await SharedPreferences.getInstance(); + final lastSyncStr = syncPrefs.getString(_lastSyncKey); + final lastSyncTime = lastSyncStr != null ? DateTime.tryParse(lastSyncStr) : null; - if (importResult.success) { - downloadedFiles = 1; - downloadedImages = importResult.stats?['图片'] ?? 0; - needReload = true; - debugPrint('[WebDAV] 远程备份已恢复: ${importResult.statsText}'); + final bool remoteIsNewer = remoteModTime != null && + (lastSyncTime == null || remoteModTime.isAfter(lastSyncTime)); + + if (remoteIsNewer) { + // 远程更新,下载并恢复 + final tempDir = await getTemporaryDirectory(); + final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip')); + + final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip); + if (downloadSuccess && await tempZip.exists()) { + final bytes = await tempZip.readAsBytes(); + final importResult = await BackupService.instance.restoreFromZipBytes(bytes); + await tempZip.delete(); + + if (importResult.success) { + downloadedFiles = 1; + downloadedImages = importResult.stats?['图片'] ?? 0; + needReload = true; + debugPrint('[WebDAV] 远程备份较新,已恢复: ${importResult.statsText}'); + } + } else { + try { await tempZip.delete(); } catch (_) {} } } else { - try { await tempZip.delete(); } catch (_) {} - debugPrint('[WebDAV] 服务器无备份,仅上传本地数据'); + debugPrint('[WebDAV] 本地数据已是最新或远程无更新,跳过下载'); } - // 始终上传本地,确保远程是最新的 + // 上传本地备份(无论是否下载,确保远程有最新数据) final exportResult = await BackupService.instance.exportDataForAutoBackup(); if (exportResult.success && exportResult.zipBytes != null) { final uploadSuccess = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!); @@ -305,6 +328,8 @@ class WebDAVService { } } catch (e) { return SyncResult(success: false, message: '同步失败: $e'); + } finally { + _isSyncing = false; } } @@ -346,7 +371,11 @@ class WebDAVService { Duration(minutes: _autoSyncIntervalMinutes), (timer) async { if (_isAutoSyncEnabled) { - await syncData(direction: SyncDirection.bidirectional); + try { + await syncData(direction: SyncDirection.bidirectional); + } catch (e) { + debugPrint('[WebDAV] 自动同步异常: $e'); + } } }, ); @@ -456,6 +485,43 @@ class WebDAVService { } } + /// 获取远程文件的修改时间 + Future _getRemoteFileModifiedTime( + http.Client client, + String url, + String username, + String password, + ) async { + try { + var request = http.Request('HEAD', 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('HEAD', Uri.parse(location)); + request.headers['Authorization'] = _basicAuth(username, password); + response = await client.send(request); + } + } + + if (response.statusCode == 200) { + final lastModified = response.headers['last-modified']; + if (lastModified != null) { + return HttpDate.parse(lastModified); + } + } + return null; + } catch (e) { + debugPrint('[WebDAV] 获取远程文件时间失败: $e'); + return null; + } + } + /// Basic Auth 编码 String _basicAuth(String username, String password) { final credentials = base64Encode(utf8.encode('$username:$password')); diff --git a/lib/utils/theme/app_theme.dart b/lib/utils/theme/app_theme.dart index 127eb49..b57f701 100644 --- a/lib/utils/theme/app_theme.dart +++ b/lib/utils/theme/app_theme.dart @@ -84,9 +84,8 @@ class AppTheme { ), cardTheme: CardThemeData( color: scheme.surface, elevation: 0, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.zero, - side: BorderSide(color: scheme.outlineVariant, width: 0.5), ), margin: EdgeInsets.zero, ), diff --git a/lib/utils/toast_util.dart b/lib/utils/toast_util.dart index 4f22a17..1e9c304 100644 --- a/lib/utils/toast_util.dart +++ b/lib/utils/toast_util.dart @@ -22,7 +22,7 @@ class ToastUtil { child: Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), decoration: BoxDecoration( - color: const Color(0xFF1A1A1A).withOpacity(0.9), + color: const Color(0xFF1A1A1A).withValues(alpha: 0.9), borderRadius: BorderRadius.circular(24), ), child: Text( diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index e367a63..372928e 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -159,7 +159,7 @@ class UserPrefs { List get searchHistory => prefs.getStringList('searchHistory') ?? []; Future setSearchHistory(List value) => prefs.setStringList('searchHistory', value); - /// 添加搜索记录(最多 20 条) + /// 添加搜索记录(最多 50 条) Future addSearchHistory(String keyword) async { final list = searchHistory; list.remove(keyword); diff --git a/lib/widgets/book_list_item.dart b/lib/widgets/book_list_item.dart index e785892..4911bde 100644 --- a/lib/widgets/book_list_item.dart +++ b/lib/widgets/book_list_item.dart @@ -53,7 +53,6 @@ class BookListItem extends StatelessWidget { width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - border: Border.all(color: colors.outline, width: 0.5), ), clipBehavior: Clip.antiAlias, child: FadeInLocalImage( diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 400a12e..4a9bdf8 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -28,13 +28,13 @@ class CustomBottomNavBar extends StatelessWidget { borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(isDark ? 0.3 : 0.08), + color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.08), blurRadius: 20, offset: const Offset(0, 4), spreadRadius: 0, ), BoxShadow( - color: Colors.black.withOpacity(isDark ? 0.15 : 0.04), + color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.04), blurRadius: 8, offset: const Offset(0, 2), spreadRadius: -2, @@ -156,6 +156,7 @@ class CustomBottomNavBar extends StatelessWidget { void _showAddDialog(BuildContext context, AppProvider provider) { final colors = Theme.of(context).colorScheme; + final outerContext = context; showModalBottomSheet( context: context, backgroundColor: colors.surface, @@ -209,7 +210,7 @@ class CustomBottomNavBar extends StatelessWidget { }; final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch'; Navigator.pushNamed( - context, + outerContext, '/movie-form', arguments: {'initialStatus': currentStatus}, ); @@ -229,7 +230,7 @@ class CustomBottomNavBar extends StatelessWidget { }; final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read'; Navigator.pushNamed( - context, + outerContext, '/book-form', arguments: {'initialStatus': currentStatus}, ); @@ -242,7 +243,7 @@ class CustomBottomNavBar extends StatelessWidget { subtitle: '记录你的想法和笔记', onTap: () { Navigator.pop(context); - Navigator.pushNamed(context, '/note-form'); + Navigator.pushNamed(outerContext, '/note-form'); }, ), ], diff --git a/lib/widgets/movie_list_item.dart b/lib/widgets/movie_list_item.dart index ab4963d..c2ef078 100644 --- a/lib/widgets/movie_list_item.dart +++ b/lib/widgets/movie_list_item.dart @@ -53,7 +53,6 @@ class MovieListItem extends StatelessWidget { width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.outline, width: 0.5), ), clipBehavior: Clip.antiAlias, child: FadeInLocalImage( diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index c963807..28770ef 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -39,7 +39,6 @@ class _NoteListItemContent extends StatelessWidget { decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.outlineVariant, width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -61,7 +60,6 @@ class _NoteListItemContent extends StatelessWidget { decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(3), - border: Border.all(color: colors.outlineVariant, width: 0.5), ), child: Text( 'MD', @@ -157,7 +155,6 @@ class _NoteListItemContent extends StatelessWidget { decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(4), - border: Border.all(color: colors.outlineVariant, width: 0.5), ), child: Text( tag, diff --git a/lib/widgets/note_plus/note_plus_editor.dart b/lib/widgets/note_plus/note_plus_editor.dart index 244f47c..39e1097 100644 --- a/lib/widgets/note_plus/note_plus_editor.dart +++ b/lib/widgets/note_plus/note_plus_editor.dart @@ -56,6 +56,19 @@ class _NotePlusEditorState extends State { }); } + /// 清理已删除 block 对应的控制器和焦点节点 + void _cleanupStaleEntries(List currentBlocks) { + final currentIds = currentBlocks.map((b) => b.id).toSet(); + final staleIds = _controllers.keys.where((id) => !currentIds.contains(id)).toList(); + for (final id in staleIds) { + _controllers.remove(id)?.dispose(); + } + final staleFocusIds = _focusNodes.keys.where((id) => !currentIds.contains(id)).toList(); + for (final id in staleFocusIds) { + _focusNodes.remove(id)?.dispose(); + } + } + void _onControllerChanged(NoteBlock block) { final provider = context.read(); final idx = provider.blocks.indexWhere((b) => b.id == block.id); @@ -230,6 +243,9 @@ class _NotePlusEditorState extends State { builder: (context, provider, _) { final blocks = provider.blocks; + // 清理已删除 block 对应的控制器和焦点节点,防止内存泄漏 + _cleanupStaleEntries(blocks); + return ListView.builder( controller: _scrollController, itemCount: blocks.length,