From 6c30026e32d5134fd1b425d48022ee7a294cd9ba Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Mon, 13 Jul 2026 19:39:01 +0800 Subject: [PATCH] =?UTF-8?q?windows=E7=89=88=E6=9C=AC=E5=88=9D=E6=AD=A5?= =?UTF-8?q?=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/book/book_dao.dart | 2 +- lib/data/database_helper.dart | 13 +- lib/data/game/game_dao.dart | 2 +- lib/data/movie/movie_dao.dart | 2 +- lib/data/note/note_dao.dart | 2 +- lib/pages/book/book_detail_page.dart | 284 +- lib/pages/book/book_form_page.dart | 1 + lib/pages/book/book_tab_page.dart | 1 + lib/pages/epub_reader/epub_edit_page.dart | 10 +- lib/pages/game/game_detail_page.dart | 224 +- lib/pages/game/game_form_page.dart | 1 + lib/pages/game/game_tab_page.dart | 1 + lib/pages/home/home_page.dart | 3931 +++++++++++++++++ lib/pages/home/main_content_page.dart | 7 +- lib/pages/movies/movie_detail_page.dart | 243 +- lib/pages/movies/movie_form_page.dart | 1 + lib/pages/movies/movie_tab_page.dart | 1 + lib/pages/note/note_detail_page.dart | 134 +- lib/pages/note/note_tab_page.dart | 1 + lib/pages/online_search/book_detail_page.dart | 216 +- .../online_search/movie_detail_page.dart | 490 +- lib/pages/profile/profile_page.dart | 23 +- lib/pages/profile/settings_page.dart | 217 +- lib/pages/sync/backup_page.dart | 7 - lib/pages/sync/webdav_sync_page.dart | 6 - lib/services/epub/epub_service.dart | 13 +- lib/services/epub/epub_webview_handler.dart | 6 +- lib/services/font_download_manager.dart | 6 +- lib/services/sync/backup_service.dart | 62 +- lib/services/sync/cache_cleaner.dart | 151 +- lib/utils/image_path_helper.dart | 15 +- lib/utils/responsive.dart | 16 +- lib/widgets/add_sheet.dart | 108 +- lib/widgets/master_detail_scaffold.dart | 4 + windows/runner/main.cpp | 4 +- windows/runner/resources/app_icon.ico | Bin 33772 -> 9380 bytes windows/runner/win32_window.cpp | 2 +- 37 files changed, 5583 insertions(+), 624 deletions(-) diff --git a/lib/data/book/book_dao.dart b/lib/data/book/book_dao.dart index 0687ba5..37c16e6 100644 --- a/lib/data/book/book_dao.dart +++ b/lib/data/book/book_dao.dart @@ -45,7 +45,7 @@ class BookDao { switch (sortMode) { case 1: return 'created_at DESC'; case 2: return 'rating DESC NULLS LAST, updated_at DESC'; - default: return 'updated_at DESC'; + default: return 'created_at DESC'; } } diff --git a/lib/data/database_helper.dart b/lib/data/database_helper.dart index 335d62e..e00a8de 100644 --- a/lib/data/database_helper.dart +++ b/lib/data/database_helper.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:io'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:flutter/foundation.dart'; +import '../utils/image_path_helper.dart'; import '../models/data_models.dart'; /// 数据库帮助类 - 管理数据库的创建和版本控制 @@ -13,9 +15,14 @@ class DatabaseHelper { DatabaseHelper._init(); + /// 获取数据库根目录(统一使用 ImagePathHelper.getAppDir) + Future _getDbRootPath() async { + return await ImagePathHelper.getAppDir(); + } + /// 数据库文件路径 Future get databasePath async { - final path = await getDatabasesPath(); + final path = await _getDbRootPath(); return join(path, 'mooknote.db'); } @@ -67,8 +74,10 @@ class DatabaseHelper { } Future _initDB(String filePath) async { - final dbPath = await getDatabasesPath(); + final dbPath = await _getDbRootPath(); final path = join(dbPath, filePath); + // 确保目录存在 + await Directory(dbPath).create(recursive: true); return await openDatabase( path, diff --git a/lib/data/game/game_dao.dart b/lib/data/game/game_dao.dart index 208c02e..9321f0f 100644 --- a/lib/data/game/game_dao.dart +++ b/lib/data/game/game_dao.dart @@ -45,7 +45,7 @@ class GameDao { switch (sortMode) { case 1: return 'created_at DESC'; case 2: return 'rating DESC NULLS LAST, updated_at DESC'; - default: return 'updated_at DESC'; + default: return 'created_at DESC'; } } diff --git a/lib/data/movie/movie_dao.dart b/lib/data/movie/movie_dao.dart index 4bd48a2..7a5756b 100644 --- a/lib/data/movie/movie_dao.dart +++ b/lib/data/movie/movie_dao.dart @@ -49,7 +49,7 @@ class MovieDao { switch (sortMode) { case 1: return 'created_at DESC'; case 2: return 'rating DESC NULLS LAST, updated_at DESC'; - default: return 'updated_at DESC'; + default: return 'created_at DESC'; } } diff --git a/lib/data/note/note_dao.dart b/lib/data/note/note_dao.dart index 8e598df..6210f43 100644 --- a/lib/data/note/note_dao.dart +++ b/lib/data/note/note_dao.dart @@ -40,7 +40,7 @@ class NoteDao { switch (sortMode) { case 1: return 'is_pinned DESC, created_at DESC'; case 2: return 'is_pinned DESC, title COLLATE NOCASE ASC'; - default: return 'is_pinned DESC, updated_at DESC'; + default: return 'is_pinned DESC, created_at DESC'; } } diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 629498e..c836d29 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -8,6 +8,7 @@ import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/user_prefs.dart'; +import '../../utils/responsive.dart'; import 'book_reviews_page.dart'; import 'book_excerpts_page.dart'; import 'book_share_page.dart'; @@ -59,12 +60,273 @@ class _BookDetailPageState extends State { .where((b) => b.id == widget.book.id) .firstOrNull ?? widget.book; + if (Breakpoint.isDesktop(context)) { + return _buildDesktopStyle(book, colors); + } if (_detailStyle == 1) { return _buildOverlayStyle(book, colors); } return _buildStandardStyle(book, colors); } + /// 桌面端左右分栏布局 + Widget _buildDesktopStyle(Book book, ColorScheme colors) { + final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; + return Scaffold( + backgroundColor: colors.surface, + body: Column( + children: [ + // 顶栏 + Container( + height: 48, + decoration: BoxDecoration( + color: colors.surface, + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row(children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18), + onPressed: widget.embedded + ? () => context.read().selectBook(null) + : () => Navigator.pop(context), + ), + Expanded( + child: Text(book.title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 4), + ]), + ), + // 主体:左封面 + 右信息 + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 左侧封面 + Container( + width: 240, + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Container( + width: 200, + height: 280, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + boxShadow: hasCover + ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4))] + : null, + ), + clipBehavior: Clip.antiAlias, + child: hasCover + ? FadeInLocalImage(path: book.coverPath, fit: BoxFit.cover) + : Center(child: Icon(Icons.menu_book, size: 48, color: colors.onSurface.withValues(alpha: 0.25))), + ), + ], + ), + ), + // 右侧信息(可滚动) + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(0, 20, 24, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(book.title, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)), + if (book.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(book.alternateTitles.join(' / '), + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)), + ], + _buildEpubProgressBar(book), + const SizedBox(height: 16), + Row(children: [ + if (book.rating != null) ...[ + Icon(Icons.star, size: 20, color: colors.onSurface), + const SizedBox(width: 4), + Text(book.rating!.toStringAsFixed(1), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(width: 16), + ], + _buildStatusTag(book), + ]), + const SizedBox(height: 8), + Text('添加于 ${_formatDate(book.createdAt)}', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + Divider(height: 32, thickness: 0.5, color: colors.outline), + // 详细信息 + _buildDesktopInfoRow('作者', book.authors.join(','), colors), + if (book.translators.isNotEmpty) + _buildDesktopInfoRow('译者', book.translators.join(','), colors), + if (book.genres.isNotEmpty) ...[ + const SizedBox(height: 8), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 56, child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Wrap(spacing: 8, runSpacing: 8, + children: book.genres.map((g) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)), + child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + )).toList(), + )), + ]), + ], + if (book.isbn != null && book.isbn!.isNotEmpty) + _buildDesktopInfoRow('ISBN', book.isbn!, colors), + if (book.publisher != null && book.publisher!.isNotEmpty) + _buildDesktopInfoRow('出版社', book.publisher!, colors), + if (book.publishDate != null) + _buildDesktopInfoRow('出版时间', '${book.publishDate!.year}年${book.publishDate!.month.toString().padLeft(2, '0')}月', colors), + if (book.startDate != null || book.finishDate != null) ...[ + const SizedBox(height: 8), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 56, child: Text('阅读日期', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Wrap(spacing: 12, runSpacing: 8, children: [ + if (book.startDate != null) _buildDateChip('开始', book.startDate!, false), + if (book.finishDate != null) _buildDateChip('读完', book.finishDate!, false), + ])), + ]), + ], + if (book.summary != null && book.summary!.isNotEmpty) ...[ + Divider(height: 32, thickness: 0.5, color: colors.outline), + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 12), + Text(book.summary!, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)), + ], + Divider(height: 32, thickness: 0.5, color: colors.outline), + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 16), + _buildExtraSectionItem( + icon: Icons.rate_review_outlined, + title: '书评', + subtitleFuture: context.read().getBookReviewCount(book.id), + emptyText: '暂无书评', + unit: '条书评', + onTap: () => _navigateToReviews(book), + ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.format_quote_outlined, + title: '摘抄', + subtitleFuture: context.read().getBookExcerptCount(book.id), + emptyText: '暂无摘抄', + unit: '条摘抄', + onTap: () => _navigateToExcerpts(book), + ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.highlight_outlined, + title: '句读', + subtitleFuture: _getEpubHighlightCount(book.id), + emptyText: '暂无句读', + unit: '条句读', + onTap: () => _navigateToEpubHighlights(book), + ), + ], + ), + ), + ), + ], + ), + ), + // 底部操作栏 + Container( + height: 56, + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildEpubReadButtonBar(book, colors), + OutlinedButton.icon( + onPressed: () => _showDeleteDialog(context), + icon: Icon(Icons.delete_outline, size: 16, color: colors.error), + label: Text('删除', style: TextStyle(color: colors.error)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.error.withValues(alpha: 0.3)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: () => _navigateToEdit(context), + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 56, child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5))), + ], + ), + ); + } + + /// EPUB 阅读按钮(底部栏样式) + Widget _buildEpubReadButtonBar(Book book, ColorScheme colors) { + return FutureBuilder?>( + future: ReaderDao().getReaderBookByBookId(book.id), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == null) { + return const SizedBox.shrink(); + } + final readerBook = snapshot.data!; + return Row(children: [ + FilledButton.tonalIcon( + onPressed: () { + Navigator.push(context, MaterialPageRoute( + builder: (_) => ReaderScreen( + bookId: readerBook['id'] as String, + filePath: readerBook['file_path'] as String, + title: readerBook['title'] as String? ?? '', + coverPath: readerBook['cover_path'] as String?, + bookData: readerBook, + ), + )); + }, + icon: const Icon(Icons.auto_stories_outlined, size: 16), + label: const Text('EPUB 阅读'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF6750A4).withValues(alpha: 0.15), + foregroundColor: const Color(0xFF6750A4), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + ]); + }, + ); + } + /// 标准样式 Widget _buildStandardStyle(Book book, ColorScheme colors) { final topSafe = MediaQuery.of(context).padding.top; @@ -156,7 +418,7 @@ class _BookDetailPageState extends State { child: Row(children: [ const SizedBox(width: 4), IconButton( - icon: Icon(widget.embedded ? Icons.close : Icons.arrow_back_ios_new, color: Colors.white, size: 18), + icon: Icon(widget.embedded ? Icons.arrow_back : Icons.arrow_back_ios_new, color: Colors.white, size: 18), onPressed: widget.embedded ? () => context.read().selectBook(null) : () => Navigator.pop(context), @@ -289,14 +551,16 @@ class _BookDetailPageState extends State { ), const SizedBox(height: 12), _buildEpubReadButton(book), - const SizedBox(height: 12), - _buildFloatingButton( - icon: Icons.share_outlined, - onPressed: () => _showSharePoster(book), - tooltip: '分享海报', - backgroundColor: const Color(0xFF4CAF50), - foregroundColor: Colors.white, - ), + if (!Platform.isWindows) ...[ + const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.share_outlined, + onPressed: () => _showSharePoster(book), + tooltip: '分享海报', + backgroundColor: const Color(0xFF4CAF50), + foregroundColor: Colors.white, + ), + ], ], ); } @@ -378,7 +642,7 @@ class _BookDetailPageState extends State { child: Row(children: [ const SizedBox(width: 4), IconButton( - icon: Icon(widget.embedded ? Icons.close : Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), + icon: Icon(widget.embedded ? Icons.arrow_back : Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), onPressed: widget.embedded ? () => context.read().selectBook(null) : () => Navigator.pop(context), diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 9d1354e..d9f8dcb 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -649,6 +649,7 @@ class _BookFormPageState extends State { publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, createdAt: now, updatedAt: now, ); await context.read().addBook(newBook); + await context.read().loadBooks(); } else { final updatedBook = widget.book!.copyWith( title: _titleController.text.trim(), coverPath: _coverPath, diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index 270ccf2..89042b4 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -52,6 +52,7 @@ class _BookTabPageState extends State { _layoutStyle = UserPrefs().bookLayoutStyle; _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; final provider = context.read(); _provider = provider; provider.addListener(_onDataChanged); diff --git a/lib/pages/epub_reader/epub_edit_page.dart b/lib/pages/epub_reader/epub_edit_page.dart index 0fc686d..5ad6090 100644 --- a/lib/pages/epub_reader/epub_edit_page.dart +++ b/lib/pages/epub_reader/epub_edit_page.dart @@ -5,13 +5,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../data/epub/reader_dao.dart'; import '../../widgets/genre_selector_page.dart'; import '../../widgets/text_input_panel.dart'; +import '../../utils/image_path_helper.dart'; /// EPUB 书籍编辑页 class EpubEditPage extends StatefulWidget { @@ -93,8 +93,8 @@ class _EpubEditPageState extends State { final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); if (picked == null || !mounted) return; - final appDir = await getApplicationDocumentsDirectory(); - final bookDir = Directory(p.join(appDir.path, 'epub_books', widget.bookId)); + final appDirPath = await ImagePathHelper.getAppDir(); + final bookDir = Directory(p.join(appDirPath, 'epub_books', widget.bookId)); if (!await bookDir.exists()) await bookDir.create(recursive: true); final existing = bookDir.listSync().whereType().where((f) { @@ -122,8 +122,8 @@ class _EpubEditPageState extends State { } Future _revertCover() async { - final appDir = await getApplicationDocumentsDirectory(); - final bookDir = Directory(p.join(appDir.path, 'epub_books', widget.bookId)); + final appDirPath = await ImagePathHelper.getAppDir(); + final bookDir = Directory(p.join(appDirPath, 'epub_books', widget.bookId)); final existing = bookDir.listSync().whereType().where((f) { final name = p.basenameWithoutExtension(f.path); diff --git a/lib/pages/game/game_detail_page.dart b/lib/pages/game/game_detail_page.dart index dd47df8..9770889 100644 --- a/lib/pages/game/game_detail_page.dart +++ b/lib/pages/game/game_detail_page.dart @@ -8,6 +8,7 @@ import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/user_prefs.dart'; import '../../utils/toast_util.dart'; +import '../../utils/responsive.dart'; import 'game_reviews_page.dart'; import 'game_screenshots_page.dart'; import 'game_share_page.dart'; @@ -73,11 +74,212 @@ class _GameDetailPageState extends State { .where((g) => g.id == widget.game.id) .firstOrNull ?? widget.game; + if (Breakpoint.isDesktop(context)) { + return _buildDesktopStyle(game, colors); + } return _detailStyle == 1 ? _buildOverlayStyle(game, colors) : _buildStandardStyle(game, colors); } + /// 桌面端左右分栏布局 + Widget _buildDesktopStyle(Game game, ColorScheme colors) { + final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty; + return Scaffold( + backgroundColor: colors.surface, + body: Column( + children: [ + // 顶栏 + Container( + height: 48, + decoration: BoxDecoration( + color: colors.surface, + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row(children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18), + onPressed: widget.embedded + ? () => context.read().selectGame(null) + : () => Navigator.pop(context), + ), + Expanded( + child: Text(game.title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 4), + ]), + ), + // 主体:左封面 + 右信息 + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 左侧封面 + Container( + width: 240, + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Container( + width: 200, + height: 280, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + boxShadow: hasCover + ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4))] + : null, + ), + clipBehavior: Clip.antiAlias, + child: hasCover + ? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover) + : Center(child: Icon(Icons.sports_esports_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.25))), + ), + ], + ), + ), + // 右侧信息(可滚动) + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(0, 20, 24, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(game.title, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)), + const SizedBox(height: 16), + Row(children: [ + if (game.rating != null) ...[ + Icon(Icons.star, size: 20, color: colors.onSurface), + const SizedBox(width: 4), + Text(game.rating!.toStringAsFixed(1), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(width: 16), + ], + _buildStatusTag(game), + const SizedBox(width: 6), + _buildCategoryTag(game), + ]), + Divider(height: 32, thickness: 0.5, color: colors.outline), + // 详细信息 + if (game.platforms.isNotEmpty) + _buildDesktopInfoRow('平台', game.platforms.join('、'), colors), + if (game.versions.isNotEmpty) + _buildDesktopInfoRow('版本', game.versions.join('、'), colors), + if (game.genres.isNotEmpty) ...[ + const SizedBox(height: 8), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 56, child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Wrap(spacing: 8, runSpacing: 8, + children: game.genres.map((g) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)), + child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + )).toList(), + )), + ]), + ], + if (game.playTimeHours > 0 || game.playTimeMinutes > 0) + _buildDesktopInfoRow('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟', colors), + if (game.purchasePlatforms.isNotEmpty) + _buildDesktopInfoRow('购买平台', game.purchasePlatforms.join('、'), colors), + if (game.purchaseDate != null) + _buildDesktopInfoRow('购买时间', _formatDate(game.purchaseDate!), colors), + if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) + _buildDesktopInfoRow('购买价格', game.purchasePrice!, colors), + if (game.summary != null && game.summary!.isNotEmpty) ...[ + Divider(height: 32, thickness: 0.5, color: colors.outline), + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('游戏简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 12), + Text(game.summary!, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)), + ], + Divider(height: 32, thickness: 0.5, color: colors.outline), + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 16), + _buildExtraSectionItem( + icon: Icons.rate_review_outlined, + title: '游戏评价', + subtitleFuture: context.read().getGameReviewCount(game.id), + emptyText: '暂无评价', + unit: '条评价', + onTap: () => _navigateToReviews(game), + ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.photo_library_outlined, + title: '游戏截图', + subtitleFuture: context.read().getGameScreenshotCount(game.id), + emptyText: '暂无截图', + unit: '张截图', + onTap: () => _navigateToScreenshots(game), + ), + ], + ), + ), + ), + ], + ), + ), + // 底部操作栏 + Container( + height: 56, + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: () => _showDeleteDialog(context), + icon: Icon(Icons.delete_outline, size: 16, color: colors.error), + label: Text('删除', style: TextStyle(color: colors.error)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.error.withValues(alpha: 0.3)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: () => _navigateToEdit(context), + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 56, child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5))), + ], + ), + ); + } + Widget _buildStandardStyle(Game game, ColorScheme colors) { final topSafe = MediaQuery.of(context).padding.top; return Scaffold( @@ -131,7 +333,7 @@ class _GameDetailPageState extends State { const SizedBox(width: 4), IconButton( icon: widget.embedded - ? Icon(Icons.close, color: colors.onSurface, size: 18) + ? Icon(Icons.arrow_back, color: colors.onSurface, size: 18) : Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), onPressed: widget.embedded ? () => context.read().selectGame(null) @@ -204,7 +406,7 @@ class _GameDetailPageState extends State { const SizedBox(width: 4), IconButton( icon: widget.embedded - ? const Icon(Icons.close, color: Colors.white, size: 18) + ? const Icon(Icons.arrow_back, color: Colors.white, size: 18) : const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), onPressed: widget.embedded ? () => context.read().selectGame(null) @@ -466,14 +668,16 @@ class _GameDetailPageState extends State { backgroundColor: colors.error, foregroundColor: colors.onError, ), - const SizedBox(height: 12), - _buildFloatingButton( - icon: Icons.share_outlined, - onPressed: () => _showSharePoster(game), - tooltip: '分享海报', - backgroundColor: const Color(0xFF4CAF50), - foregroundColor: Colors.white, - ), + if (!Platform.isWindows) ...[ + const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.share_outlined, + onPressed: () => _showSharePoster(game), + tooltip: '分享海报', + backgroundColor: const Color(0xFF4CAF50), + foregroundColor: Colors.white, + ), + ], ], ); } diff --git a/lib/pages/game/game_form_page.dart b/lib/pages/game/game_form_page.dart index d44d60f..ed1397c 100644 --- a/lib/pages/game/game_form_page.dart +++ b/lib/pages/game/game_form_page.dart @@ -1115,6 +1115,7 @@ class _GameFormPageState extends State { ); await context.read().addGame(newGame); + await context.read().loadGames(); } else { final updatedGame = widget.game!.copyWith( title: _titleController.text.trim(), diff --git a/lib/pages/game/game_tab_page.dart b/lib/pages/game/game_tab_page.dart index e08bdaf..ae169c5 100644 --- a/lib/pages/game/game_tab_page.dart +++ b/lib/pages/game/game_tab_page.dart @@ -43,6 +43,7 @@ class _GameTabPageState extends State { super.initState(); _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; final provider = context.read(); _provider = provider; provider.addListener(_onDataChanged); diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 1a4e3b9..97c5e79 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -1,12 +1,28 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; +import '../../models/data_models.dart'; import '../../utils/user_prefs.dart'; import '../../utils/responsive.dart'; +import '../../utils/toast_util.dart'; import '../../widgets/custom_drawer.dart'; import '../../widgets/bottom_nav_bar.dart'; import '../../widgets/add_sheet.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../pages/epub_reader/epub_library_page.dart'; +import '../../pages/movies/movie_detail_page.dart'; +import '../../pages/book/book_detail_page.dart'; +import '../../pages/note/note_detail_page.dart'; +import '../../services/sync/backup_service.dart'; +import '../../services/sync/webdav_service.dart'; +import '../../pages/profile/settings_page.dart'; import 'main_content_page.dart'; +import '../online_search/search_page.dart'; +import '../online_search/online_search_page.dart'; import '../profile/profile_page.dart'; /// 主页 - 包含底部导航,可切换主页/我的 @@ -59,12 +75,52 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { + if (Breakpoint.isDesktop(context)) { + return _buildDesktopLayout(context); + } if (Breakpoint.isTablet(context)) { return _buildTabletLayout(context); } return _buildPhoneLayout(context); } + // ─── 桌面布局(三栏:图标导航 | 列表面板 | 内容区) ────── + + Widget _buildDesktopLayout(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Consumer( + builder: (context, provider, child) { + _onNavIndexChanged(provider); + return Scaffold( + body: Row( + children: [ + // 第一栏:图标导航 + _DesktopIconRail( + mainTabIndex: provider.mainTabIndex, + onTabSelected: (index) { + provider.setMainTabIndex(index); + }, + ), + VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant), + // 第二栏:列表面板(搜索 + 列表) + SizedBox( + width: 300, + child: _DesktopListPanel( + mainTabIndex: provider.mainTabIndex, + ), + ), + VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant), + // 第三栏:内容区 + Expanded(child: _buildPageView(provider)), + ], + ), + ); + }, + ); + } + + // ─── 平板布局 ────────────────────────────────────────── + Widget _buildTabletLayout(BuildContext context) { final colors = Theme.of(context).colorScheme; return Consumer( @@ -115,6 +171,8 @@ class _HomePageState extends State { ); } + // ─── 手机布局 ────────────────────────────────────────── + Widget _buildPhoneLayout(BuildContext context) { return Scaffold( drawer: context.watch().bottomNavIndex != 1 @@ -225,3 +283,3876 @@ class _HomePageState extends State { ); } } + +// ─── 第一栏:图标导航栏 ────────────────────────────────── + +class _DesktopIconRail extends StatelessWidget { + final int mainTabIndex; + final ValueChanged onTabSelected; + + const _DesktopIconRail({ + required this.mainTabIndex, + required this.onTabSelected, + }); + + static const _categoryMeta = [ + (Icons.movie_outlined, Icons.movie, '影视', 0, Color(0xFF2563EB)), + (Icons.menu_book_outlined, Icons.menu_book, '阅读', 1, Color(0xFF16A34A)), + (Icons.note_outlined, Icons.note, '笔记', 2, Color(0xFF9333EA)), + (Icons.sports_esports_outlined, Icons.sports_esports, '游戏', 3, Color(0xFFEA580C)), + ]; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final userPrefs = UserPrefs(); + + final tabs = _categoryMeta.where((t) { + switch (t.$4) { + case 0: return userPrefs.showMovieTab; + case 1: return userPrefs.showBookTab; + case 2: return userPrefs.showNoteTab; + case 3: return userPrefs.showGameTab; + default: return false; + } + }).toList(); + + return Material( + color: colors.surface, + child: SizedBox( + width: 160, + child: Column( + children: [ + SizedBox(height: MediaQuery.of(context).padding.top + 8), + // 头像 + 昵称 + 座右铭 + _buildProfileHeader(context), + const SizedBox(height: 10), + // 添加 + 搜索 + _IconRailItem(icon: Icons.add_circle_outline, activeIcon: Icons.add_circle, label: '添加', accentColor: colors.primary, selected: false, onTap: () => showAddSheet(context, context.read())), + _IconRailItem(icon: Icons.search, activeIcon: Icons.search, label: '搜索', accentColor: colors.primary, selected: false, onTap: () => _showSearchDialog(context)), + const SizedBox(height: 2), + // 分类图标 + ...tabs.map((t) { + final selected = mainTabIndex == t.$4; + return _IconRailItem( + icon: t.$1, + activeIcon: t.$2, + label: t.$3, + accentColor: t.$5, + selected: selected, + onTap: () => onTabSelected(t.$4), + ); + }), + const Divider(height: 24, indent: 12, endIndent: 12), + // 探索 + 工具(可滚动) + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _IconRailItem(icon: Icons.favorite_border, activeIcon: Icons.favorite, label: '统计', accentColor: colors.primary, selected: false, onTap: () => _showEncounterDialog(context)), + _IconRailItem(icon: Icons.explore_outlined, activeIcon: Icons.explore, label: '漫步', accentColor: colors.primary, selected: false, onTap: () => _showStrollDialog(context)), + _IconRailItem(icon: Icons.calendar_month_outlined, activeIcon: Icons.calendar_month, label: '日历', accentColor: colors.primary, selected: false, onTap: () => _showCalendarDialog(context)), + const Divider(height: 24, indent: 12, endIndent: 12), + _IconRailItem(icon: Icons.people_outline, activeIcon: Icons.people, label: '角色', accentColor: colors.primary, selected: false, onTap: () => _showPersonDialog(context)), + _IconRailItem(icon: Icons.label_outline, activeIcon: Icons.label, label: '标签', accentColor: colors.primary, selected: false, onTap: () => _showTagDialog(context)), + _IconRailItem(icon: Icons.auto_stories_outlined, activeIcon: Icons.auto_stories, label: 'EPUB', accentColor: colors.primary, selected: false, onTap: () => _push(context, const EpubLibraryPage())), + _IconRailItem(icon: Icons.backup_outlined, activeIcon: Icons.backup, label: '备份', accentColor: colors.primary, selected: false, onTap: () => _showBackupDialog(context)), + _IconRailItem(icon: Icons.delete_outline, activeIcon: Icons.delete, label: '回收', accentColor: colors.primary, selected: false, onTap: () => _showRecycleBinDialog(context)), + _IconRailItem(icon: Icons.feedback_outlined, activeIcon: Icons.feedback, label: '反馈', accentColor: colors.primary, selected: false, onTap: () => _showFeedbackDialog(context)), + ], + ), + ), + ), + // 设置(固定底部) + _IconRailItem(icon: Icons.settings_outlined, activeIcon: Icons.settings, label: '设置', accentColor: colors.primary, selected: false, onTap: () => _push(context, const SettingsPage())), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _buildProfileHeader(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final userPrefs = UserPrefs(); + final avatarPath = userPrefs.avatarPath; + final nickname = userPrefs.nickname; + final motto = userPrefs.motto; + return GestureDetector( + onTap: () => _push(context, const SettingsPage()), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.surfaceContainerHighest, + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + clipBehavior: Clip.antiAlias, + child: avatarPath != null && avatarPath.isNotEmpty + ? FadeInLocalImage(path: avatarPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.person_outline, size: 16, color: colors.onSurface.withValues(alpha: 0.3))) + : Icon(Icons.person_outline, size: 16, color: colors.onSurface.withValues(alpha: 0.3)), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(nickname, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(height: 1), + Text(motto, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + ], + ), + ), + ); + } + + void _push(BuildContext context, Widget page) { + Navigator.push(context, MaterialPageRoute(builder: (_) => page)); + } + + void _showSearchDialog(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final dialogHeight = (MediaQuery.of(context).size.height * 0.82).clamp(560.0, 820.0); + showDialog( + context: context, + builder: (dialogCtx) => Dialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: SizedBox( + width: 640, + height: dialogHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: _SearchDialog(dialogContext: dialogCtx), + ), + ), + ), + ); + } + + void _showFeedbackDialog(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final email = 'dellevin99@gmail.com'; + final qqGroup = '1087203310'; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + title: Text('BUG反馈', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _feedbackRow(ctx, Icons.email_outlined, '作者邮箱', email, colors), + const SizedBox(height: 12), + _feedbackRow(ctx, Icons.group_outlined, 'QQ 群', qqGroup, colors), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('关闭')), + ], + ), + ); + } + + Widget _feedbackRow(BuildContext ctx, IconData icon, String title, String value, ColorScheme colors) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: colors.primary.withValues(alpha: 0.8)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 1), + Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + ], + ), + ), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + ToastUtil.show(ctx, '已复制'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Text('复制', style: TextStyle(fontSize: 12, color: colors.primary, fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ); + } + void _showBackupDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => const _BackupChoiceDialog(), + ); + } + + void _showEncounterDialog(BuildContext context) { + showDialog(context: context, builder: (_) => const _EncounterDialog()); + } + + void _showStrollDialog(BuildContext context) { + Navigator.of(context).push(_NoSwipeDialogRoute(builder: (_) => const _StrollDialog())); + } + + void _showCalendarDialog(BuildContext context) { + showDialog(context: context, builder: (_) => const _CalendarDialog()); + } + + void _showPersonDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => const _PersonListDialog(), + ); + } + + void _showTagDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => const _TagManagementDialog(), + ); + } + + void _showRecycleBinDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => const _RecycleBinDialog(), + ); + } +} + +// ─── 统计弹窗 ────────────────────────────────────────── + +class _EncounterDialog extends StatelessWidget { + const _EncounterDialog(); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final userPrefs = UserPrefs(); + final firstUse = userPrefs.firstUseDate; + final now = DateTime.now(); + final days = DateTime(now.year, now.month, now.day) + .difference(DateTime(firstUse.year, firstUse.month, firstUse.day)) + .inDays + 1; + + return AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + title: const Text('统计'), + content: SizedBox( + width: 400, + height: 340, + child: Consumer( + builder: (context, provider, child) { + final movies = provider.movies.where((m) => !m.isDeleted).toList(); + final books = provider.books.where((b) => !b.isDeleted).toList(); + final notes = provider.notes.where((n) => !n.isDeleted).toList(); + + final noteWords = notes.fold(0, (sum, n) => sum + n.content.length); + int imageCount = 0; + for (final m in movies) { if (m.posterPath != null && m.posterPath!.isNotEmpty) imageCount++; } + for (final b in books) { if (b.coverPath != null && b.coverPath!.isNotEmpty) imageCount++; } + for (final n in notes) { imageCount += n.images.length; } + final totalRecords = movies.length + books.length + notes.length; + + return Column( + children: [ + // 相遇天数 + const SizedBox(height: 16), + Text('与你', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: colors.onSurface, letterSpacing: 4)), + const SizedBox(height: 8), + RichText(text: TextSpan(children: [ + TextSpan(text: '相遇的第', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w400, color: colors.onSurface.withValues(alpha: 0.5))), + TextSpan(text: '$days', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: colors.primary)), + TextSpan(text: '天', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w400, color: colors.onSurface.withValues(alpha: 0.5))), + ]), textAlign: TextAlign.center), + const SizedBox(height: 4), + Text('${firstUse.year}年${firstUse.month}月${firstUse.day}日 — ${now.year}年${now.month}月${now.day}日', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + const Spacer(), + Divider(color: colors.outlineVariant, thickness: 0.5), + const SizedBox(height: 16), + Text('已记录', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5), letterSpacing: 2)), + const SizedBox(height: 12), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + _recordItem(context, '$totalRecords', '条记录', colors), + _recordItem(context, _formatCount(noteWords), '文字', colors), + _recordItem(context, '$imageCount', '张图片', colors), + ]), + const SizedBox(height: 16), + Divider(color: colors.outlineVariant, thickness: 0.5), + const SizedBox(height: 16), + // 城市天际线动画 + SizedBox(height: 52, child: _CityScape(colors: colors)), + const SizedBox(height: 8), + ], + ); + }, + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭')), + ], + ); + } + + String _formatCount(int count) { + if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}万'; + if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k'; + return '$count'; + } + + Widget _recordItem(BuildContext context, String value, String label, ColorScheme colors) { + return Column(children: [ + Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.primary)), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ]); + } +} + +// ─── 城市天际线动画 ────────────────────────────────────── + +class _CityScape extends StatefulWidget { + final ColorScheme colors; + const _CityScape({required this.colors}); + + @override + State<_CityScape> createState() => _CityScapeState(); +} + +class _CityScapeState extends State<_CityScape> with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(seconds: 20))..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) => CustomPaint(size: const Size(double.infinity, 52), painter: _CityPainter(_controller.value, widget.colors)), + ); + } +} + +class _CityPainter extends CustomPainter { + final double t; + final ColorScheme colors; + _CityPainter(this.t, this.colors); + + static const _buildings = <(double, double, bool)>[ + (0.30, 16, false), (0.48, 10, true), (0.22, 20, false), (0.55, 10, true), + (0.35, 14, false), (0.42, 10, true), (0.25, 18, false), + ]; + static const _plants = <(double, double, int)>[ + (0.35, 10, 0), (0.50, 8, 1), (0.22, 14, 2), (0.45, 8, 0), + (0.30, 12, 1), (0.20, 10, 2), (0.52, 8, 1), (0.28, 14, 2), + ]; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..style = PaintingStyle.fill; + final groundY = size.height - 2; + _drawStars(canvas, paint, size, t); + _drawBuildings(canvas, paint, size, groundY, t * 0.6); + _drawPlants(canvas, paint, size, groundY, t * 1.0); + paint.color = colors.onSurface.withValues(alpha: 0.10); + canvas.drawRect(Rect.fromLTWH(0, groundY, size.width, 1), paint); + } + + void _drawBuildings(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) { + double totalW = 0; + for (final b in _buildings) { totalW += b.$2 + 4; } + final offset = (scrollT * totalW) % totalW; + double x = -offset; + int i = 0; + while (x < size.width + 20) { + final (hR, w, spire) = _buildings[i % _buildings.length]; + final h = hR * (size.height - 8); + final bx = x; + final by = groundY - h; + if (bx + w > -10 && bx < size.width + 10) { + final a = 0.06 + hR * 0.05; + paint.color = colors.onSurface.withValues(alpha: a); + canvas.drawRect(Rect.fromLTWH(bx, by, w, h), paint); + if (h > 18) { + paint.color = colors.onSurface.withValues(alpha: 0.04); + for (int r = 0; r < ((h - 6) / 5).floor(); r++) { + for (int c = 0; c < ((w - 4) / 4).floor(); c++) { + if ((i * 13 + r * 7 + c * 11) % 4 == 0) continue; + canvas.drawRect(Rect.fromLTWH(bx + 3 + c * 4.0, by + 4 + r * 5.0, 2, 2), paint); + } + } + } + if (spire) { + paint.color = colors.onSurface.withValues(alpha: a); + final sh = h * 0.18; + canvas.drawPath(Path()..moveTo(bx + w / 2 - 2, by)..lineTo(bx + w / 2, by - sh)..lineTo(bx + w / 2 + 2, by)..close(), paint); + } + if (!spire && hR > 0.4 && i % 3 == 0) { + paint.color = colors.onSurface.withValues(alpha: a * 0.5); + canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 0.5, by - 6, 1, 6), paint); + canvas.drawCircle(Offset(bx + w / 2, by - 6), 1.2, paint); + } + } + x += w + 16; + i++; + } + } + + void _drawPlants(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) { + double totalW = 0; + for (final p in _plants) { totalW += p.$2 + 6; } + final offset = (scrollT * totalW) % totalW; + double x = -offset; + int i = 0; + while (x < size.width + 20) { + final (hR, w, type) = _plants[i % _plants.length]; + final h = hR * (size.height - 10); + final bx = x; + final by = groundY; + if (bx + w > -10 && bx < size.width + 10) { + final alpha = 0.18 + hR * 0.10; + if (type == 0) { + final trunkH = h * 0.4; + final crownR = w * 0.45; + paint.color = colors.onSurface.withValues(alpha: alpha * 0.7); + canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint); + paint.color = colors.onSurface.withValues(alpha: alpha); + canvas.drawOval(Rect.fromCenter(center: Offset(bx + w / 2, by - trunkH - crownR * 0.6), width: crownR * 2, height: crownR * 1.6), paint); + } else if (type == 1) { + final trunkH = h * 0.25; + paint.color = colors.onSurface.withValues(alpha: alpha * 0.7); + canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint); + paint.color = colors.onSurface.withValues(alpha: alpha); + for (int layer = 0; layer < 3; layer++) { + final layerW = w * (1.0 - layer * 0.2); + final layerBottom = by - trunkH - layer * (h * 0.2); + final layerTop = layerBottom - h * 0.28; + canvas.drawPath(Path()..moveTo(bx + w / 2 - layerW / 2, layerBottom)..lineTo(bx + w / 2, layerTop)..lineTo(bx + w / 2 + layerW / 2, layerBottom)..close(), paint); + } + } else { + paint.color = colors.onSurface.withValues(alpha: alpha); + canvas.drawOval(Rect.fromLTWH(bx, by - h, w, h), paint); + paint.color = colors.onSurface.withValues(alpha: alpha * 0.8); + canvas.drawOval(Rect.fromLTWH(bx + w * 0.2, by - h * 0.7, w * 0.6, h * 0.6), paint); + } + } + x += w + 14; + i++; + } + } + + void _drawStars(Canvas canvas, Paint paint, Size size, double t) { + const stars = [ + (12.0, 5.0, 1.2), (38.0, 12.0, 0.8), (65.0, 3.0, 1.0), + (95.0, 16.0, 1.4), (130.0, 7.0, 0.9), (165.0, 14.0, 1.1), + (200.0, 4.0, 1.3), (235.0, 18.0, 0.7), (270.0, 9.0, 1.0), + (310.0, 2.0, 1.2), (345.0, 15.0, 0.9), (380.0, 6.0, 1.1), + (420.0, 11.0, 0.8), (460.0, 3.0, 1.0), (500.0, 17.0, 1.3), + ]; + for (int i = 0; i < stars.length; i++) { + final (sx, sy, r) = stars[i]; + if (sx > size.width) continue; + final flicker = 0.15 + 0.12 * sin(t * 2 * pi + i * 1.1); + paint.color = colors.onSurface.withValues(alpha: flicker); + canvas.drawCircle(Offset(sx, sy), r, paint); + } + } + + @override + bool shouldRepaint(_CityPainter old) => old.t != t; +} + +// ─── 不拦截滑动手势的弹窗路由 ────────────────────────────── + +class _NoSwipeDialogRoute extends PageRoute { + final WidgetBuilder builder; + + _NoSwipeDialogRoute({required this.builder}); + + @override + bool get popGestureEnabled => false; // 关键:禁止拖拽关闭,不拦截水平手势 + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => Colors.black54; + + @override + String? get barrierLabel => 'Dismiss'; + + @override + Duration get transitionDuration => const Duration(milliseconds: 200); + + @override + Duration get reverseTransitionDuration => const Duration(milliseconds: 150); + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return builder(context); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return FadeTransition(opacity: animation, child: child); + } + + @override + bool get opaque => false; + + @override + bool get maintainState => true; +} + +// ─── 漫步弹窗 ────────────────────────────────────────── + +class _StrollDialog extends StatefulWidget { + const _StrollDialog(); + + @override + State<_StrollDialog> createState() => _StrollDialogState(); +} + +class _StrollDialogState extends State<_StrollDialog> { + final _random = Random(); + final List<_StrollItem> _items = []; + final Set _seenIds = {}; + late PageController _pageController; + String _filter = 'all'; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + _loadBatch(5); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _loadBatch(int count) { + final provider = context.read(); + final moviePool = <_StrollItem>[]; + final bookPool = <_StrollItem>[]; + final notePool = <_StrollItem>[]; + if (_filter == 'all' || _filter == 'movie') { + for (final m in provider.movies.where((m) => !m.isDeleted)) { + moviePool.add(_StrollItem(type: 'movie', data: m, id: 'm_${m.id}', title: m.title, + subtitle: m.alternateTitles.take(2).join(' / '), detail: _movieDetail(m), imagePath: m.posterPath, + icon: Icons.movie_outlined, label: '影视', rating: m.rating, createdAt: m.createdAt, + tags: m.genres.take(3).toList(), color: const Color(0xFF4A90D9))); + } + } + if (_filter == 'all' || _filter == 'book') { + for (final b in provider.books.where((b) => !b.isDeleted)) { + bookPool.add(_StrollItem(type: 'book', data: b, id: 'b_${b.id}', title: b.title, + subtitle: b.authors.take(2).join(' / '), detail: _bookDetail(b), imagePath: b.coverPath, + icon: Icons.menu_book_outlined, label: '书籍', rating: b.rating, createdAt: b.createdAt, + tags: b.genres.take(3).toList(), color: const Color(0xFF7E57C2))); + } + } + if (_filter == 'all' || _filter == 'note') { + for (final n in provider.notes.where((n) => !n.isDeleted)) { + notePool.add(_StrollItem(type: 'note', data: n, id: 'n_${n.id}', title: n.title.isNotEmpty ? n.title : '随手记', + subtitle: n.tags.take(3).join(' · '), detail: n.content, imagePath: n.images.isNotEmpty ? n.images.first : null, + icon: Icons.note_outlined, label: '笔记', createdAt: n.createdAt, + tags: n.tags.take(3).toList(), color: const Color(0xFF66BB6A))); + } + } + final pools = >[]; + if (moviePool.isNotEmpty) pools.add(moviePool); + if (bookPool.isNotEmpty) pools.add(bookPool); + if (notePool.isNotEmpty) pools.add(notePool); + if (pools.isEmpty) return; + final target = _items.length + count; + int attempts = 0; + while (_items.length < target && attempts < count * 20) { + attempts++; + final pool = _filter == 'all' ? pools[_random.nextInt(pools.length)] : pools.first; + final item = _weightedPick(pool); + if (item != null && !_seenIds.contains(item.id)) { _seenIds.add(item.id); _items.add(item); } + } + } + + _StrollItem? _weightedPick(List<_StrollItem> pool) { + if (pool.isEmpty) return null; + final weights = pool.map((item) => (item.rating ?? 5.0).clamp(1.0, 10.0)).toList(); + final total = weights.reduce((a, b) => a + b); + var roll = _random.nextDouble() * total; + for (int i = 0; i < pool.length; i++) { roll -= weights[i]; if (roll <= 0) return pool[i]; } + return pool.last; + } + + void _reshuffle() { setState(() { _items.clear(); _seenIds.clear(); _loadBatch(5); }); } + + String _movieDetail(Movie m) { + if (m.summary != null && m.summary!.isNotEmpty) return m.summary!.length > 100 ? '${m.summary!.substring(0, 100)}...' : m.summary!; + return ''; + } + + String _bookDetail(Book b) { + final parts = []; + if (b.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!); + if (b.summary != null && b.summary!.isNotEmpty) parts.add(b.summary!.length > 100 ? '${b.summary!.substring(0, 100)}...' : b.summary!); + return parts.join('\n'); + } + + String _timeAgoText(DateTime date) { + final diff = DateTime.now().difference(date); + if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前'; + if (diff.inDays >= 30) return '${(diff.inDays / 30).floor()}个月前'; + if (diff.inDays > 0) return '${diff.inDays}天前'; + if (diff.inHours > 0) return '${diff.inHours}小时前'; + return '刚刚'; + } + + String _actionVerb(String type) { + switch (type) { + case 'movie': return '看过'; + case 'book': return '读过'; + case 'note': return '写下'; + default: return ''; + } + } + + void _openDetail(_StrollItem item) { + // 先 pop 弹窗,再 push 详情页 + final navigator = Navigator.of(context); + navigator.pop(); + switch (item.type) { + case 'movie': navigator.push(MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); + case 'book': navigator.push(MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); + case 'note': navigator.push(MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as Note))); + } + } + + void _deleteItem(_StrollItem item) async { + final provider = context.read(); + switch (item.type) { + case 'movie': await provider.removeMovie(item.data.id); + case 'book': await provider.removeBook(item.data.id); + case 'note': await provider.removeNote(item.data.id); + } + setState(() => _items.remove(item)); + if (mounted) ToastUtil.show(context, '已删除'); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Dialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: SizedBox( + width: 580, + height: 520, + child: Column( + children: [ + // 标题栏 + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), + child: Row(children: [ + const Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const Spacer(), + ...[('all', '全部'), ('movie', '影视'), ('book', '书籍'), ('note', '笔记')].map((f) => Padding( + padding: const EdgeInsets.only(left: 4), + child: GestureDetector( + onTap: () { if (_filter != f.$1) setState(() { _filter = f.$1; _items.clear(); _seenIds.clear(); _loadBatch(5); }); }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: _filter == f.$1 ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: Text(f.$2, style: TextStyle(fontSize: 11, fontWeight: _filter == f.$1 ? FontWeight.w600 : FontWeight.normal, + color: _filter == f.$1 ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ), + ), + )), + const SizedBox(width: 4), + GestureDetector( + onTap: _reshuffle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(14)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.casino_outlined, size: 13, color: colors.onPrimary), + const SizedBox(width: 3), + Text('随机', style: TextStyle(fontSize: 11, color: colors.onPrimary, fontWeight: FontWeight.w500)), + ]), + ), + ), + ]), + ), + // 内容 + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: _items.isEmpty + ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.explore_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text('还没有内容', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + ])) + : PageView.builder( + controller: _pageController, + onPageChanged: (index) { if (index >= _items.length - 2) setState(() => _loadBatch(3)); }, + itemCount: _items.length, + itemBuilder: (context, index) { + return _buildCard(_items[index], colors); + }, + ), + ), + ), + // 底部按钮 + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭')), + ]), + ), + ], + ), + ), + ); + } + + Widget _buildCard(_StrollItem item, ColorScheme colors) { + final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty; + return GestureDetector( + onTap: () => _openDetail(item), + child: hasImage ? _buildImmersiveCard(item, colors) : _buildContentCard(item, colors), + ); + } + + Widget _buildImmersiveCard(_StrollItem item, ColorScheme colors) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 16, offset: const Offset(0, 6))]), + clipBehavior: Clip.antiAlias, + child: Stack(fit: StackFit.expand, children: [ + FadeInLocalImage(path: item.imagePath, fit: BoxFit.cover), + Positioned.fill(child: Container(decoration: BoxDecoration( + gradient: LinearGradient(colors: [Colors.transparent, Colors.black.withValues(alpha: 0.85)], + begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.3, 0.7])))), + Positioned(top: 12, left: 12, right: 12, child: _buildTopBadges(item)), + Positioned(left: 16, right: 16, bottom: 16, child: _buildBottomContent(item, Colors.white)), + ]), + ); + } + + Widget _buildContentCard(_StrollItem item, ColorScheme colors) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outlineVariant, width: 0.5), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4))]), + child: Padding(padding: const EdgeInsets.all(16), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTopBadges(item, textColor: colors.onSurface, bgColor: item.color.withValues(alpha: 0.1)), + const SizedBox(height: 12), + if (item.tags.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8), + child: Wrap(spacing: 6, children: item.tags.take(3).map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: item.color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12)), + child: Text(tag, style: TextStyle(fontSize: 11, color: item.color)), + )).toList())), + Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface, height: 1.3)), + if (item.subtitle.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), + ], + if (item.detail.isNotEmpty) ...[ + const SizedBox(height: 8), + Expanded(child: SingleChildScrollView(child: Text(item.detail, maxLines: 5, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.55), height: 1.6)))), + ], + const SizedBox(height: 8), + Row(children: [ + Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + const Spacer(), + _actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item), colors: colors), + const SizedBox(width: 6), + _actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item), colors: colors), + ]), + ], + )), + ); + } + + Widget _buildTopBadges(_StrollItem item, {Color? textColor, Color? bgColor}) { + final fg = textColor ?? Colors.white; + final bg = bgColor ?? Colors.black.withValues(alpha: 0.3); + return Row(children: [ + Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(16)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(item.icon, size: 13, color: fg), const SizedBox(width: 3), + Text(item.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg)), + ])), + const Spacer(), + if (item.rating != null) Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(16)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.star, size: 13, color: Color(0xFFFFB800)), const SizedBox(width: 2), + Text(item.rating!.toStringAsFixed(1), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg)), + ])), + ]); + } + + Widget _buildBottomContent(_StrollItem item, Color textColor) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + if (item.tags.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8), + child: Wrap(spacing: 6, children: item.tags.take(3).map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 0.5)), + child: Text(tag, style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.8))), + )).toList())), + Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: textColor, height: 1.3)), + if (item.subtitle.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: textColor.withValues(alpha: 0.6))), + ], + if (item.detail.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(item.detail, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.5), height: 1.5)), + ], + const SizedBox(height: 12), + Row(children: [ + Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}', + style: TextStyle(fontSize: 11, color: textColor.withValues(alpha: 0.4))), + const Spacer(), + _actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item)), + const SizedBox(width: 8), + _actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item)), + ]), + ]); + } + + Widget _actionBtn(IconData icon, String label, VoidCallback onTap, {ColorScheme? colors}) { + final fg = colors?.onSurface ?? Colors.white; + final bg = colors != null ? colors.surfaceContainerHighest : Colors.white.withValues(alpha: 0.12); + final border = colors != null ? colors.outlineVariant : Colors.white.withValues(alpha: 0.15); + return GestureDetector(onTap: onTap, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(16), border: Border.all(color: border, width: 0.5)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 13, color: fg.withValues(alpha: 0.8)), const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 11, color: fg.withValues(alpha: 0.8))), + ])), + ); + } + + void _showDeleteConfirm(_StrollItem item) { + final colors = Theme.of(context).colorScheme; + showDialog(context: context, builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除"${item.title}"吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton(onPressed: () { Navigator.pop(ctx); _deleteItem(item); }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('删除')), + ], + )); + } +} + +class _StrollItem { + final String type; + final dynamic data; + final String id; + final String title; + final String subtitle; + final String detail; + final String? imagePath; + final IconData icon; + final String label; + final double? rating; + final DateTime createdAt; + final List tags; + final Color color; + + _StrollItem({required this.type, required this.data, required this.id, required this.title, + required this.subtitle, required this.detail, this.imagePath, required this.icon, + required this.label, this.rating, required this.createdAt, this.tags = const [], required this.color}); +} + +// ─── 书影日历弹窗 ────────────────────────────────────────── + +class _CalendarDialog extends StatefulWidget { + const _CalendarDialog(); + + @override + State<_CalendarDialog> createState() => _CalendarDialogState(); +} + +class _CalendarDialogState extends State<_CalendarDialog> { + late DateTime _currentMonth; + DateTime? _selectedDay; + late Map> _dayItems; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _currentMonth = DateTime(now.year, now.month); + _selectedDay = DateTime(now.year, now.month, now.day); + _buildDayMap(); + } + + void _buildDayMap() { + final provider = context.read(); + final map = >{}; + for (final m in provider.movies.where((m) => !m.isDeleted)) { + if (m.posterPath == null || m.posterPath!.isEmpty) continue; + final day = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day); + map.putIfAbsent(day, () => []).add(_CalendarItem(path: m.posterPath!, title: m.title, type: 'movie', data: m)); + } + for (final b in provider.books.where((b) => !b.isDeleted)) { + if (b.coverPath == null || b.coverPath!.isEmpty) continue; + final day = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day); + map.putIfAbsent(day, () => []).add(_CalendarItem(path: b.coverPath!, title: b.title, type: 'book', data: b)); + } + _dayItems = map; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + return AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + title: Row(children: [ + const Text('书影日历'), + const Spacer(), + IconButton(onPressed: () => setState(() { _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1); _selectedDay = null; }), + icon: Icon(Icons.chevron_left, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), padding: EdgeInsets.zero, constraints: const BoxConstraints()), + Text('${_currentMonth.year}.${_currentMonth.month.toString().padLeft(2, '0')}', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + IconButton(onPressed: () => setState(() { _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1); _selectedDay = null; }), + icon: Icon(Icons.chevron_right, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), padding: EdgeInsets.zero, constraints: const BoxConstraints()), + ]), + content: SizedBox( + width: 480, + height: 440, + child: Column( + children: [ + // 星期头 + Padding(padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row(children: ['一', '二', '三', '四', '五', '六', '日'].map((d) => + Expanded(child: Center(child: Text(d, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.35))))), + ).toList())), + // 日历网格 + _buildCalendarGrid(colors, today), + // 选中日期详情 + if (_selectedDay != null) ...[ + Divider(height: 0.5, color: colors.outlineVariant), + Expanded(child: _buildSelectedDayDetail(colors)), + ], + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭')), + ], + ); + } + + Widget _buildCalendarGrid(ColorScheme colors, DateTime today) { + final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1); + final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0); + final startOffset = firstDay.weekday - 1; + final totalDays = lastDay.day; + final totalCells = startOffset + totalDays; + final rows = (totalCells / 7).ceil(); + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Column(children: List.generate(rows, (row) => SizedBox( + height: 52, + child: Row(children: List.generate(7, (col) { + final index = row * 7 + col; + if (index < startOffset || index >= startOffset + totalDays) return const Expanded(child: SizedBox()); + final day = index - startOffset + 1; + final date = DateTime(_currentMonth.year, _currentMonth.month, day); + final isToday = date == today; + final isSelected = _selectedDay == date; + final items = _dayItems[date] ?? []; + return Expanded(child: _buildDayCell(colors, date, day, isToday, isSelected, items)); + })), + ))), + ); + } + + Widget _buildDayCell(ColorScheme colors, DateTime date, int day, bool isToday, bool isSelected, List<_CalendarItem> items) { + final hasItems = items.isNotEmpty; + return GestureDetector( + onTap: () => setState(() => _selectedDay = date), + child: Container( + margin: const EdgeInsets.all(1.5), + decoration: BoxDecoration( + color: isSelected ? colors.primary.withValues(alpha: 0.08) : hasItems ? colors.surfaceContainerHigh : null, + borderRadius: BorderRadius.circular(8), + border: isToday ? Border.all(color: colors.primary, width: 1.5) : isSelected ? Border.all(color: colors.primary.withValues(alpha: 0.3), width: 1) : null, + ), + child: hasItems + ? ClipRRect(borderRadius: BorderRadius.circular(7), child: Stack(fit: StackFit.expand, children: [ + Image(image: FileImage(File(items.first.path)), fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest, + child: Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)))), + Positioned(left: 0, right: 0, bottom: 0, + child: Container(padding: const EdgeInsets.fromLTRB(3, 10, 3, 2), + decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.55)])), + child: Text('$day', style: TextStyle(fontSize: 9, fontWeight: isToday ? FontWeight.w700 : FontWeight.w500, + color: isToday ? const Color(0xFFFFD54F) : Colors.white)))), + if (items.length > 1) Positioned(top: 2, right: 2, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(4)), + child: Text('+${items.length - 1}', style: const TextStyle(fontSize: 8, fontWeight: FontWeight.w600, color: Colors.white)))), + ])) + : Center(child: Text('$day', style: TextStyle(fontSize: 12, + fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, + color: isToday ? colors.primary : colors.onSurface.withValues(alpha: 0.35)))), + ), + ); + } + + Widget _buildSelectedDayDetail(ColorScheme colors) { + final items = _dayItems[_selectedDay] ?? []; + if (items.isEmpty) { + return Padding(padding: const EdgeInsets.all(16), + child: Text('${_selectedDay!.month}月${_selectedDay!.day}日 暂无记录', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))); + } + return Column(mainAxisSize: MainAxisSize.min, children: [ + Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row(children: [ + Text('${_selectedDay!.month}月${_selectedDay!.day}日', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(width: 6), + Text('${items.length}条记录', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ])), + Expanded(child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: items.length, + separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant), + itemBuilder: (_, i) { + final item = items[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: ClipRRect(borderRadius: BorderRadius.circular(6), + child: SizedBox(width: 36, height: 36, + child: Image(image: FileImage(File(item.path)), fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest, + child: Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)))))), + title: Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: item.type == 'movie' ? const Color(0xFF4A90D9).withValues(alpha: 0.1) : const Color(0xFF7E57C2).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4)), + child: Text(item.type == 'movie' ? '影视' : '书籍', + style: TextStyle(fontSize: 10, color: item.type == 'movie' ? const Color(0xFF4A90D9) : const Color(0xFF7E57C2)))), + onTap: () { + final nav = Navigator.of(context); + nav.pop(); + if (item.type == 'movie') { + nav.push(MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); + } else { + nav.push(MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); + } + }, + ); + }, + )), + ]); + } +} + +class _CalendarItem { + final String path; + final String title; + final String type; + final dynamic data; + _CalendarItem({required this.path, required this.title, required this.type, required this.data}); +} + +// ─── 角色信息弹窗 ────────────────────────────────────────── + +class _PersonListDialog extends StatefulWidget { + const _PersonListDialog(); + + @override + State<_PersonListDialog> createState() => _PersonListDialogState(); +} + +class _PersonListDialogState extends State<_PersonListDialog> { + String _filter = 'all'; + String _searchQuery = ''; + final _searchController = TextEditingController(); + bool _loading = false; + _PersonEntry? _selectedPerson; + + @override + void initState() { + super.initState(); + _refresh(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _refresh() async { + setState(() => _loading = true); + final provider = context.read(); + await Future.wait([provider.loadMovies(), provider.loadBooks()]); + if (mounted) setState(() => _loading = false); + } + + List<_PersonEntry> _buildPersons() { + final provider = context.read(); + final map = {}; + + void addRole(String name, String role, {Movie? movie, Book? book}) { + if (name.trim().isEmpty) return; + final key = name.trim(); + map.putIfAbsent(key, () => _PersonEntry(name: key)); + map[key]!.roles.add(role); + if (movie != null && !map[key]!.movies.any((m) => m.id == movie.id)) map[key]!.movies.add(movie); + if (book != null && !map[key]!.books.any((b) => b.id == book.id)) map[key]!.books.add(book); + } + + for (final m in provider.movies.where((m) => !m.isDeleted)) { + for (final d in m.directors) addRole(d, '导演', movie: m); + for (final w in m.writers) addRole(w, '编剧', movie: m); + for (final a in m.actors) addRole(a, '主演', movie: m); + } + for (final b in provider.books.where((b) => !b.isDeleted)) { + for (final a in b.authors) addRole(a, '作者', book: b); + for (final t in b.translators) addRole(t, '译者', book: b); + } + + var list = map.values.toList(); + list.sort((a, b) => a.name.compareTo(b.name)); + return list; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final allPersons = _buildPersons(); + + var filtered = allPersons.where((p) { + if (_filter != 'all' && !p.roles.contains(_filter)) return false; + if (_searchQuery.isNotEmpty && !p.name.toLowerCase().contains(_searchQuery.toLowerCase())) return false; + return true; + }).toList(); + + return AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + title: Row(children: [ + const Text('角色信息'), + const Spacer(), + if (_loading) + SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) + else + IconButton(icon: const Icon(Icons.refresh, size: 20), onPressed: _refresh, padding: EdgeInsets.zero, constraints: const BoxConstraints()), + ]), + content: SizedBox( + width: 480, + height: 480, + child: _selectedPerson != null + ? _buildPersonDetail(_selectedPerson!, colors) + : Column( + children: [ + // 搜索栏 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + height: 38, + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(19)), + child: Row(children: [ + const SizedBox(width: 14), + Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.3)), + const SizedBox(width: 8), + Expanded(child: TextField( + controller: _searchController, + style: TextStyle(fontSize: 14, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: '搜索导演、编剧、演员、作者、译者', + hintStyle: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + onChanged: (v) => setState(() => _searchQuery = v.trim()), + )), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); }, + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.08), shape: BoxShape.circle), + child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ) + else + const SizedBox(width: 14), + ]), + ), + ), + // 角色筛选 + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + child: Row(children: [ + for (final f in ['all', '导演', '编剧', '主演', '作者', '译者']) + Padding( + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () => setState(() => _filter = f), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _filter == f ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: Text(f == 'all' ? '全部' : f, + style: TextStyle(fontSize: 11, fontWeight: _filter == f ? FontWeight.w600 : FontWeight.normal, + color: _filter == f ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ), + ), + ), + ]), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 4), + child: Align(alignment: Alignment.centerLeft, child: Text('共 ${filtered.length} 人', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)))), + ), + // 列表 + Expanded( + child: filtered.isEmpty + ? Center(child: Text('暂无数据', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)))) + : ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: filtered.length, + separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant), + itemBuilder: (_, i) => _buildPersonTile(filtered[i], colors), + ), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭')), + ], + ); + } + + Widget _buildPersonTile(_PersonEntry person, ColorScheme colors) { + final roleColors = { + '导演': const Color(0xFF4A90D9), + '编剧': const Color(0xFF009688), + '主演': const Color(0xFFE91E63), + '作者': const Color(0xFF7E57C2), + '译者': const Color(0xFFFF7043), + }; + final totalWorks = person.movies.length + person.books.length; + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 2), + leading: CircleAvatar( + radius: 18, + backgroundColor: colors.surfaceContainerHighest, + child: Text(person.name.isNotEmpty ? person.name[0] : '?', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + ), + title: Text(person.name, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)), + subtitle: Padding( + padding: const EdgeInsets.only(top: 3), + child: Wrap(spacing: 4, runSpacing: 3, children: [ + for (final role in person.roles.toSet()) + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration(color: (roleColors[role] ?? colors.outline).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(3)), + child: Text(role, style: TextStyle(fontSize: 9, color: roleColors[role] ?? colors.onSurface)), + ), + Text('$totalWorks 部作品', style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.35))), + ]), + ), + trailing: Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () => setState(() => _selectedPerson = person), + ); + } + + Widget _buildPersonDetail(_PersonEntry person, ColorScheme colors) { + final roleColors = { + '导演': const Color(0xFF4A90D9), + '编剧': const Color(0xFF009688), + '主演': const Color(0xFFE91E63), + '作者': const Color(0xFF7E57C2), + '译者': const Color(0xFFFF7043), + }; + + final movieItems = <_WorkItem>[]; + final bookItems = <_WorkItem>[]; + + for (final m in person.movies) { + final roles = []; + if (m.directors.contains(person.name)) roles.add('导演'); + if (m.writers.contains(person.name)) roles.add('编剧'); + if (m.actors.contains(person.name)) roles.add('主演'); + movieItems.add(_WorkItem(title: m.title, roles: roles, path: m.posterPath, data: m)); + } + for (final b in person.books) { + final roles = []; + if (b.authors.contains(person.name)) roles.add('作者'); + if (b.translators.contains(person.name)) roles.add('译者'); + bookItems.add(_WorkItem(title: b.title, roles: roles, path: b.coverPath, data: b)); + } + + return Column( + children: [ + // 返回按钮 + 标题 + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), + child: Row(children: [ + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: () => setState(() => _selectedPerson = null), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 8), + Text(person.name, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + children: [ + Wrap(spacing: 6, runSpacing: 6, children: [ + for (final role in person.roles.toSet()) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: (roleColors[role] ?? colors.outline).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8)), + child: Text(role, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: roleColors[role] ?? colors.onSurface)), + ), + ]), + const SizedBox(height: 20), + if (movieItems.isNotEmpty) ...[ + Text('影视作品(${movieItems.length})', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 8), + for (final item in movieItems) _buildWorkTile(item, colors, isMovie: true), + const SizedBox(height: 16), + ], + if (bookItems.isNotEmpty) ...[ + Text('书籍作品(${bookItems.length})', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 8), + for (final item in bookItems) _buildWorkTile(item, colors, isMovie: false), + ], + ], + ), + ), + ], + ); + } + + Widget _buildWorkTile(_WorkItem item, ColorScheme colors, {required bool isMovie}) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () { + Navigator.pop(context); + if (isMovie) { + Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); + } else { + Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); + } + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), border: Border.all(color: colors.outlineVariant, width: 0.5)), + child: Row(children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox(width: 44, height: 44, + child: item.path != null && item.path!.isNotEmpty + ? FadeInLocalImage(path: item.path!, fit: BoxFit.cover, + errorWidget: Container(color: colors.surfaceContainerHighest, child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2)))) + : Container(color: colors.surfaceContainerHighest, + child: Icon(isMovie ? Icons.movie_outlined : Icons.menu_book_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.3))), + ), + ), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 3), + Text(item.roles.join(' · '), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ])), + Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.2)), + ]), + ), + ), + ); + } +} + +class _PersonEntry { + final String name; + final Set roles = {}; + final List movies = []; + final List books = []; + _PersonEntry({required this.name}); +} + +class _WorkItem { + final String title; + final List roles; + final String? path; + final dynamic data; + _WorkItem({required this.title, required this.roles, this.path, required this.data}); +} + +// ─── 标签管理弹窗 ────────────────────────────────────────── + +class _TagManagementDialog extends StatefulWidget { + const _TagManagementDialog(); + + @override + State<_TagManagementDialog> createState() => _TagManagementDialogState(); +} + +class _TagManagementDialogState extends State<_TagManagementDialog> { + int _currentIndex = 0; + bool _isSyncing = false; + + static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag', 'game_genre']; + static const _typeLabels = ['影视类型', '书籍类型', '笔记标签', '游戏类型']; + static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined, Icons.sports_esports_outlined]; + + final Map>> _tagCache = {}; + Map _usageCounts = {}; + String? _newlyAddedTagId; + String _searchQuery = ''; + final _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadTags(_tabTypes[0]); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateUsageCounts(); + } + + void _updateUsageCounts() { + final provider = context.read(); + final counts = {}; + for (final m in provider.movies.where((m) => !m.isDeleted)) { for (final g in m.genres) counts[g] = (counts[g] ?? 0) + 1; } + for (final b in provider.books.where((b) => !b.isDeleted)) { for (final g in b.genres) counts[g] = (counts[g] ?? 0) + 1; } + for (final n in provider.notes.where((n) => !n.isDeleted)) { for (final t in n.tags) counts[t] = (counts[t] ?? 0) + 1; } + for (final g in provider.games.where((g) => !g.isDeleted)) { for (final genre in g.genres) counts[genre] = (counts[genre] ?? 0) + 1; } + _usageCounts = counts; + } + + Future _loadTags(String type) async { + final provider = context.read(); + final tags = await provider.getTags(type); + if (mounted) setState(() => _tagCache[type] = tags); + } + + Future _syncTags() async { + setState(() => _isSyncing = true); + try { + final provider = context.read(); + final count = await provider.syncTagsFromData(); + if (mounted) { + ToastUtil.show(context, count > 0 ? '已同步 $count 个新标签' : '标签已是最新'); + await _loadTags(_currentType); + _updateUsageCounts(); + } + } finally { + if (mounted) setState(() => _isSyncing = false); + } + } + + String get _currentType => _tabTypes[_currentIndex]; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + title: Row(children: [ + const Text('标签管理'), + const Spacer(), + // Tab 切换 + Container( + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), + child: Row(children: [ + for (int i = 0; i < _tabTypes.length; i++) + _tabButton(colors, _typeLabels[i], i), + ]), + ), + const SizedBox(width: 4), + if (_isSyncing) + SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) + else + IconButton(icon: const Icon(Icons.sync, size: 20), tooltip: '从数据中同步标签', onPressed: _syncTags, padding: EdgeInsets.zero, constraints: const BoxConstraints()), + ]), + content: SizedBox( + width: 520, + height: 440, + child: Column( + children: [ + // 搜索栏 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + height: 38, + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all(color: colors.outlineVariant, width: 0.5)), + child: Row(children: [ + const SizedBox(width: 12), + Icon(Icons.search_rounded, size: 18, color: colors.onSurface.withValues(alpha: 0.35)), + const SizedBox(width: 8), + Expanded(child: TextField( + controller: _searchController, + style: TextStyle(fontSize: 14, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: '搜索标签...', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + onChanged: (v) => setState(() => _searchQuery = v.trim()), + )), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); }, + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, shape: BoxShape.circle), + child: Icon(Icons.close_rounded, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ) + else + const SizedBox(width: 12), + ]), + ), + ), + // 标签列表 + Expanded(child: _buildTagList(_currentType)), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('关闭'), + ), + ElevatedButton( + onPressed: _showAddDialog, + style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: Text('添加${_typeLabels[_currentIndex]}', style: const TextStyle(fontSize: 13)), + ), + ], + ); + } + + Widget _tabButton(ColorScheme colors, String label, int index) { + final active = _currentIndex == index; + return GestureDetector( + onTap: () { setState(() => _currentIndex = index); _loadTags(_tabTypes[index]); }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration(color: active ? colors.primary : Colors.transparent, borderRadius: BorderRadius.circular(6)), + child: Text(label.replaceAll('类型', '').replaceAll('标签', ''), + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: active ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ), + ); + } + + Widget _buildTagList(String type) { + final tags = _tagCache[type] ?? []; + final colors = Theme.of(context).colorScheme; + final isSearching = _searchQuery.isNotEmpty; + + if (tags.isEmpty && !isSearching) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(_typeIcons[_currentIndex], size: 28, color: colors.onSurface.withValues(alpha: 0.15)), + const SizedBox(height: 8), + Text('暂无${_typeLabels[_currentIndex]}', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))), + ]), + ); + } + + if (isSearching) { + final filtered = tags.where((t) => (t['name'] as String).toLowerCase().contains(_searchQuery.toLowerCase())).toList() + ..sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0)); + if (filtered.isEmpty) { + return Center(child: Text('没有找到"$_searchQuery"相关标签', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))); + } + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Wrap(spacing: 8, runSpacing: 6, children: filtered.map(_buildTagChip).toList()), + ); + } + + // 分组模式 + final used = >[]; + final unused = >[]; + final hidden = >[]; + for (final t in tags) { + if ((t['is_hidden'] as int?) == 1) { hidden.add(t); continue; } + if ((_usageCounts[t['name']] ?? 0) > 0) { used.add(t); continue; } + unused.add(t); + } + used.sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0)); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 统计 + Row(children: [ + _statChip(Icons.check_circle_outline, '已使用', used.length, colors), + const SizedBox(width: 8), + _statChip(Icons.radio_button_unchecked, '未使用', unused.length, colors), + const SizedBox(width: 8), + _statChip(Icons.visibility_off_outlined, '隐藏', hidden.length, colors), + ]), + const SizedBox(height: 10), + _groupHeader('已使用', used.length, colors), + const SizedBox(height: 4), + used.isNotEmpty ? Wrap(spacing: 8, runSpacing: 6, children: used.map(_buildTagChip).toList()) : _emptyGroup('暂无', colors), + const SizedBox(height: 12), + _groupHeader('未使用', unused.length, colors), + const SizedBox(height: 4), + unused.isNotEmpty ? Wrap(spacing: 8, runSpacing: 6, children: unused.map(_buildTagChip).toList()) : _emptyGroup('暂无', colors), + const SizedBox(height: 12), + _groupHeader('隐藏', hidden.length, colors), + const SizedBox(height: 4), + hidden.isNotEmpty ? Wrap(spacing: 8, runSpacing: 6, children: hidden.map(_buildTagChip).toList()) : _emptyGroup('暂无', colors), + ]), + ); + } + + Widget _statChip(IconData icon, String label, int count, ColorScheme colors) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(6)), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 12, color: colors.onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 4), + Text('$count', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(width: 2), + Text(label, style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + ), + ); + } + + Widget _groupHeader(String label, int count, ColorScheme colors) { + return Row(children: [ + Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(width: 4), + Text('$count', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ]); + } + + Widget _emptyGroup(String text, ColorScheme colors) { + return Padding(padding: const EdgeInsets.symmetric(vertical: 4), child: Text(text, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)))); + } + + Widget _buildTagChip(Map tag) { + final colors = Theme.of(context).colorScheme; + final name = tag['name'] as String; + final count = _usageCounts[name] ?? 0; + final tagId = tag['id'] as String; + final isNew = tagId == _newlyAddedTagId; + final isHidden = (tag['is_hidden'] as int?) == 1; + + return GestureDetector( + onTap: () => _showTagMenu(tag), + onLongPress: () => _showTagMenu(tag), + child: Opacity( + opacity: isHidden ? 0.4 : 1.0, + child: isNew + ? _NewTagHighlight(child: _tagChipContent(name, count, colors, isHidden: isHidden)) + : _tagChipContent(name, count, colors, isHidden: isHidden), + ), + ); + } + + Widget _tagChipContent(String name, int count, ColorScheme colors, {bool isHidden = false}) { + return Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(name, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface, decoration: isHidden ? TextDecoration.lineThrough : null)), + if (count > 0) ...[ + const SizedBox(width: 5), + Text('$count', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ]), + ); + } + + void _showTagMenu(Map tag) { + final colors = Theme.of(context).colorScheme; + final name = tag['name'] as String; + final isHidden = (tag['is_hidden'] as int?) == 1; + + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Center(child: Container(width: 40, height: 4, margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Expanded(child: Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface))), + if (isHidden) Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(4)), + child: Text('已隐藏', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + ), + ]), + ), + const SizedBox(height: 16), + _menuAction(isHidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, isHidden ? '取消隐藏' : '隐藏', colors, () async { + Navigator.pop(ctx); + await context.read().toggleTagHidden(tag['id'] as String); + await _loadTags(_currentType); + }), + _menuAction(Icons.open_in_new_outlined, '查看相关${_typeLabels[_currentIndex].replaceAll('类型', '').replaceAll('标签', '')}', colors, () { + Navigator.pop(ctx); + _showTagItems(name); + }), + _menuAction(Icons.edit_outlined, '重命名', colors, () { Navigator.pop(ctx); _showRenameDialog(tag); }), + _menuAction(Icons.delete_outline, '删除', colors, () { Navigator.pop(ctx); _showDeleteDialog(tag); }, isDestructive: true), + ]), + ), + ), + ); + } + + Widget _menuAction(IconData icon, String title, ColorScheme colors, VoidCallback onTap, {bool isDestructive = false}) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), + child: Row(children: [ + Icon(icon, size: 20, color: isDestructive ? const Color(0xFFE53935) : colors.onSurface.withValues(alpha: 0.7)), + const SizedBox(width: 14), + Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: isDestructive ? const Color(0xFFE53935) : colors.onSurface)), + ]), + ), + ); + } + + void _showTagItems(String tagName) { + final provider = context.read(); + final colors = Theme.of(context).colorScheme; + List<({String title, String? subtitle, String type})> items = []; + if (_currentType == 'movie_genre') { + for (final m in provider.movies.where((m) => !m.isDeleted && m.genres.contains(tagName))) items.add((title: m.title, subtitle: m.directors.take(2).join(' / '), type: '影视')); + } else if (_currentType == 'book_genre') { + for (final b in provider.books.where((b) => !b.isDeleted && b.genres.contains(tagName))) items.add((title: b.title, subtitle: b.authors.take(2).join(' / '), type: '书籍')); + } else if (_currentType == 'game_genre') { + for (final g in provider.games.where((g) => !g.isDeleted && g.genres.contains(tagName))) items.add((title: g.title, subtitle: g.platforms.take(2).join(' / '), type: '游戏')); + } else { + for (final n in provider.notes.where((n) => !n.isDeleted && n.tags.contains(tagName))) items.add((title: n.title.isNotEmpty ? n.title : '随手记', subtitle: null, type: '笔记')); + } + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: ListView(shrinkWrap: true, padding: const EdgeInsets.only(bottom: 24), children: [ + Center(child: Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))), + Padding(padding: const EdgeInsets.symmetric(horizontal: 20), child: Text('$tagName(${items.length})', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + const SizedBox(height: 8), + if (items.isEmpty) + Padding(padding: const EdgeInsets.symmetric(vertical: 24), child: Center(child: Text('暂无相关内容', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))))) + else + ...items.asMap().entries.map((entry) { + final item = entry.value; + return Padding(padding: const EdgeInsets.symmetric(horizontal: 20), child: Column(mainAxisSize: MainAxisSize.min, children: [ + if (entry.key > 0) Divider(height: 0.5, color: colors.outlineVariant), + ListTile(contentPadding: EdgeInsets.zero, title: Text(item.title, style: TextStyle(fontSize: 14, color: colors.onSurface)), + subtitle: item.subtitle != null && item.subtitle!.isNotEmpty ? Text(item.subtitle!, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))) : null, + trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)), + child: Text(item.type, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5)))), + ), + ])); + }), + ]), + ), + ); + } + + void _showAddDialog() { + final colors = Theme.of(context).colorScheme; + final controller = TextEditingController(); + final type = _currentType; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text('添加${_typeLabels[_currentIndex]}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: TextField( + controller: controller, autofocus: true, style: TextStyle(fontSize: 15, color: colors.onSurface), + decoration: InputDecoration( + hintText: '输入标签名称', hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), + filled: true, fillColor: colors.surfaceContainerHigh, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colors.primary, width: 1)), + ), + onSubmitted: (value) => _doAddTag(ctx, controller.text.trim(), type), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))), + ElevatedButton(onPressed: () => _doAddTag(ctx, controller.text.trim(), type), + style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('添加')), + ], + ), + ); + } + + Future _doAddTag(BuildContext ctx, String name, String type) async { + if (name.isEmpty) return; + try { + final provider = context.read(); + final newId = await provider.addTag(name, type); + if (!mounted) return; + if (ctx.mounted) { Navigator.pop(ctx); ToastUtil.show(context, '添加成功'); } + setState(() => _newlyAddedTagId = newId); + Timer(const Duration(milliseconds: 1500), () { if (mounted) setState(() => _newlyAddedTagId = null); }); + await _loadTags(type); + } catch (e) { + if (ctx.mounted) ToastUtil.show(ctx, '添加失败:该标签已存在'); + } + } + + void _showRenameDialog(Map tag) { + final colors = Theme.of(context).colorScheme; + final controller = TextEditingController(text: tag['name'] as String); + final tagId = tag['id'] as String; + final type = tag['type'] as String; + final oldName = tag['name'] as String; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text('重命名标签', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: TextField( + controller: controller, autofocus: true, style: TextStyle(fontSize: 15, color: colors.onSurface), + decoration: InputDecoration( + hintText: '输入新名称', hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), + filled: true, fillColor: colors.surfaceContainerHigh, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colors.primary, width: 1)), + ), + onSubmitted: (value) => _doRenameTag(ctx, tagId, value.trim(), type, oldName), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))), + ElevatedButton(onPressed: () => _doRenameTag(ctx, tagId, controller.text.trim(), type, oldName), + style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('确定')), + ], + ), + ); + } + + Future _doRenameTag(BuildContext ctx, String tagId, String newName, String type, String oldName) async { + if (newName.isEmpty || newName == oldName) { if (ctx.mounted) Navigator.pop(ctx); return; } + final success = await context.read().renameTag(tagId, newName, type); + if (ctx.mounted) { Navigator.pop(ctx); ToastUtil.show(context, success ? '重命名成功' : '重命名失败:标签名已存在'); } + if (success) await _loadTags(type); + } + + void _showDeleteDialog(Map tag) { + final tagId = tag['id'] as String; + final type = tag['type'] as String; + final name = tag['name'] as String; + String? selectedAction = 'deleteOnly'; + String? selectedReplacement; + bool showAdvanced = false; + final otherTags = (_tagCache[type] ?? []).where((t) => t['id'] != tagId).map((t) => t['name'] as String).toList(); + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder(builder: (ctx, setDialogState) { + final bc = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: bc.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row(children: [ + Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: bc.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: Text(name, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: bc.onSurface.withValues(alpha: 0.6)))), + const SizedBox(width: 10), + Text('删除标签', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: bc.onSurface)), + ]), + content: SizedBox(width: double.maxFinite, child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.of(ctx).size.height * 0.45), + child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 4), + _buildDeleteOption(value: 'deleteOnly', groupValue: selectedAction, onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }), + title: '仅删除标签', subtitle: '保留已有条目上的标签名,不影响数据', colors: bc), + const SizedBox(height: 8), + GestureDetector(onTap: () => setDialogState(() => showAdvanced = !showAdvanced), + child: Row(children: [Text('更多选项', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: bc.primary)), + Icon(showAdvanced ? Icons.expand_less : Icons.expand_more, size: 16, color: bc.primary)])), + if (showAdvanced) ...[ + const SizedBox(height: 10), + _buildDeleteOption(value: 'remove', groupValue: selectedAction, onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }), + title: '从所有条目中移除', subtitle: '彻底清除该标签在所有条目中的记录', colors: bc), + const SizedBox(height: 4), + _buildDeleteOption(value: 'replace', groupValue: selectedAction, onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }), + title: '替换为其他标签', subtitle: '选择一个已有标签替代', colors: bc), + if (selectedAction == 'replace') + Padding(padding: const EdgeInsets.only(left: 40, top: 10), + child: otherTags.isNotEmpty + ? Wrap(spacing: 8, runSpacing: 8, children: otherTags.map((t) { + final isSelected = selectedReplacement == t; + return GestureDetector(onTap: () => setDialogState(() => selectedReplacement = isSelected ? null : t), + child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration(color: isSelected ? bc.primary : bc.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), + border: Border.all(color: isSelected ? bc.primary : bc.outlineVariant, width: 0.5)), + child: Text(t, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isSelected ? bc.onPrimary : bc.onSurface.withValues(alpha: 0.7))))); + }).toList()) + : Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration(color: bc.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: Text('无其他标签可替换', style: TextStyle(fontSize: 13, color: bc.onSurface.withValues(alpha: 0.35)))), + ), + ], + ])), + )), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: bc.onSurface.withValues(alpha: 0.4)))), + ElevatedButton(onPressed: () { + if (selectedAction == 'replace' && (selectedReplacement == null || selectedReplacement!.isEmpty)) return; + Navigator.pop(ctx, {'action': selectedAction, 'replacement': selectedReplacement}); + }, style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE53935), foregroundColor: Colors.white, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('删除')), + ], + ); + }), + ).then((result) async { + if (result == null) return; + final action = result['action'] as String; + final replacement = result['replacement'] as String?; + if (!mounted) return; + final provider = context.read(); + if (action == 'deleteOnly') { await provider.deleteTagOnly(tagId, type); } + else { await provider.deleteTag(tagId, type, replacementName: replacement); } + if (!mounted) return; + ToastUtil.show(context, '删除成功'); + await _loadTags(type); + }); + } + + Widget _buildDeleteOption({required String value, required String? groupValue, required ValueChanged onChanged, + required String title, String? subtitle, required ColorScheme colors}) { + final selected = value == groupValue; + return GestureDetector( + onTap: () => onChanged(value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration(color: selected ? colors.surfaceContainerHigh : colors.surface, borderRadius: BorderRadius.circular(12), + border: Border.all(color: selected ? colors.primary : colors.outlineVariant, width: selected ? 1 : 0.5)), + child: Row(children: [ + Container(width: 18, height: 18, decoration: BoxDecoration(shape: BoxShape.circle, + border: Border.all(color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.25), width: selected ? 5 : 1.5))), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(title, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w500 : FontWeight.normal, color: selected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.6))), + if (subtitle != null) Padding(padding: const EdgeInsets.only(top: 2), child: Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)))), + ])), + ]), + ), + ); + } +} + +// ─── 新标签高亮动画 ────────────────────────────────────────── + +class _NewTagHighlight extends StatefulWidget { + final Widget child; + const _NewTagHighlight({required this.child}); + + @override + State<_NewTagHighlight> createState() => _NewTagHighlightState(); +} + +class _NewTagHighlightState extends State<_NewTagHighlight> with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final opacity = _controller.value < 0.3 + ? (_controller.value / 0.3).clamp(0.0, 1.0) + : (1.0 - (_controller.value - 0.3) / 0.7).clamp(0.0, 1.0); + final scale = 1.0 + 0.06 * (1.0 - _controller.value); + return Transform.scale( + scale: scale, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colors.primary.withValues(alpha: 0.12 * opacity), + border: Border.all(color: colors.primary.withValues(alpha: 0.3 * opacity), width: 1), + ), + child: widget.child, + ), + ); + }, + ); + } +} + +// ─── 备份选择弹窗 ────────────────────────────────────────── + +class _BackupChoiceDialog extends StatefulWidget { + const _BackupChoiceDialog(); + + @override + State<_BackupChoiceDialog> createState() => _BackupChoiceDialogState(); +} + +class _BackupChoiceDialogState extends State<_BackupChoiceDialog> { + int _tabIndex = 0; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 0), + title: Row(children: [ + const Text('备份'), + const Spacer(), + // Tab 切换 + Container( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row(children: [ + _tabButton(colors, '本地备份', 0), + _tabButton(colors, 'WebDAV', 1), + ]), + ), + ]), + content: SizedBox( + width: 480, + height: 440, + child: _tabIndex == 0 + ? const _LocalBackupContent() + : const _WebDAVBackupContent(), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭')), + ], + ); + } + + Widget _tabButton(ColorScheme colors, String label, int index) { + final active = _tabIndex == index; + return GestureDetector( + onTap: () => setState(() => _tabIndex = index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: active ? colors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: active ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ); + } +} + +// ─── 本地备份内容(嵌入弹窗) ────────────────────────────── + +class _LocalBackupContent extends StatefulWidget { + const _LocalBackupContent(); + + @override + State<_LocalBackupContent> createState() => _LocalBackupContentState(); +} + +class _LocalBackupContentState extends State<_LocalBackupContent> { + bool _isExporting = false; + bool _isImporting = false; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + children: [ + _buildActionCard( + colors: colors, + title: '导出数据', + description: '将所有数据导出为 zip 文件,可用于备份或迁移到其他设备', + icon: Icons.upload_outlined, + buttonText: '导出', + isLoading: _isExporting, + onTap: _exportData, + ), + const SizedBox(height: 8), + _buildActionCard( + colors: colors, + title: '导入数据', + description: '从备份文件导入数据,将覆盖当前所有数据', + icon: Icons.download_outlined, + buttonText: '导入', + isLoading: _isImporting, + onTap: _importData, + isDestructive: true, + ), + const SizedBox(height: 20), + _buildInfoSection(colors), + ], + ); + } + + Widget _buildActionCard({ + required ColorScheme colors, + required String title, + required String description, + required IconData icon, + required String buttonText, + required bool isLoading, + required VoidCallback onTap, + bool isDestructive = false, + }) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), + child: Icon(icon, size: 18, color: isDestructive ? Colors.red : colors.onSurface.withValues(alpha: 0.6)), + ), + const SizedBox(width: 10), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + const SizedBox(height: 1), + Text(description, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4), height: 1.3)), + ], + )), + ]), + const SizedBox(height: 12), + GestureDetector( + onTap: isLoading ? null : onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isLoading ? colors.onSurface.withValues(alpha: 0.25) : colors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: isLoading + ? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(colors.onPrimary))) + : Text(buttonText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onPrimary)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInfoSection(ColorScheme colors) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container(width: 32, height: 32, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), + child: Icon(Icons.info_outline, size: 18, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(width: 10), + Text('使用说明', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + ]), + const SizedBox(height: 12), + _infoItem(colors, '导出数据会生成一个 .zip 文件,包含所有数据和图片'), + const SizedBox(height: 8), + _infoItem(colors, '选择保存路径后,可以通过微信、邮件等方式发送备份文件'), + const SizedBox(height: 8), + _infoItem(colors, '在新设备上选择导入数据,选择备份文件即可恢复'), + const SizedBox(height: 8), + _infoItem(colors, '导入数据会完全覆盖当前设备的数据,请谨慎操作'), + ], + ), + ); + } + + Widget _infoItem(ColorScheme colors, String text) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding(padding: const EdgeInsets.only(top: 8), child: Icon(Icons.circle, size: 4, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(width: 8), + Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5), height: 1.5))), + ], + ); + } + + Future _exportData() async { + setState(() => _isExporting = true); + try { + final result = await BackupService.instance.exportDataWithImages(); + if (!mounted) return; + if (result.cancelled) { + ToastUtil.show(context, '已取消导出'); + } else if (result.success) { + ToastUtil.show(context,'导出成功'); + } else { + ToastUtil.show(context,result.errorMessage ?? '导出失败'); + } + } catch (e) { + if (mounted) ToastUtil.show(context,'导出失败: $e'); + } finally { + if (mounted) setState(() => _isExporting = false); + } + } + + Future _importData() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) { + final colors = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Row(children: [ + Container(width: 40, height: 40, decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10)), + child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22)), + const SizedBox(width: 12), + Text('确认导入', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + content: Padding(padding: const EdgeInsets.only(top: 16), + child: Text('导入数据将覆盖当前所有数据,此操作不可恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6))), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton(onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('确认导入', style: TextStyle(fontWeight: FontWeight.w600))), + ], + ); + }, + ); + if (confirmed != true) return; + + setState(() => _isImporting = true); + try { + final result = await BackupService.instance.importData(); + if (!mounted) return; + if (result.cancelled) { + ToastUtil.show(context,'已取消导入'); + } else if (result.success) { + await context.read().loadMovies(); + await context.read().loadBooks(); + await context.read().loadNotes(); + await context.read().loadGames(); + if (!mounted) return; + ToastUtil.show(context,'导入成功'); + } else { + ToastUtil.show(context,result.errorMessage ?? '导入失败'); + } + } catch (e) { + if (mounted) ToastUtil.show(context,'导入失败: $e'); + } finally { + if (mounted) setState(() => _isImporting = false); + } + } +} + +// ─── WebDAV 备份内容(嵌入弹窗) ────────────────────────── + +class _WebDAVBackupContent extends StatefulWidget { + const _WebDAVBackupContent(); + + @override + State<_WebDAVBackupContent> createState() => _WebDAVBackupContentState(); +} + +class _WebDAVBackupContentState extends State<_WebDAVBackupContent> { + final _urlController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _pathController = TextEditingController(text: '/mooknote'); + + bool _isLoading = false; + bool _isConfigured = false; + bool _obscurePassword = true; + String _syncStep = ''; + + DateTime? _remoteModifiedTime; + int? _remoteFileSize; + bool _isLoadingRemoteInfo = false; + + @override + void initState() { + super.initState(); + _loadConfig(); + } + + @override + void dispose() { + _urlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _pathController.dispose(); + super.dispose(); + } + + Future _loadConfig() async { + final config = await WebDAVService.instance.getConfig(); + if (config != null) { + setState(() { + _urlController.text = config['url'] ?? ''; + _usernameController.text = config['username'] ?? ''; + _passwordController.text = config['password'] ?? ''; + _pathController.text = config['path'] ?? '/mooknote'; + _isConfigured = true; + }); + _loadRemoteInfo(); + } + } + + Future _loadRemoteInfo() async { + setState(() => _isLoadingRemoteInfo = true); + final info = await WebDAVService.instance.getRemoteBackupInfo(); + if (mounted) { + setState(() { + _remoteModifiedTime = info?['modifiedTime'] as DateTime?; + _remoteFileSize = info?['size'] as int?; + _isLoadingRemoteInfo = false; + }); + } + } + + Future _saveConfig() async { + final url = _urlController.text.trim(); + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final path = _pathController.text.trim(); + + if (url.isEmpty) { ToastUtil.show(context,'请输入服务器地址'); return; } + if (username.isEmpty) { ToastUtil.show(context,'请输入用户名'); return; } + if (password.isEmpty) { ToastUtil.show(context,'请输入密码'); return; } + + setState(() => _isLoading = true); + try { + final result = await WebDAVService.instance.testConnection(url: url, username: username, password: password, path: path); + if (!mounted) return; + if (result['success'] == true) { + await WebDAVService.instance.saveConfig(url: url, username: username, password: password, path: path); + setState(() => _isConfigured = true); + _loadRemoteInfo(); + ToastUtil.show(context,result['message'] ?? '连接成功,配置已保存'); + } else { + ToastUtil.show(context,result['message'] ?? '连接失败,请检查配置'); + } + } catch (e) { + if (mounted) ToastUtil.show(context,'连接失败: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _syncData(SyncDirection direction) async { + setState(() => _isLoading = true); + try { + SyncResult result; + if (direction == SyncDirection.upload) { + setState(() => _syncStep = '正在打包数据...'); + await Future.delayed(Duration.zero); + final exportResult = await WebDAVService.instance.exportLocalData(); + if (!exportResult.success || exportResult.zipPath == null) { + if (mounted) { setState(() { _isLoading = false; _syncStep = ''; }); ToastUtil.show(context,exportResult.errorMessage ?? '创建备份失败'); } + return; + } + if (!mounted) return; + setState(() => _syncStep = '正在上传到云端...'); + await Future.delayed(Duration.zero); + result = await WebDAVService.instance.uploadExportedData(exportResult); + } else { + setState(() => _syncStep = '正在从云端下载...'); + await Future.delayed(Duration.zero); + result = await WebDAVService.instance.syncData(direction: SyncDirection.download); + } + + if (!mounted) return; + if (result.success) { + _loadRemoteInfo(); + if (result.needReload) { + final provider = context.read(); + await provider.loadMovies(); + await provider.loadBooks(); + await provider.loadNotes(); + await provider.loadGames(); + } + ToastUtil.show(context,'同步成功'); + } else { + ToastUtil.show(context,result.message); + } + } catch (e) { + if (mounted) ToastUtil.show(context,'同步失败: $e'); + } finally { + if (mounted) setState(() { _isLoading = false; _syncStep = ''; }); + } + } + + Future _confirmSync(SyncDirection direction) async { + final isUpload = direction == SyncDirection.upload; + final confirmed = await showDialog( + context: context, + builder: (ctx) { + final colors = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text(isUpload ? '确认上传' : '确认下载', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text(isUpload ? '该操作会覆盖远程数据,请谨慎操作' : '该操作会拉取远程数据覆盖本地数据,请谨慎操作', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton(onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('确定')), + ], + ); + }, + ); + if (confirmed == true) _syncData(direction); + } + + Future _clearConfig() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) { + final colors = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Row(children: [ + Container(width: 40, height: 40, decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10)), + child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22)), + const SizedBox(width: 12), + Text('清除配置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + content: Padding(padding: const EdgeInsets.only(top: 16), + child: Text('确定要清除 WebDAV 配置吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6))), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton(onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('清除', style: TextStyle(fontWeight: FontWeight.w600))), + ], + ); + }, + ); + if (confirmed == true) { + await WebDAVService.instance.clearConfig(); + setState(() { + _urlController.clear(); + _usernameController.clear(); + _passwordController.clear(); + _pathController.text = '/mooknote'; + _isConfigured = false; + }); + if (mounted) ToastUtil.show(context,'配置已清除'); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + children: [ + if (_isConfigured) _buildConnectedBanner(colors), + if (_isConfigured) ...[ + _buildRemoteInfoCard(colors), + const SizedBox(height: 16), + ], + _buildSectionLabel(colors, '服务器配置'), + const SizedBox(height: 10), + _buildInput(colors: colors, controller: _urlController, hint: '服务器地址,如 https://dav.example.com', icon: Icons.link), + const SizedBox(height: 8), + _buildInput(colors: colors, controller: _usernameController, hint: '用户名', icon: Icons.person_outline), + const SizedBox(height: 8), + _buildInput(colors: colors, controller: _passwordController, hint: '密码', icon: Icons.lock_outline, + obscure: _obscurePassword, + suffix: GestureDetector( + onTap: () => setState(() => _obscurePassword = !_obscurePassword), + child: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility, size: 20, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ), + const SizedBox(height: 8), + _buildInput(colors: colors, controller: _pathController, hint: '同步路径,如 /mooknote', icon: Icons.folder_outlined), + const SizedBox(height: 14), + _buildBtn(colors, '测试并保存', onTap: _isLoading ? null : _saveConfig), + if (_isConfigured) ...[ + const SizedBox(height: 8), + Center(child: GestureDetector( + onTap: _isLoading ? null : _clearConfig, + child: Padding(padding: const EdgeInsets.symmetric(vertical: 4), + child: Text('清除配置', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35)))), + )), + ], + const SizedBox(height: 20), + _buildTips(colors), + ], + ); + } + + Widget _buildConnectedBanner(ColorScheme colors) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), + child: Row(children: [ + Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFF4CAF50), shape: BoxShape.circle)), + const SizedBox(width: 10), + Expanded(child: Text(_urlController.text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6)), maxLines: 1, overflow: TextOverflow.ellipsis)), + Text('已连接', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + ); + } + + Widget _buildSectionLabel(ColorScheme colors, String text) { + return Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4), letterSpacing: 0.5)); + } + + Widget _buildInput({required ColorScheme colors, required TextEditingController controller, required String hint, required IconData icon, bool obscure = false, Widget? suffix}) { + return TextField( + controller: controller, + obscureText: obscure, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + prefixIcon: Padding(padding: const EdgeInsets.only(left: 4, right: 8), child: Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: 0.3))), + prefixIconConstraints: const BoxConstraints(minWidth: 44), + suffixIcon: suffix != null ? Padding(padding: const EdgeInsets.only(right: 8), child: suffix) : null, + filled: true, + fillColor: colors.surfaceContainerHigh, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + ), + ); + } + + Widget _buildBtn(ColorScheme colors, String text, {VoidCallback? onTap}) { + final disabled = onTap == null; + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration(color: disabled ? colors.onSurface.withValues(alpha: 0.15) : colors.primary, borderRadius: BorderRadius.circular(8)), + child: Center(child: Text(text, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary))), + ), + ); + } + + Widget _buildRemoteInfoCard(ColorScheme colors) { + String timeText; + String sizeText = ''; + if (_isLoadingRemoteInfo) { + timeText = '加载中...'; + } else if (_remoteModifiedTime != null) { + final dt = _remoteModifiedTime!; + timeText = '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + if (_remoteFileSize != null) sizeText = _formatFileSize(_remoteFileSize!); + } else { + timeText = '暂无备份文件'; + } + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.5)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.cloud_outlined, size: 18, color: colors.primary), + const SizedBox(width: 8), + Text('云端备份', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + const Spacer(), + if (_isLoadingRemoteInfo) + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) + else + GestureDetector(onTap: _loadRemoteInfo, child: Icon(Icons.refresh, size: 18, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + const SizedBox(height: 10), + Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('上传时间', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 3), + Text(timeText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + ])), + if (sizeText.isNotEmpty) + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('文件大小', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 3), + Text(sizeText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + ])), + ]), + const SizedBox(height: 14), + const Divider(height: 0.5, color: Color(0xFFE0E0E0)), + const SizedBox(height: 10), + if (_isLoading) ...[ + SizedBox(width: double.infinity, child: LinearProgressIndicator(backgroundColor: colors.surfaceContainerHighest, color: colors.primary, minHeight: 3, borderRadius: BorderRadius.circular(1.5))), + const SizedBox(height: 8), + Text(_syncStep, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + ] else ...[ + Row(children: [ + Expanded(child: _buildBtn(colors, '上传', onTap: _isLoading ? null : () => _confirmSync(SyncDirection.upload))), + const SizedBox(width: 12), + Expanded(child: _buildBtn(colors, '下载', onTap: _isLoading ? null : () => _confirmSync(SyncDirection.download))), + ]), + ], + ], + ), + ); + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB'; + } + + Widget _buildTips(ColorScheme colors) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(10)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('支持的服务', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4), letterSpacing: 0.5)), + const SizedBox(height: 10), + _tip(colors, '坚果云、Nextcloud、AList 等 WebDAV 服务'), + _tip(colors, '服务器地址需包含 https://'), + _tip(colors, '首次同步可能需要较长时间'), + ], + ), + ); + } + + Widget _tip(ColorScheme colors, String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding(padding: const EdgeInsets.only(top: 8), child: Icon(Icons.circle, size: 4, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(width: 8), + Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5), height: 1.5))), + ], + ), + ); + } +} + +// ─── 回收站弹窗 ────────────────────────────────────────── + +enum _BinItemType { movie, book, note, game, movieReview, bookReview, bookExcerpt, gameReview } + +class _BinItem { + final _BinItemType type; + final String id; + final String title; + final String subtitle; + final IconData icon; + final String typeLabel; + + _BinItem.movie(Movie m) + : type = _BinItemType.movie, id = m.id, title = m.title, + subtitle = '删除于 ${m.updatedAt.year}.${m.updatedAt.month.toString().padLeft(2, '0')}.${m.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.movie_outlined, typeLabel = '影视'; + + _BinItem.book(Book b) + : type = _BinItemType.book, id = b.id, title = b.title, + subtitle = '删除于 ${b.updatedAt.year}.${b.updatedAt.month.toString().padLeft(2, '0')}.${b.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.menu_book_outlined, typeLabel = '书籍'; + + _BinItem.note(Note n) + : type = _BinItemType.note, id = n.id, title = n.title.isNotEmpty ? n.title : n.summary, + subtitle = '删除于 ${n.updatedAt.year}.${n.updatedAt.month.toString().padLeft(2, '0')}.${n.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.description_outlined, typeLabel = '笔记'; + + _BinItem.game(Game g) + : type = _BinItemType.game, id = g.id, title = g.title, + subtitle = '删除于 ${g.updatedAt.year}.${g.updatedAt.month.toString().padLeft(2, '0')}.${g.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.sports_esports_outlined, typeLabel = '游戏'; + + _BinItem.movieReview(MovieReview r) + : type = _BinItemType.movieReview, id = r.id, title = r.content.isNotEmpty ? r.content : '影评', + subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.rate_review_outlined, typeLabel = '影评'; + + _BinItem.bookReview(BookReview r) + : type = _BinItemType.bookReview, id = r.id, title = r.content.isNotEmpty ? r.content : '书评', + subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.rate_review_outlined, typeLabel = '书评'; + + _BinItem.bookExcerpt(BookExcerpt e) + : type = _BinItemType.bookExcerpt, id = e.id, title = e.content.isNotEmpty ? e.content : '摘抄', + subtitle = '删除于 ${e.updatedAt.year}.${e.updatedAt.month.toString().padLeft(2, '0')}.${e.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.format_quote_outlined, typeLabel = '书摘'; + + _BinItem.gameReview(GameReview r) + : type = _BinItemType.gameReview, id = r.id, title = r.content.isNotEmpty ? r.content : '游戏评价', + subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.rate_review_outlined, typeLabel = '游戏评价'; +} + +class _RecycleBinDialog extends StatefulWidget { + const _RecycleBinDialog(); + + @override + State<_RecycleBinDialog> createState() => _RecycleBinDialogState(); +} + +class _RecycleBinDialogState extends State<_RecycleBinDialog> { + List<_BinItem> _allItems = []; + _BinItemType? _filterType; + bool _isLoading = true; + + List<_BinItem> get _filteredItems => + _filterType == null ? _allItems : _allItems.where((i) => i.type == _filterType).toList(); + + @override + void initState() { + super.initState(); + _loadDeletedItems(); + } + + Future _loadDeletedItems() async { + setState(() => _isLoading = true); + final provider = context.read(); + final movies = await provider.getDeletedMovies(); + final books = await provider.getDeletedBooks(); + final notes = await provider.getDeletedNotes(); + final games = await provider.getDeletedGames(); + final movieReviews = await provider.getDeletedMovieReviews(); + final bookReviews = await provider.getDeletedBookReviews(); + final bookExcerpts = await provider.getDeletedBookExcerpts(); + final gameReviews = await provider.getDeletedGameReviews(); + if (!mounted) return; + setState(() { + _allItems = [ + for (final m in movies) _BinItem.movie(m), + for (final b in books) _BinItem.book(b), + for (final n in notes) _BinItem.note(n), + for (final g in games) _BinItem.game(g), + for (final r in movieReviews) _BinItem.movieReview(r), + for (final r in bookReviews) _BinItem.bookReview(r), + for (final e in bookExcerpts) _BinItem.bookExcerpt(e), + for (final r in gameReviews) _BinItem.gameReview(r), + ]; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + title: Row(children: [ + const Text('回收站'), + const Spacer(), + if (_allItems.isNotEmpty) + TextButton( + onPressed: _showClearAllDialog, + style: TextButton.styleFrom( + foregroundColor: colors.error, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + minimumSize: Size.zero, + ), + child: const Text('清空', style: TextStyle(fontSize: 12)), + ), + ]), + content: SizedBox( + width: 400, + height: 480, + child: _isLoading + ? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) + : Column(children: [ + if (_allItems.isNotEmpty) _buildFilterRow(colors), + Expanded( + child: _filteredItems.isEmpty + ? _buildEmptyState(colors) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: _filteredItems.length, + itemBuilder: (_, i) => _buildItem(_filteredItems[i], colors), + ), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('关闭')), + ], + ); + } + + Widget _buildFilterRow(ColorScheme colors) { + return Container( + padding: const EdgeInsets.only(bottom: 8), + child: Wrap(spacing: 6, runSpacing: 6, children: [ + _filterChip('全部', null, colors), + _filterChip('影视', _BinItemType.movie, colors), + _filterChip('书籍', _BinItemType.book, colors), + _filterChip('笔记', _BinItemType.note, colors), + _filterChip('游戏', _BinItemType.game, colors), + _filterChip('影评', _BinItemType.movieReview, colors), + _filterChip('书评', _BinItemType.bookReview, colors), + _filterChip('书摘', _BinItemType.bookExcerpt, colors), + _filterChip('游戏评价', _BinItemType.gameReview, colors), + ]), + ); + } + + Widget _filterChip(String label, _BinItemType? type, ColorScheme colors) { + final active = _filterType == type; + final count = type == null ? _allItems.length : _allItems.where((i) => i.type == type).length; + return GestureDetector( + onTap: () => setState(() => _filterType = type), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: active ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: Text('$label · $count', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, + color: active ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ), + ); + } + + Widget _buildItem(_BinItem item, ColorScheme colors) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 3), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row(children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(6), + border: Border.all(color: colors.outlineVariant, width: 0.5)), + child: Icon(item.icon, size: 16, color: colors.onSurface.withValues(alpha: 0.5)), + ), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + Expanded(child: Text(item.title, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface, height: 1.3), + maxLines: 1, overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(3), + border: Border.all(color: colors.outlineVariant, width: 0.5)), + child: Text(item.typeLabel, + style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4))), + ), + ]), + const SizedBox(height: 2), + Text(item.subtitle, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.35))), + ])), + const SizedBox(width: 6), + _actionBtn(Icons.restore, colors.primary, () => _restore(item)), + const SizedBox(width: 4), + _actionBtn(Icons.delete_outline, colors.error, () => _permanentDelete(item)), + ]), + ), + ); + } + + Widget _actionBtn(IconData icon, Color color, VoidCallback onTap) { + final colors = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + width: 28, height: 28, + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(6), + border: Border.all(color: colors.outlineVariant, width: 0.5)), + child: Icon(icon, size: 14, color: color), + ), + ); + } + + Widget _buildEmptyState(ColorScheme colors) { + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.delete_outline, size: 40, color: colors.onSurface.withValues(alpha: 0.15)), + const SizedBox(height: 12), + Text(_filterType == null ? '回收站是空的' : '没有删除的项目', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))), + ])); + } + + Future _restore(_BinItem item) async { + final provider = context.read(); + switch (item.type) { + case _BinItemType.movie: await provider.restoreMovie(item.id); + case _BinItemType.book: await provider.restoreBook(item.id); + case _BinItemType.note: await provider.restoreNote(item.id); + case _BinItemType.game: await provider.restoreGame(item.id); + case _BinItemType.movieReview: await provider.restoreMovieReview(item.id); + case _BinItemType.bookReview: await provider.restoreBookReview(item.id); + case _BinItemType.bookExcerpt: await provider.restoreBookExcerpt(item.id); + case _BinItemType.gameReview: await provider.restoreGameReview(item.id); + } + if (mounted) { + ToastUtil.show(context, '${item.typeLabel}已恢复'); + _loadDeletedItems(); + } + } + + Future _permanentDelete(_BinItem item) async { + final colors = Theme.of(context).colorScheme; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要彻底删除吗?此操作不可恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), + style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6)), + child: const Text('取消')), + ElevatedButton(onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('删除')), + ], + ), + ); + if (confirmed != true || !mounted) return; + final provider = context.read(); + switch (item.type) { + case _BinItemType.movie: await provider.permanentDeleteMovie(item.id); + case _BinItemType.book: await provider.permanentDeleteBook(item.id); + case _BinItemType.note: await provider.permanentDeleteNote(item.id); + case _BinItemType.game: await provider.permanentDeleteGame(item.id); + case _BinItemType.movieReview: await provider.permanentDeleteMovieReview(item.id); + case _BinItemType.bookReview: await provider.permanentDeleteBookReview(item.id); + case _BinItemType.bookExcerpt: await provider.permanentDeleteBookExcerpt(item.id); + case _BinItemType.gameReview: await provider.permanentDeleteGameReview(item.id); + } + if (mounted) { + ToastUtil.show(context, '已彻底删除'); + _loadDeletedItems(); + } + } + + void _showClearAllDialog() { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Row(children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 20), + const SizedBox(width: 8), + Text('清空回收站', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + content: Text('所有项目将被彻底删除,此操作不可恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6)), + child: const Text('取消')), + ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + await context.read().clearRecycleBin(); + if (mounted) { + ToastUtil.show(context, '回收站已清空'); + _loadDeletedItems(); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('清空')), + ], + ), + ); + } +} + +/// 搜索弹窗:本地搜索 / 增强搜索(在线)切换 +class _SearchDialog extends StatefulWidget { + final BuildContext dialogContext; + const _SearchDialog({required this.dialogContext}); + + @override + State<_SearchDialog> createState() => _SearchDialogState(); +} + +class _SearchDialogState extends State<_SearchDialog> { + final _localNavKey = GlobalKey(); + final _onlineNavKey = GlobalKey(); + bool _isOnline = false; + + NavigatorState? get _activeNav => + (_isOnline ? _onlineNavKey : _localNavKey).currentState; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final showToggle = UserPrefs().enhancedSearchEnabled; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + final nav = _activeNav; + if (nav != null && nav.canPop()) { + nav.pop(); + } else { + Navigator.of(widget.dialogContext).pop(); + } + }, + child: Column( + children: [ + if (showToggle) _buildToggle(colors), + Expanded( + child: IndexedStack( + index: _isOnline ? 1 : 0, + children: [ + _buildNav(_localNavKey, const SearchPage()), + _buildNav(_onlineNavKey, const OnlineSearchPage()), + ], + ), + ), + ], + ), + ); + } + + Widget _buildNav(Key key, Widget page) { + return Navigator( + key: key, + onGenerateRoute: (_) => MaterialPageRoute(builder: (_) => page), + ); + } + + Widget _buildToggle(ColorScheme colors) { + return Container( + margin: const EdgeInsets.fromLTRB(12, 10, 12, 6), + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + child: _toggleBtn('本地搜索', !_isOnline, () { + if (!mounted) return; + setState(() => _isOnline = false); + }, colors), + ), + Expanded( + child: _toggleBtn('增强搜索', _isOnline, () { + if (!mounted) return; + setState(() => _isOnline = true); + }, colors), + ), + ], + ), + ); + } + + Widget _toggleBtn(String label, bool selected, VoidCallback onTap, ColorScheme colors) { + return Material( + color: selected ? colors.surface : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + selected ? Icons.search_rounded : Icons.search_outlined, + size: 14, + color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.55), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _IconRailItem extends StatelessWidget { + final IconData icon; + final IconData activeIcon; + final String label; + final Color accentColor; + final bool selected; + final VoidCallback onTap; + + const _IconRailItem({ + required this.icon, + required this.activeIcon, + required this.label, + required this.accentColor, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 1), + child: Material( + color: selected ? accentColor.withValues(alpha: 0.12) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon( + selected ? activeIcon : icon, + size: 18, + color: selected ? accentColor : colors.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(width: 10), + Text( + label, + style: TextStyle( + fontSize: 12, + color: selected ? accentColor : colors.onSurface.withValues(alpha: 0.55), + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ─── 第二栏:列表面板(搜索 + 当前分类列表) ────────────── + +class _DesktopListPanel extends StatefulWidget { + final int mainTabIndex; + + const _DesktopListPanel({required this.mainTabIndex}); + + @override + State<_DesktopListPanel> createState() => _DesktopListPanelState(); +} + +class _DesktopListPanelState extends State<_DesktopListPanel> { + final TextEditingController _searchCtrl = TextEditingController(); + String _keyword = ''; + Timer? _debounce; + + @override + void dispose() { + _searchCtrl.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _onSearchChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 250), () { + if (mounted) setState(() => _keyword = value.trim().toLowerCase()); + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + children: [ + // 顶部搜索栏 + SizedBox(height: MediaQuery.of(context).padding.top), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: SizedBox( + height: 36, + child: TextField( + controller: _searchCtrl, + onChanged: _onSearchChanged, + style: TextStyle(fontSize: 13, color: colors.onSurface), + decoration: InputDecoration( + hintText: '搜索...', + hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35)), + prefixIcon: Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), + suffixIcon: _keyword.isNotEmpty + ? GestureDetector( + onTap: () { + _searchCtrl.clear(); + setState(() => _keyword = ''); + }, + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.4)), + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 0), + filled: true, + fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colors.primary.withValues(alpha: 0.3), width: 1), + ), + ), + ), + ), + ), + const SizedBox(height: 4), + // 列表 + Expanded( + child: _keyword.isNotEmpty + ? _buildSearchResults(context) + : _buildCategoryList(context), + ), + ], + ); + } + + // ─── 分类列表 ────────────────────────────────────────── + + Widget _buildCategoryList(BuildContext context) { + switch (widget.mainTabIndex) { + case 0: return _buildMovieList(context); + case 1: return _buildBookList(context); + case 2: return _buildNoteList(context); + case 3: return _buildGameList(context); + default: return const SizedBox.shrink(); + } + } + + Widget _buildMovieList(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final items = provider.movies.where((m) => !m.isDeleted).toList(); + if (items.isEmpty) return _buildEmpty('暂无影视记录', Icons.movie_outlined); + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: items.length, + itemBuilder: (_, i) { + final m = items[i]; + final (label, color) = _movieStatus(m.status); + return _CompactListItem( + title: m.title, + subtitle: _movieSubtitle(m), + imagePath: m.posterPath, + accentColor: const Color(0xFF2563EB), + icon: Icons.movie_outlined, + selected: provider.selectedMovie?.id == m.id, + statusLabel: label, + statusColor: color, + onTap: () => provider.selectMovie(m), + ); + }, + ); + }, + ); + } + + Widget _buildBookList(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final items = provider.books.where((b) => !b.isDeleted).toList(); + if (items.isEmpty) return _buildEmpty('暂无阅读记录', Icons.menu_book_outlined); + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: items.length, + itemBuilder: (_, i) { + final b = items[i]; + final (label, color) = _bookStatus(b.status); + return _CompactListItem( + title: b.title, + subtitle: b.authors.isNotEmpty ? b.authors.join(', ') : null, + imagePath: b.coverPath, + accentColor: const Color(0xFF16A34A), + icon: Icons.menu_book_outlined, + selected: provider.selectedBook?.id == b.id, + statusLabel: label, + statusColor: color, + onTap: () => provider.selectBook(b), + ); + }, + ); + }, + ); + } + + Widget _buildNoteList(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final items = provider.notes.where((n) => !n.isDeleted).toList(); + if (items.isEmpty) return _buildEmpty('暂无笔记记录', Icons.note_outlined); + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: items.length, + itemBuilder: (_, i) => _CompactListItem( + title: items[i].title.isNotEmpty ? items[i].title : '随手记', + subtitle: items[i].content.length > 40 ? '${items[i].content.substring(0, 40)}...' : (items[i].content.isNotEmpty ? items[i].content : null), + imagePath: null, + accentColor: const Color(0xFF9333EA), + icon: Icons.note_outlined, + selected: provider.selectedNote?.id == items[i].id, + onTap: () { + provider.selectNote(items[i]); + }, + ), + ); + }, + ); + } + + Widget _buildGameList(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final items = provider.games.where((g) => !g.isDeleted).toList(); + if (items.isEmpty) return _buildEmpty('暂无游戏记录', Icons.sports_esports_outlined); + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: items.length, + itemBuilder: (_, i) { + final g = items[i]; + final (label, color) = _gameStatus(g.status); + return _CompactListItem( + title: g.title, + subtitle: g.platforms.isNotEmpty ? g.platforms.join(', ') : null, + imagePath: g.coverPath, + accentColor: const Color(0xFFEA580C), + icon: Icons.sports_esports_outlined, + selected: provider.selectedGame?.id == g.id, + statusLabel: label, + statusColor: color, + onTap: () => provider.selectGame(g), + ); + }, + ); + }, + ); + } + + String? _movieSubtitle(Movie m) { + final parts = []; + if (m.directors.isNotEmpty) parts.add(m.directors.join(', ')); + if ((m.rating ?? 0) > 0) parts.add('⭐ ${m.rating}'); + return parts.isNotEmpty ? parts.join(' · ') : null; + } + + (String, Color) _movieStatus(String status) => switch (status) { + 'watched' => ('已看', const Color(0xFF16A34A)), + 'watching' => ('在看', const Color(0xFF2563EB)), + 'want_to_watch'=> ('想看', const Color(0xFF9CA3AF)), + _ => ('已看', const Color(0xFF16A34A)), + }; + + (String, Color) _bookStatus(String status) => switch (status) { + 'read' => ('已读', const Color(0xFF16A34A)), + 'reading' => ('在读', const Color(0xFF2563EB)), + 'want_to_read' => ('想读', const Color(0xFF9CA3AF)), + 'abandoned' => ('弃读', const Color(0xFFEF4444)), + _ => ('已读', const Color(0xFF16A34A)), + }; + + (String, Color) _gameStatus(String status) => switch (status) { + 'completed' => ('通关', const Color(0xFF16A34A)), + 'playing' => ('在玩', const Color(0xFF2563EB)), + 'want_to_play' => ('想玩', const Color(0xFF9CA3AF)), + 'abandoned' => ('弃游', const Color(0xFFEF4444)), + _ => ('通关', const Color(0xFF16A34A)), + }; + + // ─── 搜索结果 ────────────────────────────────────────── + + Widget _buildSearchResults(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final provider = context.watch(); + + final movies = provider.movies.where((m) => !m.isDeleted && _matchMovie(m, _keyword)).toList(); + final books = provider.books.where((b) => !b.isDeleted && _matchBook(b, _keyword)).toList(); + final notes = provider.notes.where((n) => !n.isDeleted && _matchNote(n, _keyword)).toList(); + final games = provider.games.where((g) => !g.isDeleted && _matchGame(g, _keyword)).toList(); + + final totalCount = movies.length + books.length + notes.length + games.length; + + if (totalCount == 0) { + return Center( + child: Text('未找到相关结果', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))), + ); + } + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 4), + children: [ + if (movies.isNotEmpty) ...[ + _SearchGroupHeader(label: '影视', count: movies.length, color: const Color(0xFF2563EB)), + ...movies.map((m) { + final (label, color) = _movieStatus(m.status); + return _CompactListItem( + title: m.title, + subtitle: _movieSubtitle(m), + imagePath: m.posterPath, + accentColor: const Color(0xFF2563EB), + icon: Icons.movie_outlined, + selected: provider.selectedMovie?.id == m.id, + statusLabel: label, + statusColor: color, + onTap: () { + provider.setMainTabIndex(0); + provider.selectMovie(m); + }, + ); + }), + ], + if (books.isNotEmpty) ...[ + _SearchGroupHeader(label: '阅读', count: books.length, color: const Color(0xFF16A34A)), + ...books.map((b) { + final (label, color) = _bookStatus(b.status); + return _CompactListItem( + title: b.title, + subtitle: b.authors.isNotEmpty ? b.authors.join(', ') : null, + imagePath: b.coverPath, + accentColor: const Color(0xFF16A34A), + icon: Icons.menu_book_outlined, + selected: provider.selectedBook?.id == b.id, + statusLabel: label, + statusColor: color, + onTap: () { + provider.setMainTabIndex(1); + provider.selectBook(b); + }, + ); + }), + ], + if (notes.isNotEmpty) ...[ + _SearchGroupHeader(label: '笔记', count: notes.length, color: const Color(0xFF9333EA)), + ...notes.map((n) => _CompactListItem( + title: n.title.isNotEmpty ? n.title : '随手记', + subtitle: n.content.length > 40 ? '${n.content.substring(0, 40)}...' : null, + imagePath: null, + accentColor: const Color(0xFF9333EA), + icon: Icons.note_outlined, + selected: provider.selectedNote?.id == n.id, + onTap: () { + provider.setMainTabIndex(2); + provider.selectNote(n); + }, + )), + ], + if (games.isNotEmpty) ...[ + _SearchGroupHeader(label: '游戏', count: games.length, color: const Color(0xFFEA580C)), + ...games.map((g) { + final (label, color) = _gameStatus(g.status); + return _CompactListItem( + title: g.title, + subtitle: g.platforms.isNotEmpty ? g.platforms.join(', ') : null, + imagePath: g.coverPath, + accentColor: const Color(0xFFEA580C), + icon: Icons.sports_esports_outlined, + selected: provider.selectedGame?.id == g.id, + statusLabel: label, + statusColor: color, + onTap: () { + provider.setMainTabIndex(3); + provider.selectGame(g); + }, + ); + }), + ], + ], + ); + } + + bool _matchMovie(Movie m, String kw) { + return m.title.toLowerCase().contains(kw) || + (m.summary?.toLowerCase().contains(kw) ?? false) || + m.genres.any((g) => g.toLowerCase().contains(kw)) || + m.directors.any((d) => d.toLowerCase().contains(kw)) || + m.actors.any((a) => a.toLowerCase().contains(kw)); + } + + bool _matchBook(Book b, String kw) { + return b.title.toLowerCase().contains(kw) || + (b.summary?.toLowerCase().contains(kw) ?? false) || + b.authors.any((a) => a.toLowerCase().contains(kw)); + } + + bool _matchNote(Note n, String kw) { + return n.title.toLowerCase().contains(kw) || + n.content.toLowerCase().contains(kw) || + n.tags.any((t) => t.toLowerCase().contains(kw)); + } + + bool _matchGame(Game g, String kw) { + return g.title.toLowerCase().contains(kw) || + g.genres.any((ge) => ge.toLowerCase().contains(kw)) || + g.platforms.any((p) => p.toLowerCase().contains(kw)); + } + + Widget _buildEmpty(String text, IconData icon) { + final colors = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 36, color: colors.onSurface.withValues(alpha: 0.15)), + const SizedBox(height: 8), + Text(text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ), + ); + } +} + +// ─── 紧凑列表项 ────────────────────────────────────────── + +class _CompactListItem extends StatelessWidget { + final String title; + final String? subtitle; + final String? imagePath; + final Color accentColor; + final IconData icon; + final bool selected; + final VoidCallback onTap; + final String? statusLabel; // 状态文字,如"已看""想看" + final Color? statusColor; // 状态颜色 + + const _CompactListItem({ + required this.title, + this.subtitle, + this.imagePath, + required this.accentColor, + required this.icon, + required this.selected, + required this.onTap, + this.statusLabel, + this.statusColor, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final isDark = colors.brightness == Brightness.dark; + final effectiveStatusColor = statusColor ?? accentColor; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + child: Material( + color: selected + ? accentColor.withValues(alpha: isDark ? 0.12 : 0.06) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + child: Row( + children: [ + // 左侧状态指示条 + Container( + width: 3, + height: 28, + decoration: BoxDecoration( + color: statusLabel != null ? effectiveStatusColor.withValues(alpha: 0.6) : Colors.transparent, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + // 缩略图 / 图标占位 + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + clipBehavior: Clip.antiAlias, + child: imagePath != null && imagePath!.isNotEmpty + ? FadeInLocalImage( + path: imagePath!, + fit: BoxFit.cover, + errorWidget: Icon(icon, size: 18, color: accentColor.withValues(alpha: 0.5)), + ) + : Icon(icon, size: 18, color: accentColor.withValues(alpha: 0.5)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? accentColor : colors.onSurface, + )), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: 1), + Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ], + ), + ), + // 状态标签 + if (statusLabel != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: effectiveStatusColor.withValues(alpha: isDark ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(4), + ), + child: Text(statusLabel!, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: effectiveStatusColor)), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +// ─── 搜索分组标题 ────────────────────────────────────── + +class _SearchGroupHeader extends StatelessWidget { + final String label; + final int count; + final Color color; + + const _SearchGroupHeader({required this.label, required this.count, required this.color}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(18, 12, 18, 4), + child: Row( + children: [ + Container( + width: 8, height: 8, + decoration: BoxDecoration(color: color.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 6), + Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(width: 4), + Text('$count', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ], + ), + ); + } +} diff --git a/lib/pages/home/main_content_page.dart b/lib/pages/home/main_content_page.dart index a93f32d..475a28d 100644 --- a/lib/pages/home/main_content_page.dart +++ b/lib/pages/home/main_content_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../utils/user_prefs.dart'; +import '../../utils/responsive.dart'; import '../../services/sync/webdav_service.dart'; import '../movies/movie_tab_page.dart'; import '../book/book_tab_page.dart'; @@ -87,8 +88,10 @@ class _MainContentPageState extends State { Widget build(BuildContext context) { return Column( children: [ - _buildAppBar(context), - _buildTabBar(context), + if (!Breakpoint.isDesktop(context)) ...[ + _buildAppBar(context), + _buildTabBar(context), + ], Expanded(child: _buildTabContent()), ], ); diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 66826c0..ac14e3e 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -8,6 +8,7 @@ import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/user_prefs.dart'; import '../../utils/toast_util.dart'; +import '../../utils/responsive.dart'; import 'movie_reviews_page.dart'; import 'movie_posters_page.dart'; import 'movie_share_page.dart'; @@ -63,11 +64,231 @@ class _MovieDetailPageState extends State { .where((m) => m.id == widget.movie.id) .firstOrNull ?? widget.movie; + if (Breakpoint.isDesktop(context)) { + return _buildDesktopStyle(movie, colors); + } return _detailStyle == 1 ? _buildOverlayStyle(movie, colors) : _buildStandardStyle(movie, colors); } + /// 桌面端左右分栏布局 + Widget _buildDesktopStyle(Movie movie, ColorScheme colors) { + final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty; + return Scaffold( + backgroundColor: colors.surface, + body: Column( + children: [ + // 顶栏 + Container( + height: 48, + decoration: BoxDecoration( + color: colors.surface, + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row(children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18), + onPressed: widget.embedded + ? () => context.read().selectMovie(null) + : () => Navigator.pop(context), + ), + Expanded( + child: Text(movie.title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 4), + ]), + ), + // 主体:左封面 + 右信息 + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 左侧封面 + Container( + width: 240, + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Container( + width: 200, + height: 280, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + boxShadow: hasPoster + ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4))] + : null, + ), + clipBehavior: Clip.antiAlias, + child: hasPoster + ? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover) + : Center(child: Icon(Icons.movie_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.25))), + ), + ], + ), + ), + // 右侧信息(可滚动) + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(0, 20, 24, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text(movie.title, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)), + if (movie.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(movie.alternateTitles.join(' / '), + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)), + ], + const SizedBox(height: 16), + // 评分 + 状态 + 分类 + Row(children: [ + if (movie.rating != null) ...[ + Icon(Icons.star, size: 20, color: colors.onSurface), + const SizedBox(width: 4), + Text(movie.rating!.toStringAsFixed(1), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(width: 16), + ], + _buildStatusTag(movie), + const SizedBox(width: 6), + _buildCategoryTag(movie), + ]), + const SizedBox(height: 8), + if (movie.releaseDate != null) + GestureDetector( + onTap: _toggleDateDisplay, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text( + _showExactDate + ? '${movie.releaseDate!.year}年${movie.releaseDate!.month.toString().padLeft(2, '0')}月${movie.releaseDate!.day.toString().padLeft(2, '0')}日上映' + : '${movie.releaseDate!.year}年${movie.releaseDate!.month.toString().padLeft(2, '0')}月上映', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4)), + ), + const SizedBox(width: 4), + Icon(Icons.tune, size: 14, color: colors.onSurface.withValues(alpha: 0.2)), + ]), + ), + if (movie.watchDate != null) ...[ + const SizedBox(height: 4), + Text('观看于 ${_formatDate(movie.watchDate!)}', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + ], + Divider(height: 32, thickness: 0.5, color: colors.outline), + // 详细信息 + if (movie.directors.isNotEmpty) _buildDesktopInfoRow('导演', movie.directors.join(','), colors), + if (movie.writers.isNotEmpty) _buildDesktopInfoRow('编剧', movie.writers.join(','), colors), + if (movie.actors.isNotEmpty) _buildDesktopInfoRow('主演', movie.actors.join(','), colors), + if (movie.genres.isNotEmpty) ...[ + const SizedBox(height: 8), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 56, child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Wrap(spacing: 8, runSpacing: 8, + children: movie.genres.map((g) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)), + child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + )).toList(), + )), + ]), + ], + if (movie.summary != null && movie.summary!.isNotEmpty) ...[ + Divider(height: 32, thickness: 0.5, color: colors.outline), + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 12), + Text(movie.summary!, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)), + ], + Divider(height: 32, thickness: 0.5, color: colors.outline), + // 更多 + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 16), + _buildExtraSectionItem( + icon: Icons.rate_review_outlined, + title: '影评', + subtitleFuture: context.read().getMovieReviewCount(movie.id), + emptyText: '暂无影评', + unit: '条影评', + onTap: () => _navigateToReviews(movie), + ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.photo_library_outlined, + title: '海报墙', + subtitleFuture: context.read().getMoviePosterCount(movie.id), + emptyText: '暂无海报', + unit: '张海报', + onTap: () => _navigateToPosters(movie), + ), + ], + ), + ), + ), + ], + ), + ), + // 底部操作栏 + Container( + height: 56, + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: () => _showDeleteDialog(context), + icon: Icon(Icons.delete_outline, size: 16, color: colors.error), + label: Text('删除', style: TextStyle(color: colors.error)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.error.withValues(alpha: 0.3)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: () => _navigateToEdit(context), + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 56, child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5))), + ], + ), + ); + } + /// 标准样式 Widget _buildStandardStyle(Movie movie, ColorScheme colors) { final topSafe = MediaQuery.of(context).padding.top; @@ -121,7 +342,7 @@ class _MovieDetailPageState extends State { const SizedBox(width: 4), IconButton( icon: widget.embedded - ? Icon(Icons.close, color: colors.onSurface, size: 18) + ? Icon(Icons.arrow_back, color: colors.onSurface, size: 18) : Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), onPressed: widget.embedded ? () => context.read().selectMovie(null) @@ -192,7 +413,7 @@ class _MovieDetailPageState extends State { const SizedBox(width: 4), IconButton( icon: widget.embedded - ? const Icon(Icons.close, color: Colors.white, size: 18) + ? const Icon(Icons.arrow_back, color: Colors.white, size: 18) : const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), onPressed: widget.embedded ? () => context.read().selectMovie(null) @@ -372,14 +593,16 @@ class _MovieDetailPageState extends State { backgroundColor: colors.error, foregroundColor: colors.onError, ), - const SizedBox(height: 12), - _buildFloatingButton( - icon: Icons.share_outlined, - onPressed: () => _showSharePoster(movie), - tooltip: '分享海报', - backgroundColor: const Color(0xFF4CAF50), - foregroundColor: Colors.white, - ), + if (!Platform.isWindows) ...[ + const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.share_outlined, + onPressed: () => _showSharePoster(movie), + tooltip: '分享海报', + backgroundColor: const Color(0xFF4CAF50), + foregroundColor: Colors.white, + ), + ], ], ); } diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 2d2bf10..a6809bf 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -1362,6 +1362,7 @@ class _MovieFormPageState extends State { ); await context.read().addMovie(newMovie); + await context.read().loadMovies(); } else { final updatedMovie = widget.movie!.copyWith( title: _titleController.text.trim(), diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index 91394fe..8eab4cd 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -46,6 +46,7 @@ class _MovieTabPageState extends State { super.initState(); _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; final provider = context.read(); _provider = provider; provider.addListener(_onDataChanged); diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index b2d78aa..133e798 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -1,9 +1,11 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; +import '../../utils/responsive.dart'; import 'note_share_page.dart'; /// 笔记详情页 @@ -28,12 +30,15 @@ class _NoteDetailPageState extends State { orElse: () => widget.note, ); + if (Breakpoint.isDesktop(context)) { + return _buildDesktopStyle(note, colors); + } return Scaffold( backgroundColor: colors.surface, appBar: AppBar( leading: widget.embedded ? IconButton( - icon: const Icon(Icons.close), + icon: const Icon(Icons.arrow_back), onPressed: () => context.read().selectNote(null), ) : null, @@ -123,6 +128,112 @@ class _NoteDetailPageState extends State { ); } + /// 桌面端布局 + Widget _buildDesktopStyle(Note note, ColorScheme colors) { + return Scaffold( + backgroundColor: colors.surface, + body: Column( + children: [ + // 顶栏 + Container( + height: 48, + decoration: BoxDecoration( + color: colors.surface, + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row(children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18), + onPressed: widget.embedded + ? () => context.read().selectNote(null) + : () => Navigator.pop(context), + ), + Expanded( + child: Text( + note.title.isNotEmpty ? note.title : _truncateContent(note.content), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 4), + ]), + ), + // 日期信息栏 + Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row( + children: [ + Text('${note.createdAt.day}', + style: TextStyle(fontSize: 30, fontWeight: FontWeight.w200, color: colors.onSurface.withValues(alpha: 0.75), height: 1.0)), + const SizedBox(width: 8), + Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text('${note.createdAt.year}/${note.createdAt.month.toString().padLeft(2, '0')} 周${_weekdays[note.createdAt.weekday - 1]}', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.55))), + const SizedBox(height: 1), + Text('${note.createdAt.hour.toString().padLeft(2, '0')}:${note.createdAt.minute.toString().padLeft(2, '0')}', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + const Spacer(), + Text('${note.content.length} 字', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ), + ), + if (note.tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6, left: 24, right: 24), + child: _buildTagRow(note.tags), + ), + // 内容 + Expanded( + child: Markdown( + data: note.content, + styleSheet: _buildMarkdownStyleSheet(colors), + padding: const EdgeInsets.all(24), + // ignore: deprecated_member_use + imageBuilder: (uri, title, alt) => _buildMarkdownImage(uri, note), + ), + ), + if (note.images.isNotEmpty) _buildImageRow(note.images), + // 底部操作栏 + Container( + height: 56, + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: () => _showDeleteDialog(context), + icon: Icon(Icons.delete_outline, size: 16, color: colors.error), + label: Text('删除', style: TextStyle(color: colors.error)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.error.withValues(alpha: 0.3)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: () => _navigateToEdit(context), + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ], + ), + ), + ], + ), + ); + } + Widget _buildTagRow(List tags) { final colors = Theme.of(context).colorScheme; return Container( @@ -324,14 +435,16 @@ class _NoteDetailPageState extends State { backgroundColor: colors.error, foregroundColor: colors.onError, ), - const SizedBox(height: 12), - _buildFloatingButton( - icon: Icons.share_outlined, - onPressed: _shareNote, - tooltip: '分享', - backgroundColor: const Color(0xFF4CAF50), - foregroundColor: Colors.white, - ), + if (!Platform.isWindows) ...[ + const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.share_outlined, + onPressed: _shareNote, + tooltip: '分享', + backgroundColor: const Color(0xFF4CAF50), + foregroundColor: Colors.white, + ), + ], ], ); } @@ -368,6 +481,7 @@ class _NoteDetailPageState extends State { } void _showDeleteDialog(BuildContext context) { + final errorColor = Theme.of(context).colorScheme.error; showDialog( context: context, builder: (ctx) => AlertDialog( @@ -388,7 +502,7 @@ class _NoteDetailPageState extends State { Navigator.pop(context); } }, - child: Text('删除', style: TextStyle(color: Theme.of(context).colorScheme.error)), + child: Text('删除', style: TextStyle(color: errorColor)), ), ], ), diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 6c15b0a..72de466 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -38,6 +38,7 @@ class _NoteTabPageState extends State { _layoutStyle = UserPrefs().noteLayoutStyle; _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; final provider = context.read(); _provider = provider; provider.addListener(_onDataChanged); diff --git a/lib/pages/online_search/book_detail_page.dart b/lib/pages/online_search/book_detail_page.dart index 60911ac..17ebbce 100644 --- a/lib/pages/online_search/book_detail_page.dart +++ b/lib/pages/online_search/book_detail_page.dart @@ -196,6 +196,7 @@ class _BookDetailPageState extends State { if (!mounted) return; final provider = context.read(); await provider.addBook(book); + await provider.loadBooks(); if (mounted) { setState(() { @@ -334,107 +335,111 @@ class _BookDetailPageState extends State { final pages = m['pagination']; final coverUrl = cover.toString().isNotEmpty ? _resolveCoverUrl(cover.toString()) : ''; - return Column(children: [ - // 顶部固定区域 - Container( - color: colors.surface, - child: SafeArea( - bottom: false, - child: Column(children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row(children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - shape: BoxShape.circle), - child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface)), + return NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverToBoxAdapter( + child: Container( + color: colors.surface, + child: SafeArea( + bottom: false, + child: Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row(children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + shape: BoxShape.circle), + child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface)), + ), + ]), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 110, + height: 160, + child: coverUrl.isNotEmpty + ? Image.network(coverUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _coverPlaceholder(colors)) + : _coverPlaceholder(colors), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)), + if (author.toString().isNotEmpty) ...[ + const SizedBox(height: 8), + Text(author, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + ], + if (press.toString().isNotEmpty) ...[ + const SizedBox(height: 6), + Text(press, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))), + ], + if (year.toString().isNotEmpty) ...[ + const SizedBox(height: 6), + Text('出版年份:$year', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ], + if (isbn.toString().isNotEmpty) ...[ + const SizedBox(height: 4), + Text('ISBN:$isbn', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ], + if (pages != null && pages != 0) ...[ + const SizedBox(height: 4), + Text('页数:$pages', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ], + if (_localBook != null) ...[ + const SizedBox(height: 10), + _buildLocalStatus(colors), + ], + ]), + ), + ]), ), ]), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - width: 110, - height: 160, - child: coverUrl.isNotEmpty - ? Image.network(coverUrl, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _coverPlaceholder(colors)) - : _coverPlaceholder(colors), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)), - if (author.toString().isNotEmpty) ...[ - const SizedBox(height: 8), - Text(author, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), - ], - if (press.toString().isNotEmpty) ...[ - const SizedBox(height: 6), - Text(press, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))), - ], - if (year.toString().isNotEmpty) ...[ - const SizedBox(height: 6), - Text('出版年份:$year', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), - ], - if (isbn.toString().isNotEmpty) ...[ - const SizedBox(height: 4), - Text('ISBN:$isbn', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), - ], - if (pages != null && pages != 0) ...[ - const SizedBox(height: 4), - Text('页数:$pages', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), - ], - if (_localBook != null) ...[ - const SizedBox(height: 10), - _buildLocalStatus(colors), - ], - ]), - ), - ]), - ), - ]), + ), ), - ), - - // Tab 栏 - Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), - child: Row(children: [ - _buildTabButton('基础信息', 0), - _buildTabButton('国图信息', 1), - _buildTabButton('网购地址', 2), - _buildTabButton('书籍目录', 3), - ]), - ), - - // 内容区 - Expanded( - child: _currentTab == 0 - ? _buildBasicInfo(colors) - : _currentTab == 1 - ? _buildOpacTab(colors) - : _currentTab == 2 - ? _buildOnlineTab(colors) - : _buildCatalogTab(colors), - ), - ]); + // Tab 栏:吸顶 + SliverPersistentHeader( + pinned: true, + delegate: _StickyTabBarDelegate( + child: Container( + decoration: BoxDecoration( + color: colors.surface, + border: Border( + bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), + child: Row(children: [ + _buildTabButton('基础信息', 0), + _buildTabButton('国图信息', 1), + _buildTabButton('网购地址', 2), + _buildTabButton('书籍目录', 3), + ]), + ), + ), + ), + ], + body: _currentTab == 0 + ? _buildBasicInfo(colors) + : _currentTab == 1 + ? _buildOpacTab(colors) + : _currentTab == 2 + ? _buildOnlineTab(colors) + : _buildCatalogTab(colors), + ); } Widget _buildTabButton(String label, int index) { @@ -753,3 +758,20 @@ class _BookDetailPageState extends State { ]); } } + +class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + _StickyTabBarDelegate({required this.child}); + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child; + + @override + double get minExtent => 44; + + @override + double get maxExtent => 44; + + @override + bool shouldRebuild(_StickyTabBarDelegate oldDelegate) => child != oldDelegate.child; +} diff --git a/lib/pages/online_search/movie_detail_page.dart b/lib/pages/online_search/movie_detail_page.dart index d82469c..13f2d18 100644 --- a/lib/pages/online_search/movie_detail_page.dart +++ b/lib/pages/online_search/movie_detail_page.dart @@ -173,6 +173,7 @@ class _MovieDetailPageState extends State { if (!mounted) return; final provider = context.read(); await provider.addMovie(movie); + await provider.loadMovies(); if (mounted) { setState(() { @@ -328,139 +329,138 @@ class _MovieDetailPageState extends State { final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / '); - return Column(children: [ - // 顶部:AppBar + 海报信息区 - Container( - color: colors.surface, - child: SafeArea( - bottom: false, - child: Column(children: [ - // AppBar - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row(children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - shape: BoxShape.circle), - child: Icon(Icons.arrow_back, - size: 20, color: colors.onSurface)), - ), - const Spacer(), - GestureDetector( - onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0), - child: Container( - width: 36, - height:36, - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - shape: BoxShape.circle), - child: Icon( - _detailStyle == 0 - ? Icons.crop_landscape_rounded - : Icons.grid_view_rounded, - size: 18, - color: colors.onSurface)), - ), - ]), - ), - // 海报 + 信息 - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 海报 - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - width: 120, - height: 170, - child: pic.toString().isNotEmpty - ? Image.network(pic, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - _posterPlaceholder(colors)) - : _posterPlaceholder(colors), + return NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverToBoxAdapter( + child: Container( + color: colors.surface, + child: SafeArea( + bottom: false, + child: Column(children: [ + // AppBar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row(children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + shape: BoxShape.circle), + child: Icon(Icons.arrow_back, + size: 20, color: colors.onSurface)), + ), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + shape: BoxShape.circle), + child: Icon( + _detailStyle == 0 + ? Icons.crop_landscape_rounded + : Icons.grid_view_rounded, + size: 18, + color: colors.onSurface)), + ), + ]), + ), + // 海报 + 信息 + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 120, + height: 170, + child: pic.toString().isNotEmpty + ? Image.network(pic, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + _posterPlaceholder(colors)) + : _posterPlaceholder(colors), + ), ), - ), - const SizedBox(width: 16), - // 信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: colors.onSurface)), - const SizedBox(height: 8), - // 评分 - if (score.toString().isNotEmpty && score != '0.0') ...[ - Row(children: [ - Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)), - const SizedBox(width: 3), - Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), - ]), - const SizedBox(height: 2), - Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))), - const SizedBox(height: 8), - ], - // 完结状态 - _endTag(isEnd), - if (metaParts.isNotEmpty) ...[ - const SizedBox(height: 8), - Text(metaParts, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: TextStyle( - fontSize: 12, - color: colors.onSurface - .withValues(alpha: 0.5))), - ], - if (typeParts.isNotEmpty) ...[ - const SizedBox(height: 3), - Text(typeParts, - style: TextStyle( - fontSize: 11, - color: colors.onSurface - .withValues(alpha: 0.4)), - maxLines: 1, - overflow: TextOverflow.ellipsis), - ], - // 本地状态 - if (_localMovie != null) ...[ - const SizedBox(height: 10), - _buildLocalStatus(colors), - ], - ]), - ), - ]), - ), - ])), - ), - - // Tab 栏 - Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), - child: Row(children: [ - _buildTabButton('概要', 0), - _buildTabButton('演职人员', 1), - ]), - ), - - // 内容区 - Expanded( - child: - _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors), - ), - ]); + fontSize: 18, + fontWeight: FontWeight.w700, + color: colors.onSurface)), + const SizedBox(height: 8), + if (score.toString().isNotEmpty && score != '0.0') ...[ + Row(children: [ + Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ]), + const SizedBox(height: 2), + Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 8), + ], + _endTag(isEnd), + if (metaParts.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(metaParts, + style: TextStyle( + fontSize: 12, + color: colors.onSurface + .withValues(alpha: 0.5))), + ], + if (typeParts.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(typeParts, + style: TextStyle( + fontSize: 11, + color: colors.onSurface + .withValues(alpha: 0.4)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + if (_localMovie != null) ...[ + const SizedBox(height: 10), + _buildLocalStatus(colors), + ], + ]), + ), + ]), + ), + ]), + ), + ), + ), + // Tab 栏:吸顶 + SliverPersistentHeader( + pinned: true, + delegate: _StickyTabBarDelegate( + child: Container( + decoration: BoxDecoration( + color: colors.surface, + border: Border( + bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), + child: Row(children: [ + _buildTabButton('概要', 0), + _buildTabButton('演职人员', 1), + ]), + ), + ), + ), + ], + body: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors), + ); } // ── 沉浸式布局 ────────────────────────────────────────── @@ -478,116 +478,115 @@ class _MovieDetailPageState extends State { final metaParts = [year, area].where((s) => s.toString().isNotEmpty).join(' · '); final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / '); - return Column(children: [ - // 全宽海报区 - Stack(children: [ - // 海报图 - SizedBox( - width: double.infinity, - height: 320, - child: pic.toString().isNotEmpty - ? Image.network(pic, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest)) - : Container(color: colors.surfaceContainerHighest, - child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))), - ), - // 渐变遮罩 - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)], - stops: const [0.35, 1.0], + return NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverToBoxAdapter( + child: Stack(children: [ + SizedBox( + width: double.infinity, + height: 320, + child: pic.toString().isNotEmpty + ? Image.network(pic, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest)) + : Container(color: colors.surfaceContainerHighest, + child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))), + ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)], + stops: const [0.35, 1.0], + ), + ), ), ), + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row(children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 36, height: 36, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), + child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)), + ), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _detailStyle = 0), + child: Container( + width: 36, height: 36, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), + child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)), + ), + ]), + ), + ), + Positioned( + left: 16, right: 16, bottom: 18, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(name, maxLines: 2, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)), + const SizedBox(height: 8), + Row(children: [ + if (score.toString().isNotEmpty && score != '0.0') ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400), + const SizedBox(width: 3), + Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)), + ], + ), + ), + const SizedBox(width: 10), + ], + _endTag(isEnd), + if (metaParts.isNotEmpty) ...[ + const SizedBox(width: 8), + Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))), + ], + ]), + if (typeParts.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))), + ], + if (_localMovie != null) ...[ + const SizedBox(height: 8), + _buildLocalStatus(colors), + ], + ]), + ), + ]), + ), + SliverPersistentHeader( + pinned: true, + delegate: _StickyTabBarDelegate( + child: Container( + decoration: BoxDecoration( + color: colors.surface, + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), + child: Row(children: [ + _buildTabButton('概要', 0), + _buildTabButton('演职人员', 1), + ]), + ), ), ), - // 顶部按钮 - SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row(children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 36, height: 36, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), - child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)), - ), - const Spacer(), - GestureDetector( - onTap: () => setState(() => _detailStyle = 0), - child: Container( - width: 36, height: 36, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle), - child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)), - ), - ]), - ), - ), - // 底部信息叠加 - Positioned( - left: 16, right: 16, bottom: 18, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(name, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 8), - Row(children: [ - if (score.toString().isNotEmpty && score != '0.0') ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400), - const SizedBox(width: 3), - Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)), - ], - ), - ), - const SizedBox(width: 10), - ], - _endTag(isEnd), - if (metaParts.isNotEmpty) ...[ - const SizedBox(width: 8), - Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))), - ], - ]), - if (typeParts.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))), - ], - if (_localMovie != null) ...[ - const SizedBox(height: 8), - _buildLocalStatus(colors), - ], - ]), - ), - ]), - - // Tab 栏 - Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), - child: Row(children: [ - _buildTabButton('概要', 0), - _buildTabButton('演职人员', 1), - ]), - ), - - // 内容区 - Expanded( - child: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors), - ), - ]); + ], + body: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors), + ); } Widget _posterPlaceholder(ColorScheme colors) { @@ -908,3 +907,20 @@ class _MovieDetailPageState extends State { ); } } + +class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + _StickyTabBarDelegate({required this.child}); + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child; + + @override + double get minExtent => 44; + + @override + double get maxExtent => 44; + + @override + bool shouldRebuild(_StickyTabBarDelegate oldDelegate) => child != oldDelegate.child; +} diff --git a/lib/pages/profile/profile_page.dart b/lib/pages/profile/profile_page.dart index a6eb359..d1a92ad 100644 --- a/lib/pages/profile/profile_page.dart +++ b/lib/pages/profile/profile_page.dart @@ -2,14 +2,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import '../../main.dart' show routeObserver; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; import '../../utils/user_prefs.dart'; +import '../../utils/responsive.dart'; import '../../utils/toast_util.dart'; +import '../../utils/image_path_helper.dart'; import '../settings/recycle_bin_page.dart'; import '../sync/backup_page.dart'; import '../../widgets/fade_in_local_image.dart'; @@ -77,12 +78,14 @@ class _ProfilePageState extends State with RouteAware { AppBar( titleSpacing: 8, leadingWidth: 44, - leading: Builder( - builder: (context) => IconButton( - icon: Icon(Icons.menu, color: colors.onSurface), - onPressed: () => Scaffold.of(context).openDrawer(), - ), - ), + leading: Breakpoint.isDesktop(context) + ? const SizedBox.shrink() + : Builder( + builder: (context) => IconButton( + icon: Icon(Icons.menu, color: colors.onSurface), + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), title: const Text('我的'), ), Expanded( @@ -998,10 +1001,10 @@ class _ProfilePageState extends State with RouteAware { maxHeight: 400, imageQuality: 85); if (pickedFile != null) { - final appDir = await getApplicationDocumentsDirectory(); + final appDirPath = await ImagePathHelper.getAppDir(); final fileName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final savedPath = path.join(appDir.path, 'avatars', fileName); - final avatarDir = Directory(path.join(appDir.path, 'avatars')); + final savedPath = path.join(appDirPath, 'avatars', fileName); + final avatarDir = Directory(path.join(appDirPath, 'avatars')); if (!await avatarDir.exists()) await avatarDir.create(recursive: true); await File(pickedFile.path).copy(savedPath); await _userPrefs.setAvatarPath(savedPath); diff --git a/lib/pages/profile/settings_page.dart b/lib/pages/profile/settings_page.dart index a02c710..4e99ac8 100644 --- a/lib/pages/profile/settings_page.dart +++ b/lib/pages/profile/settings_page.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +11,7 @@ import '../../providers/app_provider.dart'; import '../../utils/user_prefs.dart'; import '../../utils/theme/app_theme.dart'; import '../../utils/toast_util.dart'; +import '../../utils/image_path_helper.dart'; import '../../data/database_helper.dart'; import '../../services/sync/cache_cleaner.dart'; import '../online_search/enhanced_search_settings_page.dart'; @@ -65,30 +67,32 @@ class _SettingsPageState extends State { indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildNavigationItem( - icon: Icons.tune_outlined, - title: '功能设置', - subtitle: '启动标签、模块开关、侧边栏功能', - onTap: () => Navigator.push(context, - MaterialPageRoute(builder: (_) => const FeatureSettingsPage())), - ), - Divider( - height: 0.5, - indent: 24, - endIndent: 24, - color: colors.outlineVariant), - _buildNavigationItem( - icon: Icons.dashboard_outlined, - title: '布局设置', - subtitle: '影视、阅读、笔记的展示样式', - onTap: () => Navigator.push(context, - MaterialPageRoute(builder: (_) => const LayoutSettingsPage())), - ), - Divider( - height: 0.5, - indent: 24, - endIndent: 24, - color: colors.outlineVariant), + if (!Platform.isWindows) ...[ + _buildNavigationItem( + icon: Icons.tune_outlined, + title: '功能设置', + subtitle: '启动标签、模块开关、侧边栏功能', + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const FeatureSettingsPage())), + ), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildNavigationItem( + icon: Icons.dashboard_outlined, + title: '布局设置', + subtitle: '影视、阅读、笔记的展示样式', + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const LayoutSettingsPage())), + ), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + ], _buildThemeModeSelector(), Divider( height: 0.5, @@ -101,7 +105,7 @@ class _SettingsPageState extends State { indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildFontSelector(), + if (!Platform.isWindows) _buildFontSelector(), _buildSectionHeader('其他设置'), _buildActionItem( icon: Icons.person_outline, @@ -128,18 +132,20 @@ class _SettingsPageState extends State { indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildSwitchItem( - icon: Icons.swipe_vertical_outlined, - title: '底部导航栏滚动隐藏', - subtitle: '下滑时自动隐藏底部导航栏', - value: _hideBottomNavOnScroll, - onChanged: _toggleHideBottomNavOnScroll, - ), - Divider( - height: 0.5, - indent: 24, - endIndent: 24, - color: colors.outlineVariant), + if (!Platform.isWindows) ...[ + _buildSwitchItem( + icon: Icons.swipe_vertical_outlined, + title: '底部导航栏滚动隐藏', + subtitle: '下滑时自动隐藏底部导航栏', + value: _hideBottomNavOnScroll, + onChanged: _toggleHideBottomNavOnScroll, + ), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + ], _buildSectionHeader('数据管理'), _buildActionItem( icon: Icons.cleaning_services_outlined, @@ -152,17 +158,19 @@ class _SettingsPageState extends State { indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildActionItem( - icon: Icons.folder_outlined, - title: '获取系统权限', - subtitle: '前往系统设置开启存储权限', - onTap: _showStoragePermissionDialog, - ), - Divider( - height: 0.5, - indent: 24, - endIndent: 24, - color: colors.outlineVariant), + if (!Platform.isWindows) ...[ + _buildActionItem( + icon: Icons.folder_outlined, + title: '获取系统权限', + subtitle: '前往系统设置开启存储权限', + onTap: _showStoragePermissionDialog, + ), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + ], _buildSectionHeader('帮助'), _buildActionItem( icon: Icons.language_outlined, @@ -944,11 +952,10 @@ class _SettingsPageState extends State { builder: (_) => Center(child: CircularProgressIndicator(color: colors.primary)), ); - final appProvider = pageContext.read(); - final dbImagePaths = await _getAllDbImagePaths(appProvider); + final dbImagePaths = await _getAllDbImagePaths(); final imageInfo = await _scanImageDirectory(dbImagePaths); - final epubInfo = await _scanOrphanedEpubBooks(appProvider); + final epubInfo = await _scanOrphanedEpubBooks(); final tempInfo = await _scanTempDirectory(); final emptyDirInfo = await _scanEmptyDirectories(); @@ -1036,7 +1043,7 @@ class _SettingsPageState extends State { context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator())); - final result = await CacheCleaner.instance.clean(context.read()); + final result = await CacheCleaner.instance.clean(); Navigator.pop(context); if (context.mounted) { if (result.total == 0) { @@ -1051,42 +1058,69 @@ class _SettingsPageState extends State { } } - Future> _getAllDbImagePaths(AppProvider provider) async { + /// 直接查 DB 收集所有图片路径(含软删除记录,与 CacheCleaner 保持一致) + Future> _getAllDbImagePaths() async { + final db = await DatabaseHelper.instance.database; final paths = {}; - for (final movie in provider.movies) { - if (movie.posterPath?.isNotEmpty == true) paths.add(movie.posterPath!); + + final movies = await db.query('movies', columns: ['poster_path']); + for (final m in movies) { + final p = m['poster_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } - for (final book in provider.books) { - if (book.coverPath?.isNotEmpty == true) paths.add(book.coverPath!); + + final books = await db.query('books', columns: ['cover_path']); + for (final b in books) { + final p = b['cover_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } - for (final note in provider.notes) { - for (final p in note.images) { - if (p.isNotEmpty) paths.add(p); + + final notes = await db.query('notes', columns: ['images']); + for (final n in notes) { + final imagesJson = n['images'] as String?; + if (imagesJson != null && imagesJson.isNotEmpty) { + try { + for (final ip in jsonDecode(imagesJson) as List) { + if (ip is String && ip.isNotEmpty) paths.add(ip); + } + } catch (_) {} } } - for (final movieId in provider.movies.map((m) => m.id)) { - for (final poster in await provider.getMoviePosters(movieId)) { - if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath); - } + + final moviePosters = await db.query('movie_posters', columns: ['poster_path']); + for (final p in moviePosters) { + final pp = p['poster_path'] as String?; + if (pp != null && pp.isNotEmpty) paths.add(pp); } - for (final game in provider.games) { - if (game.coverPath?.isNotEmpty == true) paths.add(game.coverPath!); + + final games = await db.query('games', columns: ['cover_path']); + for (final g in games) { + final p = g['cover_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } - for (final gameId in provider.games.map((g) => g.id)) { - for (final screenshot in await provider.getGameScreenshots(gameId)) { - if (screenshot.screenshotPath.isNotEmpty) paths.add(screenshot.screenshotPath); - } + + final gameScreenshots = await db.query('game_screenshots', columns: ['screenshot_path']); + for (final s in gameScreenshots) { + final p = s['screenshot_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } + + final userPrefs = UserPrefs(); + final avatarPath = userPrefs.avatarPath; + if (avatarPath != null && avatarPath.isNotEmpty) paths.add(avatarPath); + return paths; } /// 从绝对路径中提取 epub_books/ 下的目录名 + /// 兼容 Windows(\) 和 Unix(/) 分隔符 void _collectEpubDirName(String? pathStr, Set dirs) { if (pathStr == null || pathStr.isEmpty) return; + final unified = pathStr.replaceAll('\\', '/'); final marker = '/epub_books/'; - final idx = pathStr.indexOf(marker); + final idx = unified.indexOf(marker); if (idx < 0) return; - final rest = pathStr.substring(idx + marker.length); + final rest = unified.substring(idx + marker.length); final slashIdx = rest.indexOf('/'); dirs.add(slashIdx >= 0 ? rest.substring(0, slashIdx) : rest); } @@ -1097,12 +1131,13 @@ class _SettingsPageState extends State { Future<(int, int)> _scanImageDirectory(Set dbImagePaths) async { int count = 0, totalSize = 0; try { - final appDir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory('${appDir.path}/images'); + final appDirPath = await ImagePathHelper.getAppDir(); + final imagesDir = Directory(path.join(appDirPath, 'images')); if (!await imagesDir.exists()) return (0, 0); + final normalizedDbPaths = dbImagePaths.map(_normalizePath).toSet(); await for (final entity in imagesDir.list(recursive: true, followLinks: false)) { if (entity is File && - !dbImagePaths.contains(entity.path) && + !normalizedDbPaths.contains(_normalizePath(entity.path)) && !path.basename(entity.path).startsWith('avatar')) { try { totalSize += await entity.length(); @@ -1114,7 +1149,12 @@ class _SettingsPageState extends State { return (count, totalSize); } - Future<(int, int)> _scanOrphanedEpubBooks(AppProvider provider) async { + /// 规范化路径用于跨平台比较(统一分隔符) + String _normalizePath(String p) { + return path.normalize(p.replaceAll('\\', '/')); + } + + Future<(int, int)> _scanOrphanedEpubBooks() async { int count = 0, totalSize = 0; try { final db = await DatabaseHelper.instance.database; @@ -1128,9 +1168,9 @@ class _SettingsPageState extends State { _collectEpubDirName(r['file_path'] as String?, usedDirs); _collectEpubDirName(r['cover_path'] as String?, usedDirs); } - final appDir = await getApplicationDocumentsDirectory(); + final appDirPath = await ImagePathHelper.getAppDir(); final possiblePaths = [ - '${appDir.path}/epub_books', + path.join(appDirPath, 'epub_books'), '/data/user/0/top.iletter.mooknote/app_flutter/epub_books', ]; for (final epubPath in possiblePaths) { @@ -1183,10 +1223,17 @@ class _SettingsPageState extends State { if (await cacheDir.exists()) { await for (final entity in cacheDir.list(recursive: true, followLinks: false)) { if (entity is File) { - try { - totalSize += await entity.length(); - count++; - } catch (_) {} + final name = path.basename(entity.path); + if (name.startsWith('book_poster_') || + name.startsWith('movie_poster_') || + name.startsWith('note_share_') || + name.startsWith('mooknote_download') || + name.startsWith('mooknote_bidir')) { + try { + totalSize += await entity.length(); + count++; + } catch (_) {} + } } } } @@ -1197,11 +1244,11 @@ class _SettingsPageState extends State { Future<(int, int)> _scanEmptyDirectories() async { int count = 0; try { - final appDir = await getApplicationDocumentsDirectory(); + final appDirPath = await ImagePathHelper.getAppDir(); final cacheDir = await getApplicationCacheDirectory(); final dirs = [ - Directory('${appDir.path}/images'), - Directory('${appDir.path}/epub_books'), + Directory(path.join(appDirPath, 'images')), + Directory(path.join(appDirPath, 'epub_books')), cacheDir, ]; for (final dir in dirs) { diff --git a/lib/pages/sync/backup_page.dart b/lib/pages/sync/backup_page.dart index 9b2d5bf..7528591 100644 --- a/lib/pages/sync/backup_page.dart +++ b/lib/pages/sync/backup_page.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../services/sync/backup_service.dart'; -import '../../services/sync/cache_cleaner.dart'; import '../../utils/toast_util.dart'; /// 本地备份页面 @@ -335,9 +334,6 @@ class _BackupPageState extends State { setState(() => _isExporting = true); try { - // 先清理缓存 - await CacheCleaner.instance.clean(context.read()); - final result = await BackupService.instance.exportDataWithImages(); if (!mounted) return; @@ -435,9 +431,6 @@ class _BackupPageState extends State { setState(() => _isImporting = true); try { - // 先清理缓存 - await CacheCleaner.instance.clean(context.read()); - final result = await BackupService.instance.importData(); if (!mounted) return; diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart index 8a70674..39ce11d 100644 --- a/lib/pages/sync/webdav_sync_page.dart +++ b/lib/pages/sync/webdav_sync_page.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../utils/toast_util.dart'; import '../../services/sync/webdav_service.dart'; -import '../../services/sync/cache_cleaner.dart'; import '../../providers/app_provider.dart'; /// WebDAV 备份页面 @@ -127,11 +126,6 @@ class _WebDAVSyncPageState extends State { setState(() => _isLoading = true); try { - // 先清理缓存 - setState(() => _syncStep = '正在清理缓存...'); - await Future.delayed(Duration.zero); - await CacheCleaner.instance.clean(context.read()); - SyncResult result; if (_syncDirection == SyncDirection.upload) { diff --git a/lib/services/epub/epub_service.dart b/lib/services/epub/epub_service.dart index 7d6cb7e..73d6b9f 100644 --- a/lib/services/epub/epub_service.dart +++ b/lib/services/epub/epub_service.dart @@ -6,6 +6,7 @@ import 'package:uuid/uuid.dart'; import 'epub_parser.dart'; import '../../data/epub/reader_dao.dart'; import '../../data/epub/reader_models.dart'; +import '../../utils/image_path_helper.dart'; /// EPUB 服务层 - 管理导入、解压、删除 class EpubService { @@ -21,8 +22,8 @@ class EpubService { final fileName = p.basename(sourcePath); // 复制 EPUB 到永久存储(FilePicker 临时文件会被清理) - final appDir = await getApplicationDocumentsDirectory(); - final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId)); + final appDirPath = await ImagePathHelper.getAppDir(); + final bookDir = Directory(p.join(appDirPath, 'epub_books', bookId)); if (!await bookDir.exists()) await bookDir.create(recursive: true); final permanentPath = p.join(bookDir.path, 'book.epub'); await File(sourcePath).copy(permanentPath); @@ -98,8 +99,8 @@ class EpubService { if (!await coverFile.exists()) return null; // 保存到 epub_books/{bookId}/ 目录下 - final appDir = await getApplicationDocumentsDirectory(); - final coverDir = p.join(appDir.path, 'epub_books', bookId); + final appDirPath = await ImagePathHelper.getAppDir(); + final coverDir = p.join(appDirPath, 'epub_books', bookId); await Directory(coverDir).create(recursive: true); final ext = p.extension(coverFile.path).toLowerCase(); final destPath = p.join(coverDir, 'cover$ext'); @@ -144,8 +145,8 @@ class EpubService { // 清理 epub_books/{bookId}/ 目录(epub + 封面) try { - final appDir = await getApplicationDocumentsDirectory(); - final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId)); + final appDirPath = await ImagePathHelper.getAppDir(); + final bookDir = Directory(p.join(appDirPath, 'epub_books', bookId)); if (await bookDir.exists()) await bookDir.delete(recursive: true); } catch (_) {} diff --git a/lib/services/epub/epub_webview_handler.dart b/lib/services/epub/epub_webview_handler.dart index 80385b7..e398173 100644 --- a/lib/services/epub/epub_webview_handler.dart +++ b/lib/services/epub/epub_webview_handler.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:path_provider/path_provider.dart'; import 'epub_stream_service.dart'; +import '../../utils/image_path_helper.dart'; /// Simple file reference with path and optional anchor. class Href { @@ -34,8 +34,8 @@ class EpubWebViewHandler { static Future getDocumentsPath() async { if (_documentsPath != null) return _documentsPath!; - final dir = await getApplicationDocumentsDirectory(); - _documentsPath = '${dir.path}/'; + final appDirPath = await ImagePathHelper.getAppDir(); + _documentsPath = '$appDirPath/'; return _documentsPath!; } diff --git a/lib/services/font_download_manager.dart b/lib/services/font_download_manager.dart index 368a665..ae4f143 100644 --- a/lib/services/font_download_manager.dart +++ b/lib/services/font_download_manager.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; +import '../utils/image_path_helper.dart'; /// 本地字体扫描与加载管理器 /// @@ -131,8 +131,8 @@ class FontDownloadManager { return fontDir; } // iOS / 桌面端 fallback - final appDir = await getApplicationDocumentsDirectory(); - final fontDir = Directory(path.join(appDir.path, 'fonts')); + final appDirPath = await ImagePathHelper.getAppDir(); + final fontDir = Directory(path.join(appDirPath, 'fonts')); if (!await fontDir.exists()) { await fontDir.create(recursive: true); } diff --git a/lib/services/sync/backup_service.dart b/lib/services/sync/backup_service.dart index 392d308..80834c3 100644 --- a/lib/services/sync/backup_service.dart +++ b/lib/services/sync/backup_service.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import '../../data/database_helper.dart'; import '../../utils/user_prefs.dart'; +import '../../utils/image_path_helper.dart'; /// 数据备份服务 - 支持导出和导入数据(包含图片) class BackupService { @@ -17,6 +18,11 @@ class BackupService { BackupService._init(); + /// 获取应用数据根目录(统一路径) + Future _getAppDir() async { + return await ImagePathHelper.getAppDir(); + } + // ─── 共享导出逻辑 ───────────────────────────────────── /// 收集所有表数据和图片,构建 ZIP 文件 @@ -112,13 +118,25 @@ class BackupService { // 阶段2:后台 isolate 执行 JSON 编码 + ZIP 压缩(避免阻塞主线程动画) final tempDir = await getTemporaryDirectory(); - final appDir = await getApplicationDocumentsDirectory(); + final appDirPath = await _getAppDir(); + + // DEBUG: 诊断 Windows 导出图片缺失问题 + final imagesRootPath = path.join(appDirPath, 'images'); + debugPrint('[BackupService] DEBUG appDirPath=$appDirPath'); + debugPrint('[BackupService] DEBUG imagesRoot=$imagesRootPath'); + debugPrint('[BackupService] DEBUG imagePaths count=${imagePaths.length}'); + for (final ip in imagePaths) { + final f = File(ip); + final exists = f.existsSync(); + final match = ip.startsWith(imagesRootPath); + debugPrint('[BackupService] DEBUG path=$ip exists=$exists startsWithImagesRoot=$match'); + } final result = await compute(_buildZipInIsolate, _ZipComputeParams( backupData: backupData, imagePaths: imagePaths.toList(), tempDirPath: tempDir.path, - appDirPath: appDir.path, + appDirPath: appDirPath, )); return _ExportData( @@ -144,19 +162,17 @@ class BackupService { String? finalPath; try { - // FilePicker.saveFile 需要 bytes,这里必须读入内存 - final zipBytes = await zipFile.readAsBytes(); final outputPath = await FilePicker.platform.saveFile( dialogTitle: '保存备份文件', fileName: fileName, type: FileType.custom, allowedExtensions: ['zip'], - bytes: zipBytes, ); if (outputPath == null) { await zipFile.delete(); return ExportResult.cancelled(); } + await zipFile.copy(outputPath); finalPath = outputPath; } catch (e) { // FilePicker 不可用时,复制到临时目录 @@ -239,8 +255,8 @@ class BackupService { backupData = jsonDecode(utf8.decode(dataFile.content as List)) as Map; - final appDir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory(path.join(appDir.path, 'images')); + final appDirPath = await _getAppDir(); + final imagesDir = Directory(path.join(appDirPath, 'images')); if (!await imagesDir.exists()) await imagesDir.create(recursive: true); for (final archiveFile in archive) { @@ -254,7 +270,7 @@ class BackupService { imageCount++; } else if (archiveFile.name.startsWith('epub_books/')) { final relativePath = archiveFile.name.substring(12); - final epubDir = Directory(path.join(appDir.path, 'epub_books')); + final epubDir = Directory(path.join(appDirPath, '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); @@ -401,8 +417,8 @@ class BackupService { final epubFileMap = {}; int imageCount = 0; - final appDir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory(path.join(appDir.path, 'images')); + final appDirPath = await _getAppDir(); + final imagesDir = Directory(path.join(appDirPath, 'images')); if (!await imagesDir.exists()) await imagesDir.create(recursive: true); for (final archiveFile in archive) { @@ -415,7 +431,7 @@ class BackupService { imageCount++; } else if (archiveFile.name.startsWith('epub_books/')) { final relativePath = archiveFile.name.substring(12); - final epubDir = Directory(path.join(appDir.path, 'epub_books')); + final epubDir = Directory(path.join(appDirPath, '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); @@ -627,12 +643,11 @@ class BackupService { /// 将绝对路径转为 images/ 下的相对路径(用于 imagePathMap key) String _toRelativePath(String absolutePath) { + // 统一为正斜杠,避免 Windows 反斜杠与 zip 内正斜杠不匹配 + final normalized = absolutePath.replaceAll('\\', '/'); // 尝试提取 images/ 后面的部分 - final idx = absolutePath.indexOf('/images/'); - if (idx >= 0) return absolutePath.substring(idx + 8); // skip '/images/' - // Windows 路径 - final winIdx = absolutePath.indexOf('\\images\\'); - if (winIdx >= 0) return absolutePath.substring(winIdx + 8); + final idx = normalized.indexOf('/images/'); + if (idx >= 0) return normalized.substring(idx + 8); // skip '/images/' return path.basename(absolutePath); } @@ -683,10 +698,9 @@ class BackupService { /// 从绝对路径中提取 epub_books/ 下的相对路径 String? _toEpubRelativePath(String absolutePath) { - final idx = absolutePath.indexOf('/epub_books/'); - if (idx >= 0) return absolutePath.substring(idx + 13); // skip '/epub_books/' - final winIdx = absolutePath.indexOf('\\epub_books\\'); - if (winIdx >= 0) return absolutePath.substring(winIdx + 13); + final normalized = absolutePath.replaceAll('\\', '/'); + final idx = normalized.indexOf('/epub_books/'); + if (idx >= 0) return normalized.substring(idx + 13); // skip '/epub_books/' return null; } @@ -910,14 +924,16 @@ _ZipComputeResult _buildZipInIsolate(_ZipComputeParams params) { dataFile.deleteSync(); int imageCount = 0; - final imagesRoot = path.join(params.appDirPath, 'images'); for (final imagePath in params.imagePaths) { final file = File(imagePath); if (file.existsSync()) { + // 统一用 /images/ 子串匹配提取相对路径,兼容旧路径(路径前缀可能不含 mooknote 子目录) + final normalized = imagePath.replaceAll('\\', '/'); + final idx = normalized.indexOf('/images/'); String relativePath; - if (imagePath.startsWith(imagesRoot)) { - relativePath = imagePath.substring(imagesRoot.length + 1); + if (idx >= 0) { + relativePath = normalized.substring(idx + 8); // skip '/images/' } else { relativePath = path.basename(imagePath); } diff --git a/lib/services/sync/cache_cleaner.dart b/lib/services/sync/cache_cleaner.dart index 06c216a..cca8d3a 100644 --- a/lib/services/sync/cache_cleaner.dart +++ b/lib/services/sync/cache_cleaner.dart @@ -1,9 +1,11 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; -import '../../providers/app_provider.dart'; import '../../data/database_helper.dart'; +import '../../utils/image_path_helper.dart'; +import '../../utils/user_prefs.dart'; /// 缓存清理服务 class CacheCleaner { @@ -11,10 +13,10 @@ class CacheCleaner { static final CacheCleaner instance = CacheCleaner._(); /// 执行完整缓存清理,返回各分类删除数量 - Future clean(AppProvider provider) async { - final dbImagePaths = await _getAllDbImagePaths(provider); + Future clean() async { + final dbImagePaths = await _getAllDbImagePaths(); final deletedImages = await _cleanImageDirectory(dbImagePaths); - final deletedEpubs = await _cleanOrphanedEpubBooks(provider); + final deletedEpubs = await _cleanOrphanedEpubBooks(); final deletedTemp = await _cleanTempDirectory(); final deletedEmptyDirs = await _cleanEmptyDirectories(); return CacheCleanResult( @@ -25,44 +27,87 @@ class CacheCleaner { ); } - Future> _getAllDbImagePaths(AppProvider provider) async { + /// 直接查 DB 收集所有图片路径(含软删除记录,与 BackupService 保持一致) + Future> _getAllDbImagePaths() async { + final db = await DatabaseHelper.instance.database; final paths = {}; - for (final movie in provider.movies) { - if (movie.posterPath?.isNotEmpty == true) paths.add(movie.posterPath!); + + // 影视海报 + final movies = await db.query('movies', columns: ['poster_path']); + for (final m in movies) { + final p = m['poster_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } - for (final book in provider.books) { - if (book.coverPath?.isNotEmpty == true) paths.add(book.coverPath!); + + // 书籍封面 + final books = await db.query('books', columns: ['cover_path']); + for (final b in books) { + final p = b['cover_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } - for (final note in provider.notes) { - for (final p in note.images) { - if (p.isNotEmpty) paths.add(p); + + // 笔记图片 + final notes = await db.query('notes', columns: ['images']); + for (final n in notes) { + final imagesJson = n['images'] as String?; + if (imagesJson != null && imagesJson.isNotEmpty) { + try { + for (final ip in jsonDecode(imagesJson) as List) { + if (ip is String && ip.isNotEmpty) paths.add(ip); + } + } catch (_) {} } } - for (final movieId in provider.movies.map((m) => m.id)) { - for (final poster in await provider.getMoviePosters(movieId)) { - if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath); - } + + // 影视海报墙图片 + final moviePosters = await db.query('movie_posters', columns: ['poster_path']); + for (final p in moviePosters) { + final pp = p['poster_path'] as String?; + if (pp != null && pp.isNotEmpty) paths.add(pp); } - for (final game in provider.games) { - if (game.coverPath?.isNotEmpty == true) paths.add(game.coverPath!); + + // 游戏封面 + final games = await db.query('games', columns: ['cover_path']); + for (final g in games) { + final p = g['cover_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } - for (final gameId in provider.games.map((g) => g.id)) { - for (final screenshot in await provider.getGameScreenshots(gameId)) { - if (screenshot.screenshotPath.isNotEmpty) paths.add(screenshot.screenshotPath); - } + + // 游戏截图 + final gameScreenshots = await db.query('game_screenshots', columns: ['screenshot_path']); + for (final s in gameScreenshots) { + final p = s['screenshot_path'] as String?; + if (p != null && p.isNotEmpty) paths.add(p); } + + // 用户头像 + final userPrefs = UserPrefs(); + final avatarPath = userPrefs.avatarPath; + if (avatarPath != null && avatarPath.isNotEmpty) paths.add(avatarPath); + return paths; } + /// 规范化路径用于跨平台比较(统一分隔符、去掉末尾分隔符) + /// Windows 上 DB 存的路径和文件系统遍历得到的路径分隔符可能不一致, + /// 直接字符串比较会漏匹配导致图片被误删。 + String _normalize(String p) { + // 统一为正斜杠后再用 path.normalize 处理 .. 和 . 等 + final unified = p.replaceAll('\\', '/'); + return path.normalize(unified); + } + Future _cleanImageDirectory(Set dbImagePaths) async { int deletedCount = 0; try { - final appDir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory('${appDir.path}/images'); + final appDirPath = await ImagePathHelper.getAppDir(); + final imagesDir = Directory(path.join(appDirPath, 'images')); if (!await imagesDir.exists()) return 0; + // 预先规范化 DB 路径,避免每个文件都做转换 + final normalizedDbPaths = dbImagePaths.map(_normalize).toSet(); await for (final entity in imagesDir.list(recursive: true, followLinks: false)) { if (entity is File && - !dbImagePaths.contains(entity.path) && + !normalizedDbPaths.contains(_normalize(entity.path)) && !path.basename(entity.path).startsWith('avatar')) { try { await entity.delete(); @@ -76,7 +121,7 @@ class CacheCleaner { return deletedCount; } - Future _cleanOrphanedEpubBooks(AppProvider provider) async { + Future _cleanOrphanedEpubBooks() async { int deletedCount = 0; try { final db = await DatabaseHelper.instance.database; @@ -91,9 +136,10 @@ class CacheCleaner { _collectEpubDirName(r['cover_path'] as String?, usedDirs); } - final appDir = await getApplicationDocumentsDirectory(); + final appDirPath = await ImagePathHelper.getAppDir(); final possiblePaths = [ - '${appDir.path}/epub_books', + path.join(appDirPath, 'epub_books'), + // Android 旧版绝对路径(path.join 在 Windows 上不会破坏它) '/data/user/0/top.iletter.mooknote/app_flutter/epub_books', ]; @@ -118,16 +164,36 @@ class CacheCleaner { return deletedCount; } + /// 从路径中提取 epub_books/{bookId} 的 bookId 部分 + /// 兼容 Windows(\) 和 Unix(/) 分隔符 void _collectEpubDirName(String? pathStr, Set dirs) { if (pathStr == null || pathStr.isEmpty) return; + // 统一为正斜杠便于查找 marker + final unified = pathStr.replaceAll('\\', '/'); final marker = '/epub_books/'; - final idx = pathStr.indexOf(marker); + final idx = unified.indexOf(marker); if (idx < 0) return; - final rest = pathStr.substring(idx + marker.length); + final rest = unified.substring(idx + marker.length); final slashIdx = rest.indexOf('/'); dirs.add(slashIdx >= 0 ? rest.substring(0, slashIdx) : rest); } + /// mooknote 自己产生的临时文件名前缀 + static const _tempPrefixes = [ + 'book_poster_', + 'movie_poster_', + 'note_share_', + 'mooknote_download', + 'mooknote_bidir', + ]; + + bool _isMooknoteTempFile(String name) { + for (final prefix in _tempPrefixes) { + if (name.startsWith(prefix)) return true; + } + return false; + } + Future _cleanTempDirectory() async { int deletedCount = 0; final now = DateTime.now(); @@ -138,11 +204,7 @@ class CacheCleaner { await for (final entity in tempDir.list(followLinks: false)) { if (entity is File) { final name = path.basename(entity.path); - if (name.startsWith('book_poster_') || - name.startsWith('movie_poster_') || - name.startsWith('note_share_') || - name.startsWith('mooknote_download') || - name.startsWith('mooknote_bidir')) { + if (_isMooknoteTempFile(name)) { try { final stat = await entity.stat(); if (now.difference(stat.modified).inHours >= 1) { @@ -158,15 +220,20 @@ class CacheCleaner { debugPrint('清理临时目录失败: $e'); } + // cacheDir 只删 mooknote 自己产生的临时文件,不再无差别全清 + // (Windows/Flutter 引擎也在该目录放缓存文件,全清可能误伤) try { final cacheDir = await getApplicationCacheDirectory(); if (await cacheDir.exists()) { await for (final entity in cacheDir.list(recursive: true, followLinks: false)) { if (entity is File) { - try { - await entity.delete(); - deletedCount++; - } catch (_) {} + final name = path.basename(entity.path); + if (_isMooknoteTempFile(name)) { + try { + await entity.delete(); + deletedCount++; + } catch (_) {} + } } } } @@ -180,11 +247,11 @@ class CacheCleaner { Future _cleanEmptyDirectories() async { int deletedCount = 0; try { - final appDir = await getApplicationDocumentsDirectory(); + final appDirPath = await ImagePathHelper.getAppDir(); final cacheDir = await getApplicationCacheDirectory(); final dirs = [ - Directory('${appDir.path}/images'), - Directory('${appDir.path}/epub_books'), + Directory(path.join(appDirPath, 'images')), + Directory(path.join(appDirPath, 'epub_books')), cacheDir, ]; for (final dir in dirs) { diff --git a/lib/utils/image_path_helper.dart b/lib/utils/image_path_helper.dart index b1f209e..779d269 100644 --- a/lib/utils/image_path_helper.dart +++ b/lib/utils/image_path_helper.dart @@ -17,19 +17,24 @@ class ImagePathHelper { ImagePathHelper._init(); - String? _appDirPath; + static String? _appDirPath; - /// 获取应用文档目录 - Future get _appDir async { + /// 获取应用数据根目录(Windows 下统一到 mooknote 子目录) + /// 所有需要访问 images/、epub_books/ 等目录的代码都应使用此方法 + static Future getAppDir() async { if (_appDirPath != null) return _appDirPath!; final appDir = await getApplicationDocumentsDirectory(); - _appDirPath = appDir.path; + if (Platform.isWindows) { + _appDirPath = p.join(appDir.path, 'mooknote'); + } else { + _appDirPath = appDir.path; + } return _appDirPath!; } /// 获取图片根目录 Future get imagesRoot async { - final appDir = await _appDir; + final appDir = await getAppDir(); return p.join(appDir, 'images'); } diff --git a/lib/utils/responsive.dart b/lib/utils/responsive.dart index a38cd53..77280a6 100644 --- a/lib/utils/responsive.dart +++ b/lib/utils/responsive.dart @@ -1,3 +1,4 @@ +import 'dart:io' show Platform; import 'package:flutter/widgets.dart'; /// 响应式布局断点工具 @@ -8,15 +9,24 @@ class Breakpoint { /// ≥900dp: 宽屏内容区(列表-详情并排显示) static const double wideContent = 900.0; + /// ≥1200dp: 桌面布局(侧边导航 + 宽内容区) + static const double desktop = 1200.0; + static bool isTablet(BuildContext context) => - MediaQuery.sizeOf(context).width >= tablet; + Platform.isWindows || MediaQuery.sizeOf(context).width >= tablet; static bool isPhone(BuildContext context) => - MediaQuery.sizeOf(context).width < tablet; + !Platform.isWindows && MediaQuery.sizeOf(context).width < tablet; /// 是否使用宽屏内容布局(列表+详情并排) + /// 桌面模式下始终为 true(列表已在第二栏,第三栏只显示详情) static bool isWideContent(BuildContext context) => - MediaQuery.sizeOf(context).width >= wideContent; + Platform.isWindows || MediaQuery.sizeOf(context).width >= wideContent; + + /// 是否使用桌面布局(侧边导航 + 宽内容区) + /// Windows 平台始终使用桌面布局 + static bool isDesktop(BuildContext context) => + Platform.isWindows || MediaQuery.sizeOf(context).width >= desktop; } /// 根据可用宽度动态计算网格列数 diff --git a/lib/widgets/add_sheet.dart b/lib/widgets/add_sheet.dart index fc63ac5..29fa520 100644 --- a/lib/widgets/add_sheet.dart +++ b/lib/widgets/add_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:io' show Platform; import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; @@ -96,49 +97,74 @@ void showAddSheet(BuildContext context, AppProvider provider) { )); } - showModalBottomSheet( - context: context, - backgroundColor: colors.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) { - final bc = Theme.of(ctx).colorScheme; - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( + if (Platform.isWindows) { + showDialog( + context: context, + builder: (ctx) { + final bc = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: bc.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + title: Text('新增记录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: bc.onSurface)), + content: Column( mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 32, - height: 3, - decoration: BoxDecoration( - color: bc.onSurface.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - Text('新增记录', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: bc.onSurface)), - ], - ), - ), - const SizedBox(height: 8), - ...options, - ], + children: options, ), - ), - ); - }, - ); + ); + }, + ); + } else { + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + final bc = Theme.of(ctx).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: bc.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text('新增记录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: bc.onSurface)), + ], + ), + ), + const SizedBox(height: 8), + ...options, + ], + ), + ), + ); + }, + ); + } } Widget _buildOption({ diff --git a/lib/widgets/master_detail_scaffold.dart b/lib/widgets/master_detail_scaffold.dart index e8e05c9..f0086a4 100644 --- a/lib/widgets/master_detail_scaffold.dart +++ b/lib/widgets/master_detail_scaffold.dart @@ -19,6 +19,10 @@ class MasterDetailScaffold extends StatelessWidget { @override Widget build(BuildContext context) { + // 桌面三栏布局下只显示 detail(列表已在侧边栏) + if (Breakpoint.isDesktop(context) && detail != null) { + return detail!; + } if (!Breakpoint.isWideContent(context) || detail == null) { return master; } diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 30be2a9..1316272 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -25,8 +25,8 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); + Win32Window::Point origin(100, 100); + Win32Window::Size size(1400, 900); if (!window.Create(L"MookNote", origin, size)) { return EXIT_FAILURE; } diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20caf6370ebb9253ad831cc31de4a9c965f6..11823cabb837fa8edaeb2716d6fe6f28cc1cb9a6 100644 GIT binary patch literal 9380 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 W7{IMZ@ z(c6F44WS7%`;W)nG5<{_U(>RF_qOmKms7Pt!Ac3X`(JjnkL}F1+5XGUNwiANTa6f; zKh5|g1m>%Vpj8(Hr>Egl=fO%ehLcOIydpbEs5$7a_`6J3rC__^d}fyoEIaP-VxPbj zCJB#x8zXusONE!oFbjq_s6v-Hc%sH!@^k8@$c{_G2Ks8`Ds+2(op#82y{hn{db&iJ z%~QRuy|Uqg2QmU)(Cd*>6(7_voHjuJm!1aG_>cDBd?h7@`3fGm%;7D`kDoxalR7OA zd=nauyQ^TRjpFc@I39lA?Z%4zEm=h1nmdgE%UkbgasHC(=5O%3oHguWwudv7)R?Nm zAGA3AriWn2$*=+hP8|>3tu+GCj0SMqH)4u2^`r2VZ|`wq#2Cq%K9l{?>iJ44te0Oi zE$-e)+bA#$?;>4_1*M%v=`MQ$&EL^?8r>|x@_HI1EHznq3G4N~pMT$Z`>T1@XoqLu zQPUHO$Y$;)I+$&Q&C;_93su1`)A3@`vDu_J0eDvcr-IcplQq$41*f2=-ZTob9 z94Pl%S4B3Z#(R%x79-&aw9+?zuudM6_WnpGXWZBCkq0r1Uxq6iaeJ9_9OmLs9EGl%N`t;vTvv$<3y*Qbmw8@e#cu& zaDPF`Kj|8=3*|Ld#*ZUJ6yuZnZ z?p>C*^RMMXYe+&DJ|e%*;}_A;#UjyYBWA(?eW&#&JIu*ls-G>eZvO20y^eU9r6fY( z!5~SSs&Se`wPn&w#MQ`VOZ58v=_o?& zEYd>)Dql?n=Q$eLzs3f4929r)e^ih@mEe1olVIk(p9l1MS0^_&e?RT`p+Rxo%yuf| Sh$Te=lbl4n{r~*B;J*MzvnDhE literal 33772 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 diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index a25b48c..904111a 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -199,7 +199,7 @@ Win32Window::MessageHandler(HWND hwnd, } case WM_GETMINMAXINFO: { auto info = reinterpret_cast(lparam); - info->ptMinTrackSize.x = 360; + info->ptMinTrackSize.x = 900; info->ptMinTrackSize.y = 640; return 0; }