From 7e2a0bc106a522c6aed1a7bd30ac930fb6821b12 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 11 Jul 2026 02:12:23 +0800 Subject: [PATCH] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/epub/reader_dao.dart | 11 +- lib/data/movie/movie_dao.dart | 6 +- lib/models/data_models.dart | 2 +- lib/pages/epub_reader/epub_library_page.dart | 169 ++++-- .../epub_reader/widgets/book_grid_item.dart | 145 ++--- lib/pages/movies/movie_form_page.dart | 1 + lib/pages/movies/movie_tab_page.dart | 75 ++- lib/pages/profile/feature_settings_page.dart | 12 + lib/pages/profile/layout_settings_page.dart | 532 +++++++++++------- lib/providers/app_provider.dart | 24 +- lib/utils/user_prefs.dart | 11 + lib/widgets/custom_drawer.dart | 336 +++++++++-- lib/widgets/movie_category_bar.dart | 109 ++++ 13 files changed, 1045 insertions(+), 388 deletions(-) create mode 100644 lib/widgets/movie_category_bar.dart diff --git a/lib/data/epub/reader_dao.dart b/lib/data/epub/reader_dao.dart index 2194e6b..8937a58 100644 --- a/lib/data/epub/reader_dao.dart +++ b/lib/data/epub/reader_dao.dart @@ -7,15 +7,22 @@ class ReaderDao { // ─── reader_books ───────────────────────────────────────────────── /// 获取所有未删除的阅读记录(包含关联书籍封面) - Future>> getAllReaderBooks() async { + /// [sortMode] 排序模式: 0=更新时间, 1=创建时间, 2=阅读进度, 3=书名 + Future>> getAllReaderBooks({int sortMode = 0}) async { final db = await _db.database; + final orderBy = switch (sortMode) { + 1 => 'rb.created_at DESC', + 2 => 'rb.reading_percentage DESC', + 3 => 'rb.title COLLATE NOCASE ASC', + _ => 'rb.updated_at DESC', + }; final results = await db.rawQuery(''' SELECT rb.*, COALESCE(b.cover_path, rb.cover_path) as display_cover_path FROM reader_books rb LEFT JOIN books b ON rb.book_id = b.id AND b.is_deleted = 0 WHERE rb.is_deleted = 0 - ORDER BY rb.updated_at DESC + ORDER BY $orderBy '''); return results.map((row) { final map = Map.from(row); diff --git a/lib/data/movie/movie_dao.dart b/lib/data/movie/movie_dao.dart index 62e9b9b..4bd48a2 100644 --- a/lib/data/movie/movie_dao.dart +++ b/lib/data/movie/movie_dao.dart @@ -28,7 +28,7 @@ class MovieDao { }); // 分页查询影视记录 - Future> getMoviesPaged({String? status, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getMoviesPaged', () async { + Future> getMoviesPaged({String? status, String? category, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getMoviesPaged', () async { final db = await _dbHelper.database; String where = 'is_deleted = 0'; List whereArgs = []; @@ -36,6 +36,10 @@ class MovieDao { where += ' AND status = ?'; whereArgs.add(status); } + if (category != null && category.isNotEmpty) { + where += ' AND category = ?'; + whereArgs.add(category); + } final maps = await db.query('movies', where: where, whereArgs: whereArgs, orderBy: _buildMovieOrderBy(sortMode), limit: limit, offset: offset); return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index d1786e0..a8f0bf7 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -56,7 +56,7 @@ class Movie { final String? summary; // 剧情简介 final double? rating; // 评分 1-10 final String status; // watched/want_to_watch/watching - final String category; // 影视分类: movie/tv/anime/variety/documentary/short + final String category; // 影视分类: movie/tv/anime/variety/documentary/short/other final DateTime? watchDate; // 观看日期 final DateTime createdAt; final DateTime updatedAt; diff --git a/lib/pages/epub_reader/epub_library_page.dart b/lib/pages/epub_reader/epub_library_page.dart index b4cedbe..15d7b6f 100644 --- a/lib/pages/epub_reader/epub_library_page.dart +++ b/lib/pages/epub_reader/epub_library_page.dart @@ -27,6 +27,7 @@ class _EpubLibraryPageState extends State { ViewMode _viewMode = UserPrefs().epubViewMode == 1 ? ViewMode.compact : ViewMode.relaxed; + int _sortMode = UserPrefs().epubSortMode; @override void initState() { @@ -36,7 +37,7 @@ class _EpubLibraryPageState extends State { Future _loadBooks() async { if (mounted) setState(() => _isLoading = true); - final books = await _dao.getAllReaderBooks(); + final books = await _dao.getAllReaderBooks(sortMode: _sortMode); if (mounted) { setState(() { _books = books; @@ -169,6 +170,54 @@ class _EpubLibraryPageState extends State { UserPrefs().setEpubViewMode(_viewMode == ViewMode.compact ? 1 : 0); } + void _showSortMenu() { + final colors = Theme.of(context).colorScheme; + final options = [ + (0, '按更新时间排序', Icons.update), + (1, '按创建时间排序', Icons.calendar_today_outlined), + (2, '按阅读进度排序', Icons.auto_stories_outlined), + (3, '按书名排序', Icons.sort_by_alpha), + ]; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + 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))), + Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text('书架排序', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))), + const SizedBox(height: 8), + for (int i = 0; i < options.length; i++) ...[ + if (i > 0) Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant), + _sortOption(ctx, options[i].$1, options[i].$2, options[i].$3, colors), + ], + const SizedBox(height: 12), + ]), + ), + ); + } + + Widget _sortOption(BuildContext ctx, int value, String label, IconData icon, ColorScheme colors) { + final selected = _sortMode == value; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, size: 20, color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), + title: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: colors.onSurface)), + trailing: selected ? Icon(Icons.check, size: 20, color: colors.primary) : null, + onTap: () { + Navigator.pop(ctx); + if (_sortMode != value) { + setState(() => _sortMode = value); + UserPrefs().setEpubSortMode(value); + _loadBooks(); + } + }, + ); + } + @override void dispose() { _searchCtrl.dispose(); @@ -217,6 +266,10 @@ class _EpubLibraryPageState extends State { ), onPressed: _toggleViewMode, ), + IconButton( + icon: Icon(Icons.sort, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), + onPressed: _showSortMenu, + ), IconButton( icon: Icon(Icons.add_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), onPressed: _pickAndImport, @@ -314,14 +367,28 @@ class _EpubLibraryPageState extends State { Widget _buildListView(ColorScheme colors) { return ListView.builder( padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), - itemCount: _books.length, + itemCount: _filteredBooks.length, itemBuilder: (context, index) { - final book = _books[index]; + final book = _filteredBooks[index]; final title = book['title'] as String? ?? ''; final author = book['author'] as String? ?? ''; final coverPath = book['cover_path'] as String?; final progress = (book['reading_percentage'] as num?)?.toDouble() ?? 0.0; + // 阅读状态推断 + final String statusLabel; + final Color statusColor; + if (progress >= 1.0) { + statusLabel = '已读'; + statusColor = const Color(0xFF16A34A); + } else if (progress > 0.0) { + statusLabel = '在读'; + statusColor = colors.primary; + } else { + statusLabel = '未读'; + statusColor = const Color(0xFFDC2626); + } + return GestureDetector( onTap: () => _openBook(book), onLongPress: () => _deleteBook(book), @@ -333,36 +400,67 @@ class _EpubLibraryPageState extends State { borderRadius: BorderRadius.circular(10), border: Border.all(color: colors.outlineVariant, width: 0.5), ), - child: Row( + child: Column( children: [ - // 封面 - SizedBox( - width: 56, height: 80, - child: _buildCover(coverPath, colors), + Row( + children: [ + // 封面 + SizedBox( + width: 56, height: 80, + child: _buildCover(coverPath, colors), + ), + const SizedBox(width: 14), + // 信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + if (author.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(author, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), + ], + const SizedBox(height: 8), + // 状态标签 + 百分比 + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: statusColor.withValues(alpha: 0.25), width: 0.5), + ), + child: Text(statusLabel, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: statusColor)), + ), + if (progress > 0 && progress < 1.0) ...[ + const SizedBox(width: 6), + Text('${(progress * 100).toInt()}%', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))), + ], + ], + ), + ], + ), + ), + ], ), - const SizedBox(width: 14), - // 信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - if (author.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(author, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), - ], - const SizedBox(height: 8), - // 标签 - Wrap(spacing: 6, runSpacing: 4, children: [ - _buildTag('EPUB', colors), - if (progress > 0) _buildTag('${(progress * 100).toInt()}%', colors), - ]), - ], + // 进度条 + if (progress > 0) ...[ + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(1.5), + child: LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: colors.outlineVariant.withValues(alpha: 0.3), + valueColor: AlwaysStoppedAnimation(statusColor), + ), ), - ), - Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)), + ], ], ), ), @@ -389,15 +487,4 @@ class _EpubLibraryPageState extends State { ); } - Widget _buildTag(String label, ColorScheme colors) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text(label, - style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.5))), - ); - } } diff --git a/lib/pages/epub_reader/widgets/book_grid_item.dart b/lib/pages/epub_reader/widgets/book_grid_item.dart index a7f3bb0..8d3a23e 100644 --- a/lib/pages/epub_reader/widgets/book_grid_item.dart +++ b/lib/pages/epub_reader/widgets/book_grid_item.dart @@ -41,7 +41,6 @@ class BookGridItem extends StatelessWidget { final colors = Theme.of(context).colorScheme; final title = book['title'] as String? ?? ''; final author = book['author'] as String? ?? ''; - final progress = _readingProgress; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -60,30 +59,8 @@ class BookGridItem extends StatelessWidget { ), clipBehavior: Clip.antiAlias, child: _buildCoverStack(context, fit: StackFit.expand, extras: [ - // 底部渐变背景 + 进度条 - if (progress > 0) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - height: 12, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black54], - ), - ), - alignment: Alignment.bottomCenter, - child: LinearProgressIndicator( - value: progress, - minHeight: 2.5, - backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation(Colors.white70), - ), - ), - ), + // 阅读状态角标 + _buildStatusBadge(context), ]), ), ), @@ -114,15 +91,17 @@ class BookGridItem extends StatelessWidget { ); } - /// Compact: cover only, title gradient overlay + progress badge. + /// Compact: cover only, title + author gradient overlay + progress badge. Widget _buildCompact(BuildContext context) { final title = book['title'] as String? ?? ''; + final author = book['author'] as String? ?? ''; return _buildCoverStack( context, fit: StackFit.expand, extras: [ - // Bottom gradient + title + // 阅读状态角标 + _buildStatusBadge(context), Positioned( bottom: 0, left: 0, @@ -132,7 +111,7 @@ class BookGridItem extends StatelessWidget { bottom: Radius.circular(6), ), child: Container( - padding: const EdgeInsets.fromLTRB(6, 32, 6, 6), + padding: const EdgeInsets.fromLTRB(6, 24, 6, 6), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -140,27 +119,43 @@ class BookGridItem extends StatelessWidget { colors: [Colors.transparent, Colors.black87], ), ), - child: Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w500, - shadows: [ - const Shadow( - color: Colors.black54, - blurRadius: 2.0, - offset: Offset(0, 1.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + shadows: [ + const Shadow( + color: Colors.black54, + blurRadius: 2.0, + offset: Offset(0, 1.0), + ), + ], + ), + ), + if (author.isNotEmpty) ...[ + const SizedBox(height: 1), + Text( + author, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 9, + color: Colors.white.withValues(alpha: 0.7), + ), ), ], - ), + ], ), ), ), ), - // Progress badge (top-right) - _buildProgressBadge(context), ], ); } @@ -198,42 +193,62 @@ class BookGridItem extends StatelessWidget { } Widget _buildPlaceholder(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final title = book['title'] as String? ?? ''; + final initial = title.isNotEmpty ? title.substring(0, 1) : ''; return Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: colors.surfaceContainerHighest, child: Center( - child: Icon( - Icons.auto_stories_outlined, - size: 36, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2), - ), + child: initial.isNotEmpty + ? Text(initial, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.2), + )) + : Icon( + Icons.auto_stories_outlined, + size: 36, + color: colors.onSurface.withValues(alpha: 0.2), + ), ), ); } // ─── badge helpers ──────────────────────────────────────────────────────── - /// Progress badge (compact mode). - Widget _buildProgressBadge(BuildContext context) { + /// 阅读状态角标(左上角,含百分比) + Widget _buildStatusBadge(BuildContext context) { + final colors = Theme.of(context).colorScheme; final progress = _readingProgress; - if (progress <= 0) return const SizedBox.shrink(); + final String label; + final Color color; + if (progress >= 1.0) { + label = '已读'; + color = const Color(0xFF16A34A); + } else if (progress > 0.0) { + label = '在读 ${(progress * 100).toInt()}%'; + color = colors.primary; + } else { + label = '未读'; + color = const Color(0xFFDC2626); + } return Positioned( - top: 8, - right: 8, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - color: Theme.of(context).colorScheme.shadow.withValues(alpha: 0.8), - child: Text( - '${(progress * 100).toStringAsFixed(0)}%', + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(4), + ), + child: Text(label, style: const TextStyle( color: Colors.white, - fontSize: 10, + fontSize: 9, fontWeight: FontWeight.w600, - ), - ), - ), + )), ), ); } diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index c1145d5..2d2bf10 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -893,6 +893,7 @@ class _MovieFormPageState extends State { ('综艺', 'variety'), ('纪录片', 'documentary'), ('微短剧', 'short'), + ('其他', 'other'), ]; /// 构建状态选项 diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index 133378d..91394fe 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -4,6 +4,7 @@ import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; import '../../utils/user_prefs.dart'; import '../../widgets/movie_status_bar.dart'; +import '../../widgets/movie_category_bar.dart'; import '../../widgets/movie_list_item.dart'; import '../../widgets/animated_star_rating.dart'; import '../../widgets/shimmer_skeleton.dart'; @@ -34,6 +35,8 @@ class _MovieTabPageState extends State { int _lastEditRefreshCounter = 0; int _prevMovieCount = -1; int _prevLayoutStyle = -1; + int _prevCategoryIndex = -1; + int _prevDisplayMode = -1; double _swipeOffset = 0.0; // 当前拖动偏移量(用于左右滑动切换状态) static const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; @@ -72,6 +75,8 @@ class _MovieTabPageState extends State { // 仅在数据或布局实际变化时刷新列表,避免底部导航栏显隐等UI变化误触发重载 final statusChanged = provider.movieStatusIndex != _lastStatusIndex; final layoutChanged = provider.movieLayoutStyle != _prevLayoutStyle; + final categoryChanged = provider.movieCategoryIndex != _prevCategoryIndex; + final displayModeChanged = provider.movieDisplayMode != _prevDisplayMode; final countChanged = provider.movies.length != _prevMovieCount; final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; if (editRefreshed && provider.lastEditedItemId != null) { @@ -88,8 +93,10 @@ class _MovieTabPageState extends State { } return; } - if (statusChanged || layoutChanged || countChanged || editRefreshed) { + if (statusChanged || layoutChanged || categoryChanged || displayModeChanged || countChanged || editRefreshed) { _prevLayoutStyle = provider.movieLayoutStyle; + _prevCategoryIndex = provider.movieCategoryIndex; + _prevDisplayMode = provider.movieDisplayMode; _prevMovieCount = provider.movies.length; _loadFirst(); } @@ -107,14 +114,29 @@ class _MovieTabPageState extends State { Future _loadFirst() async { final provider = context.read(); final isWallMode = provider.movieWallMode; + final isCategoryMode = provider.movieDisplayMode == 1; final statusIdx = provider.movieStatusIndex; + final categoryIdx = provider.movieCategoryIndex; _lastStatusIndex = statusIdx; _initialized = true; - // 影视墙模式:不筛选状态,使用用户选择的排序(默认创建时间) - final status = isWallMode ? null : (_statusMap[statusIdx] ?? 'watched'); + // 影视墙模式:不筛选状态和分类 + // 分类模式:按分类筛选 + // 观看状态模式:按状态筛选 + String? status; + String? category; + if (isWallMode) { + status = null; + category = null; + } else if (isCategoryMode) { + status = null; + category = MovieCategoryBar.categoryValue(categoryIdx); + } else { + status = _statusMap[statusIdx] ?? 'watched'; + category = null; + } final sortMode = UserPrefs().movieSortMode; setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); - final list = await provider.loadMoviesPaged(status: status, offset: 0, sortMode: sortMode); + final list = await provider.loadMoviesPaged(status: status, category: category, offset: 0, sortMode: sortMode); if (!mounted) return; setState(() { _items.clear(); @@ -130,9 +152,21 @@ class _MovieTabPageState extends State { setState(() => _isLoading = true); final provider = context.read(); final isWallMode = provider.movieWallMode; - final status = isWallMode ? null : (_statusMap[provider.movieStatusIndex] ?? 'watched'); + final isCategoryMode = provider.movieDisplayMode == 1; + String? status; + String? category; + if (isWallMode) { + status = null; + category = null; + } else if (isCategoryMode) { + status = null; + category = MovieCategoryBar.categoryValue(provider.movieCategoryIndex); + } else { + status = _statusMap[provider.movieStatusIndex] ?? 'watched'; + category = null; + } final sortMode = UserPrefs().movieSortMode; - final list = await provider.loadMoviesPaged(status: status, offset: _offset, sortMode: sortMode); + final list = await provider.loadMoviesPaged(status: status, category: category, offset: _offset, sortMode: sortMode); if (!mounted) return; setState(() { _items.addAll(list); @@ -165,7 +199,10 @@ class _MovieTabPageState extends State { final masterContent = Column( children: [ - if (!isWallMode) const MovieStatusBar(), + if (!isWallMode) + provider.movieDisplayMode == 1 + ? const MovieCategoryBar() + : const MovieStatusBar(), Expanded(child: _buildBody(context)), ], ); @@ -212,7 +249,7 @@ class _MovieTabPageState extends State { ); }(); - // 用 GestureDetector 包裹,左右滑动切换状态(用 Transform 而非 AnimatedContainer padding 避免负数崩溃) + // 用 GestureDetector 包裹,左右滑动切换状态/分类 return GestureDetector( onHorizontalDragStart: (_) => _swipeOffset = 0.0, onHorizontalDragUpdate: (details) => setState(() => _swipeOffset += details.primaryDelta ?? 0), @@ -223,10 +260,17 @@ class _MovieTabPageState extends State { return; } final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个 - final currentIndex = provider.movieStatusIndex; - final newIndex = (currentIndex + direction + 3) % 3; setState(() => _swipeOffset = 0.0); - provider.setMovieStatusIndex(newIndex); + if (provider.movieDisplayMode == 1) { + // 分类模式 + final count = MovieCategoryBar.count; + final newIndex = (provider.movieCategoryIndex + direction + count) % count; + provider.setMovieCategoryIndex(newIndex); + } else { + // 观看状态模式 + final newIndex = (provider.movieStatusIndex + direction + 3) % 3; + provider.setMovieStatusIndex(newIndex); + } }, child: TweenAnimationBuilder( tween: Tween(begin: 0.0, end: _swipeOffset.clamp(-100.0, 100.0)), @@ -492,13 +536,18 @@ class _MovieTabPageState extends State { final colors = Theme.of(context).colorScheme; final provider = context.read(); final isWallMode = provider.movieWallMode; - final statusText = isWallMode ? '' : ['已看', '在看', '想看'][statusIndex]; + final isCategoryMode = provider.movieDisplayMode == 1; + final emptyText = isWallMode + ? '暂无影片' + : isCategoryMode + ? '暂无${MovieCategoryBar.categoryLabel(provider.movieCategoryIndex)}' + : '暂无${['已看', '在看', '想看'][statusIndex]}的影片'; return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Container(width: 80, height: 80, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), child: Icon(Icons.movie_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25))), const SizedBox(height: 20), - Text(isWallMode ? '暂无影片' : '暂无$statusText的影片', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), + Text(emptyText, style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), ])); } } diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index 21de73d..37a4ae4 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -29,6 +29,7 @@ class _FeatureSettingsPageState extends State { bool _showTags = true; bool _showMdReader = true; bool _showEpub = true; + bool _showQuickActions = true; @override void initState() { @@ -52,6 +53,7 @@ class _FeatureSettingsPageState extends State { _showTags = _userPrefs.showSidebarTags; _showMdReader = _userPrefs.showSidebarMdReader; _showEpub = _userPrefs.showSidebarEpub; + _showQuickActions = _userPrefs.showSidebarQuickActions; }); } @@ -204,6 +206,16 @@ class _FeatureSettingsPageState extends State { await _userPrefs.setShowSidebarRecent(v); setState(() => _showRecent = v); }), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildSwitchItem(Icons.bolt_outlined, '快捷操作', '快速新建笔记/影视/导入EPUB', _showQuickActions, + (v) async { + await _userPrefs.setShowSidebarQuickActions(v); + setState(() => _showQuickActions = v); + }), Divider( height: 0.5, indent: 24, diff --git a/lib/pages/profile/layout_settings_page.dart b/lib/pages/profile/layout_settings_page.dart index 349e99a..91ed682 100644 --- a/lib/pages/profile/layout_settings_page.dart +++ b/lib/pages/profile/layout_settings_page.dart @@ -17,6 +17,7 @@ class _LayoutSettingsPageState extends State { int _bookLayout = 0; int _gameLayout = 0; bool _movieWallMode = false; + int _movieDisplayMode = 0; bool _bookshelfMode = false; bool _gameWallMode = false; @@ -28,6 +29,7 @@ class _LayoutSettingsPageState extends State { _bookLayout = _userPrefs.bookLayoutStyle; _gameLayout = _userPrefs.gameLayoutStyle; _movieWallMode = _userPrefs.movieWallMode; + _movieDisplayMode = _userPrefs.movieDisplayMode; _bookshelfMode = _userPrefs.bookshelfMode; _gameWallMode = _userPrefs.gameWallMode; } @@ -39,181 +41,342 @@ class _LayoutSettingsPageState extends State { backgroundColor: colors.surface, appBar: AppBar(title: const Text('布局设置')), body: ListView( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ - // ── 影视 ── - _buildCategoryHeader(Icons.movie_outlined, '影视', colors.primary), - _buildWallSwitch( - icon: Icons.wallpaper_outlined, - title: '影视墙模式', - subtitle: '显示全部影片,不区分状态', - value: _movieWallMode, - onChanged: (v) => _setWallMode('movie', v), + _buildCategoryTile( + icon: Icons.movie_outlined, + title: '影视', + color: colors.primary, + subtitle: _movieSubtitle, + onTap: _showMovieSheet, ), - _buildLayoutSelector( - selected: _movieLayout, - options: [ - (0, Icons.grid_view_outlined, '海报网格'), - (1, Icons.view_list_outlined, '列表'), - (2, Icons.crop_landscape_outlined, '大图卡片'), - ], - onChanged: (v) => _setLayout('movie', v), + _buildCategoryTile( + icon: Icons.menu_book_outlined, + title: '阅读', + color: colors.primary, + subtitle: _bookSubtitle, + onTap: _showBookSheet, ), - const SizedBox(height: 8), - Divider( - height: 1, - indent: 16, - endIndent: 16, - color: colors.outlineVariant.withValues(alpha: 0.5)), - const SizedBox(height: 8), - - // ── 阅读 ── - _buildCategoryHeader(Icons.menu_book_outlined, '阅读', colors.primary), - _buildWallSwitch( - icon: Icons.auto_stories_outlined, - title: '书架模式', - subtitle: '显示全部书籍,不区分状态', - value: _bookshelfMode, - onChanged: (v) => _setWallMode('book', v), + _buildCategoryTile( + icon: Icons.note_outlined, + title: '笔记', + color: colors.primary, + subtitle: _noteSubtitle, + onTap: _showNoteSheet, ), - _buildLayoutSelector( - selected: _bookLayout, - options: [ - (0, Icons.grid_view_outlined, '海报网格'), - (1, Icons.view_list_outlined, '列表'), - ], - onChanged: (v) => _setLayout('book', v), - ), - const SizedBox(height: 8), - Divider( - height: 1, - indent: 16, - endIndent: 16, - color: colors.outlineVariant.withValues(alpha: 0.5)), - const SizedBox(height: 8), - - // ── 笔记 ── - _buildCategoryHeader(Icons.note_outlined, '笔记', colors.primary), - _buildLayoutSelector( - selected: _noteLayout, - options: [ - (0, Icons.view_list_outlined, '列表'), - (1, Icons.grid_view_outlined, '瀑布流'), - (2, Icons.timeline_outlined, '时间线'), - ], - onChanged: (v) => _setLayout('note', v), - ), - const SizedBox(height: 8), - Divider( - height: 1, - indent: 16, - endIndent: 16, - color: colors.outlineVariant.withValues(alpha: 0.5)), - const SizedBox(height: 8), - - // ── 游戏 ── - _buildCategoryHeader(Icons.sports_esports_outlined, '游戏', colors.primary), - _buildWallSwitch( - icon: Icons.wallpaper_outlined, - title: '游戏墙模式', - subtitle: '显示全部游戏,不区分状态', - value: _gameWallMode, - onChanged: (v) => _setWallMode('game', v), - ), - _buildLayoutSelector( - selected: _gameLayout, - options: [ - (0, Icons.grid_view_outlined, '海报网格'), - (1, Icons.view_list_outlined, '列表'), - (2, Icons.crop_landscape_outlined, '大图卡片'), - ], - onChanged: (v) => _setLayout('game', v), + _buildCategoryTile( + icon: Icons.sports_esports_outlined, + title: '游戏', + color: colors.primary, + subtitle: _gameSubtitle, + onTap: _showGameSheet, ), ], ), ); } - Widget _buildCategoryHeader(IconData icon, String title, Color color) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, size: 16, color: color), - ), - const SizedBox(width: 10), - Text(title, - style: TextStyle( - fontSize: 15, fontWeight: FontWeight.w700, color: color)), - ], - ), - ); + String get _movieSubtitle { + final parts = []; + if (_movieWallMode) { + parts.add('影视墙'); + } else { + parts.add(_movieDisplayMode == 1 ? '分类状态' : '观看状态'); + } + parts.add(['海报网格', '列表', '大图卡片'][_movieLayout]); + return parts.join(' · '); } - Widget _buildWallSwitch({ + String get _bookSubtitle { + final parts = []; + if (_bookshelfMode) parts.add('书架模式'); + parts.add(['海报网格', '列表'][_bookLayout]); + return parts.join(' · '); + } + + String get _noteSubtitle => ['列表', '瀑布流', '时间线'][_noteLayout]; + + String get _gameSubtitle { + final parts = []; + if (_gameWallMode) parts.add('游戏墙'); + parts.add(['海报网格', '列表', '大图卡片'][_gameLayout]); + return parts.join(' · '); + } + + // ─── 分类行 ────────────────────────────────────────────────────── + + Widget _buildCategoryTile({ required IconData icon, + required String title, + required Color color, + required String subtitle, + required VoidCallback onTap, + }) { + final colors = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.5), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + child: Row( + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: color), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))), + ], + ), + ), + Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)), + ], + ), + ), + ), + ); + } + + // ─── 弹窗内组件 ────────────────────────────────────────────────── + + Widget _sheetSwitchRow({ required String title, required String subtitle, required bool value, required ValueChanged onChanged, + required ColorScheme colors, }) { - final colors = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colors.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))), + ], + ), + ), + Switch(value: value, onChanged: onChanged, activeThumbColor: colors.primary), + ], + ), + ); + } + + Widget _sheetOptionRow({ + required String label, + required int selected, + required List<(int, IconData, String)> options, + required ValueChanged onChanged, + required ColorScheme colors, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(width: 12), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: options.map((opt) { + final isSelected = selected == opt.$1; + return Padding( + padding: const EdgeInsets.only(left: 6), + child: GestureDetector( + onTap: () => onChanged(opt.$1), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? colors.primary : colors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? colors.primary : colors.outlineVariant.withValues(alpha: 0.7), + width: isSelected ? 0 : 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(opt.$2, size: 14, color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 4), + Text(opt.$3, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + )), + ], + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } + + Widget _sheetDivider(ColorScheme colors) { + return Divider(height: 1, indent: 20, endIndent: 20, color: colors.outlineVariant.withValues(alpha: 0.4)); + } + + // ─── 弹窗 ──────────────────────────────────────────────────────── + + void _showMovieSheet() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(colors), + _sheetTitle('影视布局', colors), + _sheetSwitchRow( + title: '影视墙模式', subtitle: '显示全部影片,不区分状态', + value: _movieWallMode, onChanged: (v) { _setWallMode('movie', v); setSheetState(() {}); }, colors: colors, + ), + if (!_movieWallMode) ...[ + _sheetDivider(colors), + _sheetOptionRow( + label: '显示模式', selected: _movieDisplayMode, + options: const [(0, Icons.check_circle_outline, '观看状态'), (1, Icons.category_outlined, '分类状态')], + onChanged: (v) { _setDisplayMode('movie', v); setSheetState(() {}); }, colors: colors, ), - child: Icon(icon, - size: 20, color: colors.primary.withValues(alpha: 0.8)), + ], + _sheetDivider(colors), + _sheetOptionRow( + label: '布局样式', selected: _movieLayout, + options: const [(0, Icons.grid_view_outlined, '海报网格'), (1, Icons.view_list_outlined, '列表'), (2, Icons.crop_landscape_outlined, '大图卡片')], + onChanged: (v) { _setLayout('movie', v); setSheetState(() {}); }, colors: colors, ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colors.onSurface)), - const SizedBox(height: 2), - Text(subtitle, - style: TextStyle( - fontSize: 12, - color: colors.onSurface.withValues(alpha: 0.5))), - ], - ), - ), - Switch( - value: value, - onChanged: onChanged, - activeThumbColor: colors.primary, - ), - ], + const SizedBox(height: 16), + ]), ), ), ); } + void _showBookSheet() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(colors), + _sheetTitle('阅读布局', colors), + _sheetSwitchRow( + title: '书架模式', subtitle: '显示全部书籍,不区分状态', + value: _bookshelfMode, onChanged: (v) { _setWallMode('book', v); setSheetState(() {}); }, colors: colors, + ), + _sheetDivider(colors), + _sheetOptionRow( + label: '布局样式', selected: _bookLayout, + options: const [(0, Icons.grid_view_outlined, '海报网格'), (1, Icons.view_list_outlined, '列表')], + onChanged: (v) { _setLayout('book', v); setSheetState(() {}); }, colors: colors, + ), + const SizedBox(height: 16), + ]), + ), + ), + ); + } + + void _showNoteSheet() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(colors), + _sheetTitle('笔记布局', colors), + _sheetOptionRow( + label: '布局样式', selected: _noteLayout, + options: const [(0, Icons.view_list_outlined, '列表'), (1, Icons.grid_view_outlined, '瀑布流'), (2, Icons.timeline_outlined, '时间线')], + onChanged: (v) { _setLayout('note', v); setSheetState(() {}); }, colors: colors, + ), + const SizedBox(height: 16), + ]), + ), + ), + ); + } + + void _showGameSheet() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(colors), + _sheetTitle('游戏布局', colors), + _sheetSwitchRow( + title: '游戏墙模式', subtitle: '显示全部游戏,不区分状态', + value: _gameWallMode, onChanged: (v) { _setWallMode('game', v); setSheetState(() {}); }, colors: colors, + ), + _sheetDivider(colors), + _sheetOptionRow( + label: '布局样式', selected: _gameLayout, + options: const [(0, Icons.grid_view_outlined, '海报网格'), (1, Icons.view_list_outlined, '列表'), (2, Icons.crop_landscape_outlined, '大图卡片')], + onChanged: (v) { _setLayout('game', v); setSheetState(() {}); }, colors: colors, + ), + const SizedBox(height: 16), + ]), + ), + ), + ); + } + + Widget _sheetHandle(ColorScheme colors) { + return Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))); + } + + Widget _sheetTitle(String title, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Align(alignment: Alignment.centerLeft, + child: Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + ); + } + + // ─── 状态更新 ────────────────────────────────────────────────── + void _setWallMode(String type, bool value) async { switch (type) { case 'movie': @@ -231,6 +394,15 @@ class _LayoutSettingsPageState extends State { } } + void _setDisplayMode(String type, int value) async { + switch (type) { + case 'movie': + await _userPrefs.setMovieDisplayMode(value); + setState(() => _movieDisplayMode = value); + if (mounted) context.read().setMovieDisplayMode(value); + } + } + void _setLayout(String type, int value) async { switch (type) { case 'note': @@ -249,66 +421,4 @@ class _LayoutSettingsPageState extends State { if (mounted) context.read().setGameLayoutStyle(value); } } - - Widget _buildLayoutSelector({ - required int selected, - required List<(int, IconData, String)> options, - required ValueChanged onChanged, - }) { - final colors = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - children: options.map((opt) { - final isSelected = selected == opt.$1; - return Expanded( - child: GestureDetector( - onTap: () => onChanged(opt.$1), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - margin: const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: isSelected - ? colors.primary - : colors.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? colors.primary - : colors.outlineVariant.withValues(alpha: 0.5), - width: isSelected ? 0 : 0.5, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - opt.$2, - size: 22, - color: isSelected - ? colors.onPrimary - : colors.onSurface.withValues(alpha: 0.5), - ), - const SizedBox(height: 6), - Text( - opt.$3, - style: TextStyle( - fontSize: 12, - fontWeight: - isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected - ? colors.onPrimary - : colors.onSurface.withValues(alpha: 0.5), - ), - ), - ], - ), - ), - ), - ); - }).toList(), - ), - ); - } } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index e3e9fe0..4f9f4e3 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -71,6 +71,12 @@ class AppProvider extends ChangeNotifier { // 影视墙模式(不显示分类,按创建时间排序) bool _movieWallMode = false; + // 影视显示模式 (0: 观看状态, 1: 分类状态) + int _movieDisplayMode = 0; + + // 影视分类索引 + int _movieCategoryIndex = 0; + // 阅读选中的状态 (0: 读完,1: 在读,2: 准备读) int _bookStatusIndex = 0; @@ -162,6 +168,7 @@ class AppProvider extends ChangeNotifier { final userPrefs = UserPrefs(); _movieLayoutStyle = userPrefs.movieLayoutStyle; _movieWallMode = userPrefs.movieWallMode; + _movieDisplayMode = userPrefs.movieDisplayMode; _bookshelfMode = userPrefs.bookshelfMode; _gameLayoutStyle = userPrefs.gameLayoutStyle; _gameWallMode = userPrefs.gameWallMode; @@ -225,8 +232,8 @@ class AppProvider extends ChangeNotifier { // ─── 分页加载(供列表页触底加载使用)──────────────────────── static const int _pageSize = 20; - Future> loadMoviesPaged({String? status, required int offset, int sortMode = 0}) async { - return _movieDao.getMoviesPaged(status: status, limit: _pageSize, offset: offset, sortMode: sortMode); + Future> loadMoviesPaged({String? status, String? category, required int offset, int sortMode = 0}) async { + return _movieDao.getMoviesPaged(status: status, category: category, limit: _pageSize, offset: offset, sortMode: sortMode); } Future> loadBooksPaged({String? status, required int offset, int sortMode = 0}) async { @@ -251,6 +258,8 @@ class AppProvider extends ChangeNotifier { int get movieStatusIndex => _movieStatusIndex; int get movieLayoutStyle => _movieLayoutStyle; bool get movieWallMode => _movieWallMode; + int get movieDisplayMode => _movieDisplayMode; + int get movieCategoryIndex => _movieCategoryIndex; int get bookStatusIndex => _bookStatusIndex; bool get bookshelfMode => _bookshelfMode; int get gameStatusIndex => _gameStatusIndex; @@ -383,6 +392,17 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void setMovieDisplayMode(int mode) { + _movieDisplayMode = mode; + UserPrefs().setMovieDisplayMode(mode); + notifyListeners(); + } + + void setMovieCategoryIndex(int index) { + _movieCategoryIndex = index; + notifyListeners(); + } + void setBookStatusIndex(int index) { _bookStatusIndex = index; notifyListeners(); diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index d0ed337..1666ccb 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -141,6 +141,9 @@ class UserPrefs { bool get showSidebarEpub => prefs.getBool('showSidebarEpub') ?? true; Future setShowSidebarEpub(bool value) => prefs.setBool('showSidebarEpub', value); + bool get showSidebarQuickActions => prefs.getBool('showSidebarQuickActions') ?? true; + Future setShowSidebarQuickActions(bool value) => prefs.setBool('showSidebarQuickActions', value); + /// 笔记布局样式 (0: 列表, 1: 瀑布流, 2: 时间线) int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0; Future setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value); @@ -161,6 +164,10 @@ class UserPrefs { int get movieLayoutStyle => prefs.getInt('movieLayoutStyle') ?? 0; Future setMovieLayoutStyle(int value) => prefs.setInt('movieLayoutStyle', value); + /// 影视显示模式 (0: 观看状态, 1: 分类状态) + int get movieDisplayMode => prefs.getInt('movieDisplayMode') ?? 0; + Future setMovieDisplayMode(int value) => prefs.setInt('movieDisplayMode', value); + /// 阅读布局样式 (0: 封面网格, 1: 列表) int get bookLayoutStyle => prefs.getInt('bookLayoutStyle') ?? 0; Future setBookLayoutStyle(int value) => prefs.setInt('bookLayoutStyle', value); @@ -276,6 +283,10 @@ class UserPrefs { int get epubViewMode => prefs.getInt('epubViewMode') ?? 0; Future setEpubViewMode(int value) => prefs.setInt('epubViewMode', value); + /// EPUB 书架排序模式: 0=更新时间, 1=创建时间, 2=阅读进度, 3=书名 + int get epubSortMode => prefs.getInt('epubSortMode') ?? 0; + Future setEpubSortMode(int value) => prefs.setInt('epubSortMode', value); + /// EPUB 句读列表视图模式: 0=瀑布流, 1=列表 int get highlightsViewMode => prefs.getInt('highlightsViewMode') ?? 0; Future setHighlightsViewMode(int value) => prefs.setInt('highlightsViewMode', value); diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 5de7e5d..acf56ea 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -11,9 +11,13 @@ import '../pages/markdown_reader/md_reader_tab_page.dart'; import '../pages/epub_reader/epub_library_page.dart'; import '../pages/settings/tag_management_page.dart'; import '../pages/movies/movie_detail_page.dart'; +import '../pages/movies/movie_form_page.dart'; import '../pages/book/book_detail_page.dart'; +import '../pages/book/book_form_page.dart'; import '../pages/note/note_detail_page.dart'; +import '../pages/note/note_form_page.dart'; import '../pages/game/game_detail_page.dart'; +import '../pages/profile/settings_page.dart'; import '../models/data_models.dart'; import 'fade_in_local_image.dart'; @@ -81,6 +85,7 @@ class _CustomDrawerState extends State { final userPrefs = UserPrefs(); final showHeatmap = userPrefs.showSidebarHeatmap; final showRecent = userPrefs.showSidebarRecent; + final showQuickActions = userPrefs.showSidebarQuickActions; final showTools = userPrefs.showSidebarEncounter || userPrefs.showSidebarStroll || userPrefs.showSidebarCalendar || @@ -95,6 +100,10 @@ class _CustomDrawerState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildProfileCard(context), + if (showQuickActions) ...[ + const SizedBox(height: 12), + _buildQuickActions(context), + ], if (showHeatmap) ...[ const SizedBox(height: 16), _buildCalendarSection(context), @@ -108,11 +117,23 @@ class _CustomDrawerState extends State { _buildToolsCard(context), ], Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), - child: Center( - child: Text('v$_version', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.2))), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('v$_version', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.2))), + const SizedBox(width: 16), + GestureDetector( + onTap: () { + if (!widget.embedded) Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage())); + }, + child: Icon(Icons.settings_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)), + ), + ], ), ), + const SizedBox(height: 16), ], ), ), @@ -141,15 +162,15 @@ class _CustomDrawerState extends State { final gameCount = provider.games.where((g) => !g.isDeleted).length; // 根据功能开关过滤显示的统计项 - final statItems = []; - if (userPrefs.showMovieTab) statItems.add(_buildProfileStatRow(Icons.movie_outlined, movieCount, '观影')); - if (userPrefs.showBookTab) statItems.add(_buildProfileStatRow(Icons.menu_book_outlined, bookCount, '阅读')); - if (userPrefs.showNoteTab) statItems.add(_buildProfileStatRow(Icons.note_outlined, noteCount, '笔记')); - if (userPrefs.showGameTab) statItems.add(_buildProfileStatRow(Icons.sports_esports_outlined, gameCount, '游戏')); + final statItems = <(IconData, int, String, Color)>[]; + if (userPrefs.showMovieTab) statItems.add((Icons.movie_outlined, movieCount, '观影', const Color(0xFF2563EB))); + if (userPrefs.showBookTab) statItems.add((Icons.menu_book_outlined, bookCount, '阅读', const Color(0xFF16A34A))); + if (userPrefs.showNoteTab) statItems.add((Icons.note_outlined, noteCount, '笔记', const Color(0xFF9333EA))); + if (userPrefs.showGameTab) statItems.add((Icons.sports_esports_outlined, gameCount, '游戏', const Color(0xFFEA580C))); return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(16), @@ -160,8 +181,8 @@ class _CustomDrawerState extends State { Row( children: [ Container( - width: 52, - height: 52, + width: 44, + height: 44, decoration: BoxDecoration( shape: BoxShape.circle, color: colors.surfaceContainerHighest, @@ -170,18 +191,18 @@ class _CustomDrawerState extends State { clipBehavior: Clip.antiAlias, child: avatarPath != null && avatarPath.isNotEmpty ? FadeInLocalImage(path: avatarPath, fit: BoxFit.cover, - errorWidget: Icon(Icons.person_outline, size: 26, color: colors.onSurface.withValues(alpha: 0.3))) - : Icon(Icons.person_outline, size: 26, color: colors.onSurface.withValues(alpha: 0.3)), + errorWidget: Icon(Icons.person_outline, size: 22, color: colors.onSurface.withValues(alpha: 0.3))) + : Icon(Icons.person_outline, size: 22, color: colors.onSurface.withValues(alpha: 0.3)), ), - const SizedBox(width: 14), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(nickname, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), + Text(nickname, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), const SizedBox(height: 2), - Text(motto, maxLines: 1, overflow: TextOverflow.ellipsis, + Text(motto, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), ], ), @@ -189,13 +210,30 @@ class _CustomDrawerState extends State { ], ), if (statItems.isNotEmpty) ...[ - const SizedBox(height: 16), - Divider(height: 1, color: colors.outlineVariant), const SizedBox(height: 14), - for (int i = 0; i < statItems.length; i++) ...[ - statItems[i], - if (i < statItems.length - 1) const SizedBox(height: 12), - ], + Row( + children: statItems.map((item) { + final (icon, count, label, color) = item; + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_formatCount(count), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color)), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ], + ), + ); + }).toList(), + ), ], ], ), @@ -204,42 +242,120 @@ class _CustomDrawerState extends State { ); } - Widget _buildProfileStatRow(IconData icon, int count, String label) { - final colors = Theme.of(context).colorScheme; - return Row( - children: [ - Icon(icon, size: 16, color: colors.onSurface.withValues(alpha: 0.5)), - const SizedBox(width: 10), - Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), - const Spacer(), - Text(_formatCount(count), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - ], - ); - } - String _formatCount(int count) { if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}万'; if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k'; return count.toString(); } + // ─── 快捷操作 ────────────────────────────────────────────────────── + + Widget _buildQuickActions(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final userPrefs = UserPrefs(); + + final actions = <(IconData, String, Color, VoidCallback)>[]; + if (userPrefs.showMovieTab) actions.add(( + Icons.add_photo_alternate_outlined, '影视', const Color(0xFF2563EB), + () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const MovieFormPage())); }, + )); + if (userPrefs.showBookTab) actions.add(( + Icons.menu_book_outlined, '阅读', const Color(0xFF16A34A), + () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const BookFormPage())); }, + )); + if (userPrefs.showNoteTab) actions.add(( + Icons.edit_note_outlined, '笔记', const Color(0xFF9333EA), + () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const NoteFormPage())); }, + )); + + if (actions.isEmpty) return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: actions.map((action) { + final (icon, label, color, onTap) = action; + return Expanded( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), + ], + ), + ), + ), + ); + }).toList(), + ), + ); + } + // ─── 功能入口卡片 ──────────────────────────────────────────────────── Widget _buildToolsCard(BuildContext context) { final colors = Theme.of(context).colorScheme; final userPrefs = UserPrefs(); - final items = <(IconData, String, Widget)>[]; - if (userPrefs.showSidebarEncounter) items.add((Icons.favorite_border, '统计', const EncounterPage())); - if (userPrefs.showSidebarStroll) items.add((Icons.explore_outlined, '漫步', const StrollPage())); - if (userPrefs.showSidebarCalendar) items.add((Icons.calendar_month_outlined, '书影日历', const MediaCalendarPage())); - if (userPrefs.showSidebarPerson) items.add((Icons.people_outline, '角色信息', const PersonListPage())); - if (userPrefs.showSidebarTags) items.add((Icons.label_outline, '标签管理', const TagManagementPage())); - if (userPrefs.showSidebarMdReader) items.add((Icons.description_outlined, 'MD阅读', const MdReaderTabPage())); - if (userPrefs.showSidebarEpub) items.add((Icons.auto_stories_outlined, 'EPUB阅读', const EpubLibraryPage())); + final exploreItems = <(IconData, String, Widget)>[]; + if (userPrefs.showSidebarEncounter) exploreItems.add((Icons.favorite_border, '统计', const EncounterPage())); + if (userPrefs.showSidebarStroll) exploreItems.add((Icons.explore_outlined, '漫步', const StrollPage())); + if (userPrefs.showSidebarCalendar) exploreItems.add((Icons.calendar_month_outlined, '书影日历', const MediaCalendarPage())); - if (items.isEmpty) return const SizedBox.shrink(); + final toolItems = <(IconData, String, Widget)>[]; + if (userPrefs.showSidebarPerson) toolItems.add((Icons.people_outline, '角色信息', const PersonListPage())); + if (userPrefs.showSidebarTags) toolItems.add((Icons.label_outline, '标签管理', const TagManagementPage())); + if (userPrefs.showSidebarMdReader) toolItems.add((Icons.description_outlined, 'MD阅读', const MdReaderTabPage())); + if (userPrefs.showSidebarEpub) toolItems.add((Icons.auto_stories_outlined, 'EPUB阅读', const EpubLibraryPage())); + if (exploreItems.isEmpty && toolItems.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (exploreItems.isNotEmpty) ...[ + _buildGroupTitle('探索', colors), + const SizedBox(height: 6), + _buildGroupCard(context, exploreItems), + ], + if (toolItems.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildGroupTitle('工具', colors), + const SizedBox(height: 6), + _buildGroupCard(context, toolItems), + ], + ], + ); + } + + Widget _buildGroupTitle(String title, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text(title, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.3), letterSpacing: 1)), + ); + } + + Widget _buildGroupCard(BuildContext context, List<(IconData, String, Widget)> items) { + final colors = Theme.of(context).colorScheme; return Container( margin: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( @@ -389,6 +505,17 @@ class _CustomDrawerState extends State { } } + // 连续打卡天数 + int streak = 0; + for (int i = 0; i < 365; i++) { + final date = today.subtract(Duration(days: i)); + if (dailyCounts[date] != null && dailyCounts[date]! > 0) { + streak++; + } else { + break; + } + } + final monthLabels = {}; for (int week = 0; week < totalWeeks; week++) { final date = lastSunday.subtract(Duration(days: (totalWeeks - 1 - week) * 7)); @@ -412,6 +539,12 @@ class _CustomDrawerState extends State { Icon(Icons.calendar_today, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), const SizedBox(width: 8), Text('热力图', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), + const Spacer(), + if (streak > 0) ...[ + Icon(Icons.local_fire_department, size: 14, color: const Color(0xFFFF6D00)), + const SizedBox(width: 3), + Text('连续 $streak 天', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: const Color(0xFFFF6D00))), + ], ], ), const SizedBox(height: 14), @@ -435,10 +568,21 @@ class _CustomDrawerState extends State { ...List.generate(weekDays, (day) => Row( children: List.generate(totalWeeks, (week) { final count = cells[day][week]; - return Container( - width: cellSize, height: cellSize, - margin: EdgeInsets.only(right: week < totalWeeks - 1 ? cellGap : 0, bottom: day < weekDays - 1 ? cellGap : 0), - decoration: BoxDecoration(color: _heatmapColor(count, maxCount), borderRadius: BorderRadius.circular(2)), + final date = lastSunday.subtract(Duration(days: (totalWeeks - 1 - week) * 7 + (6 - day))); + return GestureDetector( + onTap: count > 0 ? () => _showDayDetail(context, date, dailyCounts[date] ?? 0, movies, books, notes, games) : null, + child: Tooltip( + message: '${date.month}月${date.day}日${count > 0 ? ' · $count条' : ''}', + child: Container( + width: cellSize, height: cellSize, + margin: EdgeInsets.only(right: week < totalWeeks - 1 ? cellGap : 0, bottom: day < weekDays - 1 ? cellGap : 0), + decoration: BoxDecoration( + color: _heatmapColor(count, maxCount), + borderRadius: BorderRadius.circular(2), + border: count > 0 ? null : Border.all(color: colors.outlineVariant.withValues(alpha: 0.3), width: 0.5), + ), + ), + ), ); }), )), @@ -464,6 +608,77 @@ class _CustomDrawerState extends State { ); } + void _showDayDetail(BuildContext context, DateTime date, int count, List movies, List books, List notes, List games) { + final colors = Theme.of(context).colorScheme; + final dayMovies = movies.where((m) => !m.isDeleted && DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day) == date).toList(); + final dayBooks = books.where((b) => !b.isDeleted && DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day) == date).toList(); + final dayNotes = notes.where((n) => !n.isDeleted && DateTime(n.createdAt.year, n.createdAt.month, n.createdAt.day) == date).toList(); + final dayGames = games.where((g) => !g.isDeleted && DateTime(g.createdAt.year, g.createdAt.month, g.createdAt.day) == date).toList(); + + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Row( + children: [ + Text('${date.month}月${date.day}日', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(width: 8), + Text('$count条记录', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.45))), + ], + ), + ), + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 12), + children: [ + if (dayMovies.isNotEmpty) ...[ + for (final m in dayMovies) _dayDetailItem(ctx, Icons.movie_outlined, m.title, colors.primary), + ], + if (dayBooks.isNotEmpty) ...[ + for (final b in dayBooks) _dayDetailItem(ctx, Icons.menu_book_outlined, b.title, const Color(0xFF16A34A)), + ], + if (dayNotes.isNotEmpty) ...[ + for (final n in dayNotes) _dayDetailItem(ctx, Icons.note_outlined, n.title.isNotEmpty ? n.title : '随手记', const Color(0xFF9333EA)), + ], + if (dayGames.isNotEmpty) ...[ + for (final g in dayGames) _dayDetailItem(ctx, Icons.sports_esports_outlined, g.title, const Color(0xFFEA580C)), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _dayDetailItem(BuildContext ctx, IconData icon, String title, Color color) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Row( + children: [ + Container( + width: 28, height: 28, + decoration: BoxDecoration(color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(7)), + child: Icon(icon, size: 15, color: color), + ), + const SizedBox(width: 12), + Expanded(child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, color: Theme.of(ctx).colorScheme.onSurface))), + ], + ), + ); + } + Color _heatmapColor(int count, int maxCount) { if (count == 0) return const Color(0xFFF0F0F0); final ratio = count / maxCount; @@ -504,16 +719,23 @@ class _CustomDrawerState extends State { ], ), const SizedBox(height: 14), - ...recent.take(4).map((item) => InkWell( + ...recent.take(6).map((item) => InkWell( onTap: () => _openRecentItem(context, item), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.only(bottom: 10, top: 2), child: Row( children: [ - Icon( - item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : item.type == 'game' ? Icons.sports_esports_outlined : Icons.note_outlined, - size: 14, color: colors.onSurface.withValues(alpha: 0.3), + Container( + width: 22, height: 22, + decoration: BoxDecoration( + color: _typeColor(item.type).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(5), + ), + child: Icon( + item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : item.type == 'game' ? Icons.sports_esports_outlined : Icons.note_outlined, + size: 12, color: _typeColor(item.type), + ), ), const SizedBox(width: 10), Expanded( @@ -553,6 +775,16 @@ class _CustomDrawerState extends State { if (diff.inHours > 0) return '${diff.inHours}小时前'; return '刚刚'; } + + Color _typeColor(String type) { + return switch (type) { + 'movie' => const Color(0xFF2563EB), + 'book' => const Color(0xFF16A34A), + 'note' => const Color(0xFF9333EA), + 'game' => const Color(0xFFEA580C), + _ => const Color(0xFF6B7280), + }; + } } class _RecentItem { diff --git a/lib/widgets/movie_category_bar.dart b/lib/widgets/movie_category_bar.dart new file mode 100644 index 0000000..f60d6c6 --- /dev/null +++ b/lib/widgets/movie_category_bar.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; + +/// 影视分类选择栏 - 可横向滚动,带动画指示器 +class MovieCategoryBar extends StatelessWidget { + const MovieCategoryBar({super.key}); + + static const _categories = [ + ('电影', 'movie'), + ('电视剧', 'tv'), + ('动漫', 'anime'), + ('综艺', 'variety'), + ('纪录片', 'documentary'), + ('微短剧', 'short'), + ('其他', 'other'), + ]; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Consumer( + builder: (context, provider, child) { + final currentIndex = provider.movieCategoryIndex; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration(color: colors.surface), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final count = _categories.length; + final tabWidth = constraints.maxWidth / count; + return SizedBox( + height: 36, + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + left: currentIndex * tabWidth, + top: 0, bottom: 0, width: tabWidth, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + ), + ), + ), + Row( + children: List.generate(count, (i) { + final isSelected = currentIndex == i; + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => provider.setMovieCategoryIndex(i), + child: AnimatedOpacity( + opacity: isSelected ? 1.0 : 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Center( + child: FittedBox( + child: Text( + _categories[i].$1, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? colors.onPrimary : colors.onSurface, + ), + ), + ), + ), + ), + ), + ); + }), + ), + ], + ), + ); + }, + ), + ), + ); + }, + ); + } + + /// 根据索引获取分类值 + static String categoryValue(int index) => + _categories[index.clamp(0, _categories.length - 1)].$2; + + /// 根据索引获取分类标签 + static String categoryLabel(int index) => + _categories[index.clamp(0, _categories.length - 1)].$1; + + /// 分类数量 + static int get count => _categories.length; +}