From 65231e204f01398bd80c17847422030c1a2541c6 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 9 Aug 2026 22:42:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B0=83=E6=95=B4=E2=80=9C?= =?UTF-8?q?=E6=88=91=E7=9A=84=E2=80=9D=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/book/book_tab_page.dart | 10 +- lib/pages/game/game_tab_page.dart | 10 +- lib/pages/movies/movie_tab_page.dart | 10 +- lib/pages/profile/layout_settings_page.dart | 77 ++++- lib/pages/profile/profile_page.dart | 157 ++++++++-- lib/providers/app_provider.dart | 33 ++ lib/utils/user_prefs.dart | 12 + lib/widgets/book_status_bar.dart | 103 +------ lib/widgets/custom_drawer.dart | 2 +- lib/widgets/game_status_bar.dart | 103 +------ lib/widgets/movie_status_bar.dart | 100 +----- lib/widgets/status_bar_shell.dart | 325 ++++++++++++++++++++ lib/widgets/top_fade_scrim.dart | 32 ++ 13 files changed, 682 insertions(+), 292 deletions(-) create mode 100644 lib/widgets/status_bar_shell.dart create mode 100644 lib/widgets/top_fade_scrim.dart diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index 2aadfa4..979dbbe 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -8,6 +8,7 @@ import '../../widgets/book_status_bar.dart'; import '../../widgets/book_list_item.dart'; import '../../widgets/animated_star_rating.dart'; import '../../widgets/shimmer_skeleton.dart'; +import '../../widgets/top_fade_scrim.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../widgets/master_detail_scaffold.dart'; import '../../widgets/detail_placeholder.dart'; @@ -155,7 +156,14 @@ class _BookTabPageState extends State { final isWallMode = provider.bookshelfMode; final masterContent = Column(children: [ if (!isWallMode) const BookStatusBar(), - Expanded(child: _buildBody(context)), + Expanded( + child: Stack( + children: [ + _buildBody(context), + if (!isWallMode) const TopFadeScrim(), + ], + ), + ), ]); if (!isWideContent) return masterContent; diff --git a/lib/pages/game/game_tab_page.dart b/lib/pages/game/game_tab_page.dart index 5eb6c17..fd7aafc 100644 --- a/lib/pages/game/game_tab_page.dart +++ b/lib/pages/game/game_tab_page.dart @@ -7,6 +7,7 @@ import '../../widgets/game_status_bar.dart'; import '../../widgets/game_list_item.dart'; import '../../widgets/animated_star_rating.dart'; import '../../widgets/shimmer_skeleton.dart'; +import '../../widgets/top_fade_scrim.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../utils/responsive.dart'; import '../../widgets/master_detail_scaffold.dart'; @@ -176,7 +177,14 @@ class _GameTabPageState extends State { final masterContent = Column( children: [ if (!isWallMode) const GameStatusBar(), - Expanded(child: _buildBody(context)), + Expanded( + child: Stack( + children: [ + _buildBody(context), + if (!isWallMode) const TopFadeScrim(), + ], + ), + ), ], ); diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index 74bcb3a..a16cd28 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -8,6 +8,7 @@ import '../../widgets/movie_category_bar.dart'; import '../../widgets/movie_list_item.dart'; import '../../widgets/animated_star_rating.dart'; import '../../widgets/shimmer_skeleton.dart'; +import '../../widgets/top_fade_scrim.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../utils/responsive.dart'; import '../../widgets/master_detail_scaffold.dart'; @@ -207,7 +208,14 @@ class _MovieTabPageState extends State { provider.movieDisplayMode == 1 ? const MovieCategoryBar() : const MovieStatusBar(), - Expanded(child: _buildBody(context)), + Expanded( + child: Stack( + children: [ + _buildBody(context), + if (!isWallMode) const TopFadeScrim(), + ], + ), + ), ], ); diff --git a/lib/pages/profile/layout_settings_page.dart b/lib/pages/profile/layout_settings_page.dart index 17cfaa6..db35fbc 100644 --- a/lib/pages/profile/layout_settings_page.dart +++ b/lib/pages/profile/layout_settings_page.dart @@ -19,8 +19,11 @@ class _LayoutSettingsPageState extends State { int _gameLayout = 0; bool _movieWallMode = false; int _movieDisplayMode = 0; + int _movieStatusBarStyle = 0; bool _bookshelfMode = false; + int _bookStatusBarStyle = 0; bool _gameWallMode = false; + int _gameStatusBarStyle = 0; int _movieSortMode = 0; int _bookSortMode = 0; int _noteSortMode = 0; @@ -36,8 +39,11 @@ class _LayoutSettingsPageState extends State { _gameLayout = _userPrefs.gameLayoutStyle; _movieWallMode = _userPrefs.movieWallMode; _movieDisplayMode = _userPrefs.movieDisplayMode; + _movieStatusBarStyle = _userPrefs.movieStatusBarStyle; _bookshelfMode = _userPrefs.bookshelfMode; + _bookStatusBarStyle = _userPrefs.bookStatusBarStyle; _gameWallMode = _userPrefs.gameWallMode; + _gameStatusBarStyle = _userPrefs.gameStatusBarStyle; _movieSortMode = _userPrefs.movieSortMode; _bookSortMode = _userPrefs.bookSortMode; _noteSortMode = _userPrefs.noteSortMode; @@ -103,6 +109,9 @@ class _LayoutSettingsPageState extends State { parts.add('影视墙'); } else { parts.add(_movieDisplayMode == 1 ? '分类状态' : '观看状态'); + if (_movieDisplayMode == 0) { + parts.add(['胶囊', '下划线', '芯片', '下拉'][_movieStatusBarStyle]); + } } parts.add(['海报网格', '列表', '大图卡片'][_movieLayout]); parts.add(['更新时间', '创建时间', '评分', '观看日期', '上映时间'][_movieSortMode]); @@ -111,7 +120,11 @@ class _LayoutSettingsPageState extends State { String get _bookSubtitle { final parts = []; - if (_bookshelfMode) parts.add('书架模式'); + if (_bookshelfMode) { + parts.add('书架模式'); + } else { + parts.add(['胶囊', '下划线', '芯片', '下拉'][_bookStatusBarStyle]); + } parts.add(['海报网格', '列表'][_bookLayout]); parts.add(['更新时间', '创建时间', '评分', '开始阅读', '出版时间'][_bookSortMode]); return parts.join(' · '); @@ -126,7 +139,11 @@ class _LayoutSettingsPageState extends State { String get _gameSubtitle { final parts = []; - if (_gameWallMode) parts.add('游戏墙'); + if (_gameWallMode) { + parts.add('游戏墙'); + } else { + parts.add(['胶囊', '下划线', '芯片', '下拉'][_gameStatusBarStyle]); + } parts.add(['海报网格', '列表', '大图卡片'][_gameLayout]); parts.add(['更新时间', '创建时间', '评分', '发售时间'][_gameSortMode]); return parts.join(' · '); @@ -384,6 +401,19 @@ class _LayoutSettingsPageState extends State { options: const [(0, Icons.check_circle_outline, '观看状态'), (1, Icons.category_outlined, '分类状态')], onChanged: (v) { _setDisplayMode('movie', v); setSheetState(() {}); }, colors: colors, ), + if (_movieDisplayMode == 0) ...[ + _sheetDivider(colors), + _sheetOptionRow( + label: '状态栏样式', selected: _movieStatusBarStyle, + options: const [ + (0, Icons.circle, '胶囊'), + (1, Icons.format_underlined, '下划线'), + (2, Icons.square_outlined, '芯片'), + (3, Icons.arrow_drop_down_circle_outlined, '下拉'), + ], + onChanged: (v) { _setStatusBarStyle('movie', v); setSheetState(() {}); }, colors: colors, + ), + ], ], _sheetDivider(colors), _sheetOptionRow( @@ -429,6 +459,19 @@ class _LayoutSettingsPageState extends State { title: '书架模式', subtitle: '显示全部书籍,不区分状态', value: _bookshelfMode, onChanged: (v) { _setWallMode('book', v); setSheetState(() {}); }, colors: colors, ), + if (!_bookshelfMode) ...[ + _sheetDivider(colors), + _sheetOptionRow( + label: '状态栏样式', selected: _bookStatusBarStyle, + options: const [ + (0, Icons.circle, '胶囊'), + (1, Icons.format_underlined, '下划线'), + (2, Icons.square_outlined, '芯片'), + (3, Icons.arrow_drop_down_circle_outlined, '下拉'), + ], + onChanged: (v) { _setStatusBarStyle('book', v); setSheetState(() {}); }, colors: colors, + ), + ], _sheetDivider(colors), _sheetOptionRow( label: '布局样式', selected: _bookLayout, @@ -509,6 +552,19 @@ class _LayoutSettingsPageState extends State { title: '游戏墙模式', subtitle: '显示全部游戏,不区分状态', value: _gameWallMode, onChanged: (v) { _setWallMode('game', v); setSheetState(() {}); }, colors: colors, ), + if (!_gameWallMode) ...[ + _sheetDivider(colors), + _sheetOptionRow( + label: '状态栏样式', selected: _gameStatusBarStyle, + options: const [ + (0, Icons.circle, '胶囊'), + (1, Icons.format_underlined, '下划线'), + (2, Icons.square_outlined, '芯片'), + (3, Icons.arrow_drop_down_circle_outlined, '下拉'), + ], + onChanged: (v) { _setStatusBarStyle('game', v); setSheetState(() {}); }, colors: colors, + ), + ], _sheetDivider(colors), _sheetOptionRow( label: '布局样式', selected: _gameLayout, @@ -577,6 +633,23 @@ class _LayoutSettingsPageState extends State { } } + void _setStatusBarStyle(String type, int value) async { + switch (type) { + case 'movie': + await _userPrefs.setMovieStatusBarStyle(value); + setState(() => _movieStatusBarStyle = value); + if (mounted) context.read().setMovieStatusBarStyle(value); + case 'book': + await _userPrefs.setBookStatusBarStyle(value); + setState(() => _bookStatusBarStyle = value); + if (mounted) context.read().setBookStatusBarStyle(value); + case 'game': + await _userPrefs.setGameStatusBarStyle(value); + setState(() => _gameStatusBarStyle = value); + if (mounted) context.read().setGameStatusBarStyle(value); + } + } + void _setLayout(String type, int value) async { switch (type) { case 'note': diff --git a/lib/pages/profile/profile_page.dart b/lib/pages/profile/profile_page.dart index d1a92ad..b5ab107 100644 --- a/lib/pages/profile/profile_page.dart +++ b/lib/pages/profile/profile_page.dart @@ -36,6 +36,9 @@ class _ProfilePageState extends State with RouteAware { String _motto = '好运不会眷顾一无所有之人。'; String? _avatarPath; + // 我的模块切换索引 (0=影视, 1=阅读, 2=笔记, 3=游戏) + int _myModuleIndex = 0; + @override void initState() { super.initState(); @@ -102,21 +105,8 @@ class _ProfilePageState extends State with RouteAware { children: [ _buildHero(movies, books, notes, games), const SizedBox(height: 20), - if (_userPrefs.showMovieTab) ...[ - _buildModuleHeader('影视'), - _buildMovieModule(movies), - const SizedBox(height: 20), - ], - if (_userPrefs.showBookTab) ...[ - _buildModuleHeader('阅读'), - _buildBookModule(books), - const SizedBox(height: 20), - ], - if (_userPrefs.showNoteTab) ...[ - _buildModuleHeader('笔记'), - _buildNoteModule(notes), - const SizedBox(height: 20), - ], + _buildMyModule(movies, books, notes, games), + const SizedBox(height: 20), _buildTagsSection(movies, books, notes), const SizedBox(height: 20), _buildToolsGrid(context), @@ -300,17 +290,136 @@ class _ProfilePageState extends State with RouteAware { ); } - // ─── 模块标题 ────────────────────────────────────────────────────── + // 我的模块数据 + static const _moduleDefs = <(String, IconData)>[ + ('影视', Icons.movie_outlined), + ('阅读', Icons.menu_book_outlined), + ('笔记', Icons.sticky_note_2_outlined), + ('游戏', Icons.sports_esports_outlined), + ]; + + List<(String, IconData)> get _visibleModules { + final result = <(String, IconData)>[]; + if (_userPrefs.showMovieTab) result.add(_moduleDefs[0]); + if (_userPrefs.showBookTab) result.add(_moduleDefs[1]); + if (_userPrefs.showNoteTab) result.add(_moduleDefs[2]); + if (_userPrefs.showGameTab) result.add(_moduleDefs[3]); + return result; + } + + // 我的模块分发 + Widget _buildMyModule(List movies, List books, List notes, List games) { + final modules = _visibleModules; + if (modules.isEmpty) return const SizedBox.shrink(); + final index = _myModuleIndex.clamp(0, modules.length - 1); + final (title, _) = modules[index]; + + Widget content; + switch (title) { + case '阅读': + content = _buildBookModule(books); + break; + case '笔记': + content = _buildNoteModule(notes); + break; + case '游戏': + content = _buildGameModule(games); + break; + default: + content = _buildMovieModule(movies); + } - Widget _buildModuleHeader(String title) { final colors = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text(title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: colors.onSurface)), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text(title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface)), + const Spacer(), + // 图标切换器 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < modules.length; i++) ...[ + if (i != 0) const SizedBox(width: 6), + GestureDetector( + onTap: () => setState(() => _myModuleIndex = i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: i == index ? colors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(modules[i].$2, + size: 16, + color: i == index + ? colors.onPrimary + : colors.onSurface.withValues(alpha: 0.4)), + ), + ), + ], + ], + ), + ], + ), + ), + const SizedBox(height: 8), + content, + ], + ); + } + + // ─── 游戏模块 ────────────────────────────────────────────────────── + + Widget _buildGameModule(List games) { + final colors = Theme.of(context).colorScheme; + final completed = games.where((g) => g.status == 'completed').length; + final playing = games.where((g) => g.status == 'playing').length; + final wantTo = games.where((g) => g.status == 'want_to_play').length; + final recent = games.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row(children: [ + _buildStatusTag('已通关', completed, true, colors), + const SizedBox(width: 10), + _buildStatusTag('在玩', playing, false, colors), + const SizedBox(width: 10), + _buildStatusTag('想玩', wantTo, false, colors), + ]), + ), + const SizedBox(height: 10), + if (recent.isNotEmpty) + SizedBox( + height: 120, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: recent.take(15).length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) => _buildCoverCard( + title: recent[i].title, + imagePath: recent[i].coverPath, + onTap: () => Navigator.pushNamed(context, '/game-detail', + arguments: recent[i]), + ), + ), + ) + else + _buildEmptyHint('暂无游戏记录'), + ], ); } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 0f5f110..6c9a83b 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -99,12 +99,18 @@ class AppProvider extends ChangeNotifier { // 影视显示模式 (0: 观看状态, 1: 分类状态) int _movieDisplayMode = 0; + // 影视状态栏样式 (0: 胶囊滑块, 1: 下划线, 2: 芯片组, 3: 下拉) + int _movieStatusBarStyle = 0; + // 影视分类索引 int _movieCategoryIndex = 0; // 阅读选中的状态 (0: 读完,1: 在读,2: 准备读) int _bookStatusIndex = 0; + // 书籍状态栏样式 (0: 胶囊滑块, 1: 下划线, 2: 芯片组, 3: 下拉) + int _bookStatusBarStyle = 0; + // 书架模式(不显示分类,按创建时间排序) bool _bookshelfMode = false; @@ -114,6 +120,9 @@ class AppProvider extends ChangeNotifier { // 游戏列表布局样式 (0: 网格, 1: 列表, 2: 大图卡片) int _gameLayoutStyle = 0; + // 游戏状态栏样式 (0: 胶囊滑块, 1: 下划线, 2: 芯片组, 3: 下拉) + int _gameStatusBarStyle = 0; + // 游戏墙模式 bool _gameWallMode = false; @@ -199,9 +208,12 @@ class AppProvider extends ChangeNotifier { _movieLayoutStyle = userPrefs.movieLayoutStyle; _movieWallMode = userPrefs.movieWallMode; _movieDisplayMode = userPrefs.movieDisplayMode; + _movieStatusBarStyle = userPrefs.movieStatusBarStyle; + _bookStatusBarStyle = userPrefs.bookStatusBarStyle; _bookshelfMode = userPrefs.bookshelfMode; _gameLayoutStyle = userPrefs.gameLayoutStyle; _gameWallMode = userPrefs.gameWallMode; + _gameStatusBarStyle = userPrefs.gameStatusBarStyle; _homeModuleSwitchMode = userPrefs.homeModuleSwitchMode; final defaultIndex = userPrefs.defaultMainTabIndex; // Windows 桌面端且主页开启时,强制默认为主页 @@ -311,12 +323,15 @@ class AppProvider extends ChangeNotifier { int get movieLayoutStyle => _movieLayoutStyle; bool get movieWallMode => _movieWallMode; int get movieDisplayMode => _movieDisplayMode; + int get movieStatusBarStyle => _movieStatusBarStyle; int get movieCategoryIndex => _movieCategoryIndex; int get bookStatusIndex => _bookStatusIndex; + int get bookStatusBarStyle => _bookStatusBarStyle; bool get bookshelfMode => _bookshelfMode; int get gameStatusIndex => _gameStatusIndex; int get gameLayoutStyle => _gameLayoutStyle; bool get gameWallMode => _gameWallMode; + int get gameStatusBarStyle => _gameStatusBarStyle; bool get drawerOpen => _drawerOpen; bool get bottomNavVisible => _bottomNavVisible; ThemeMode get themeMode => _themeMode; @@ -495,6 +510,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void setMovieStatusBarStyle(int style) { + _movieStatusBarStyle = style; + UserPrefs().setMovieStatusBarStyle(style); + notifyListeners(); + } + void setMovieCategoryIndex(int index) { _movieCategoryIndex = index; notifyListeners(); @@ -511,6 +532,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void setBookStatusBarStyle(int style) { + _bookStatusBarStyle = style; + UserPrefs().setBookStatusBarStyle(style); + notifyListeners(); + } + void setGameStatusIndex(int index) { _gameStatusIndex = index; notifyListeners(); @@ -528,6 +555,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void setGameStatusBarStyle(int style) { + _gameStatusBarStyle = style; + UserPrefs().setGameStatusBarStyle(style); + notifyListeners(); + } + void toggleDrawer() { _drawerOpen = !_drawerOpen; notifyListeners(); diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 242c561..10cbcb5 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -193,10 +193,18 @@ class UserPrefs { int get movieDisplayMode => prefs.getInt('movieDisplayMode') ?? 0; Future setMovieDisplayMode(int value) => prefs.setInt('movieDisplayMode', value); + /// 影视状态栏样式 (0: 胶囊滑块, 1: 下划线, 2: 芯片组, 3: 下拉) + int get movieStatusBarStyle => prefs.getInt('movieStatusBarStyle') ?? 0; + Future setMovieStatusBarStyle(int value) => prefs.setInt('movieStatusBarStyle', value); + /// 阅读布局样式 (0: 封面网格, 1: 列表) int get bookLayoutStyle => prefs.getInt('bookLayoutStyle') ?? 0; Future setBookLayoutStyle(int value) => prefs.setInt('bookLayoutStyle', value); + /// 书籍状态栏样式 (0: 胶囊滑块, 1: 下划线, 2: 芯片组, 3: 下拉) + int get bookStatusBarStyle => prefs.getInt('bookStatusBarStyle') ?? 0; + Future setBookStatusBarStyle(int value) => prefs.setInt('bookStatusBarStyle', value); + /// 影视墙模式(不显示分类,按创建时间排序) bool get movieWallMode => prefs.getBool('movieWallMode') ?? false; Future setMovieWallMode(bool value) => prefs.setBool('movieWallMode', value); @@ -213,6 +221,10 @@ class UserPrefs { int get gameLayoutStyle => prefs.getInt('gameLayoutStyle') ?? 0; Future setGameLayoutStyle(int value) => prefs.setInt('gameLayoutStyle', value); + /// 游戏状态栏样式 (0: 胶囊滑块, 1: 下划线, 2: 芯片组, 3: 下拉) + int get gameStatusBarStyle => prefs.getInt('gameStatusBarStyle') ?? 0; + Future setGameStatusBarStyle(int value) => prefs.setInt('gameStatusBarStyle', value); + /// 游戏墙模式 bool get gameWallMode => prefs.getBool('gameWallMode') ?? false; Future setGameWallMode(bool value) => prefs.setBool('gameWallMode', value); diff --git a/lib/widgets/book_status_bar.dart b/lib/widgets/book_status_bar.dart index 6462e52..40bf39b 100644 --- a/lib/widgets/book_status_bar.dart +++ b/lib/widgets/book_status_bar.dart @@ -1,103 +1,30 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; +import 'status_bar_shell.dart'; -/// 阅读状态选择栏 - 平滑过渡动画 +/// 阅读状态选择栏 class BookStatusBar extends StatelessWidget { const BookStatusBar({super.key}); + static const _tabs = [ + StatusBarTab(label: '已读', icon: Icons.check_circle_outline), + StatusBarTab(label: '在读', icon: Icons.menu_book_outlined), + StatusBarTab(label: '想读', icon: Icons.bookmark_outlined), + StatusBarTab(label: '弃读', icon: Icons.cancel_outlined), + ]; + @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; return Consumer( - builder: (context, provider, child) { - final currentIndex = provider.bookStatusIndex; - 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 tabWidth = constraints.maxWidth / 4; - return SizedBox( - height: 40, - 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(20), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), - ], - ), - ), - ), - ), - Row( - children: [ - _buildTab(colors, '已读', Icons.check_circle_outline, currentIndex == 0, - () => provider.setBookStatusIndex(0)), - _buildTab(colors, '在读', Icons.menu_book_outlined, currentIndex == 1, - () => provider.setBookStatusIndex(1)), - _buildTab(colors, '想读', Icons.bookmark_outlined, currentIndex == 2, - () => provider.setBookStatusIndex(2)), - _buildTab(colors, '弃读', Icons.cancel_outlined, currentIndex == 3, - () => provider.setBookStatusIndex(3)), - ], - ), - ], - ), - ); - }, - ), - ), + builder: (context, provider, _) { + return StatusBarShell( + tabs: _tabs, + currentIndex: provider.bookStatusIndex, + onChanged: provider.setBookStatusIndex, + style: provider.bookStatusBarStyle, ); }, ); } - - Widget _buildTab(ColorScheme colors, String label, IconData icon, bool isSelected, VoidCallback onTap) { - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: AnimatedOpacity( - opacity: isSelected ? 1.0 : 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: SizedBox.expand( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: isSelected ? colors.onPrimary : colors.onSurface), - const SizedBox(width: 6), - Text(label, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? colors.onPrimary : colors.onSurface, - )), - ], - ), - ), - ), - ), - ); - } } diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 232e0a2..f83404c 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -172,8 +172,8 @@ class _CustomDrawerState extends State { 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.sticky_note_2_outlined, noteCount, '笔记', const Color(0xFF9333EA))); if (userPrefs.showGameTab) statItems.add((Icons.sports_esports_outlined, gameCount, '游戏', const Color(0xFFEA580C))); + if (userPrefs.showNoteTab) statItems.add((Icons.sticky_note_2_outlined, noteCount, '笔记', const Color(0xFF9333EA))); return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), diff --git a/lib/widgets/game_status_bar.dart b/lib/widgets/game_status_bar.dart index 9c955cc..d69b85f 100644 --- a/lib/widgets/game_status_bar.dart +++ b/lib/widgets/game_status_bar.dart @@ -1,103 +1,30 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; +import 'status_bar_shell.dart'; -/// 游戏状态选择栏 - 平滑过渡动画 +/// 游戏状态选择栏 class GameStatusBar extends StatelessWidget { const GameStatusBar({super.key}); + static const _tabs = [ + StatusBarTab(label: '已通关', icon: Icons.emoji_events_outlined), + StatusBarTab(label: '在玩', icon: Icons.sports_esports_outlined), + StatusBarTab(label: '想玩', icon: Icons.bookmark_outlined), + StatusBarTab(label: '弃游', icon: Icons.cancel_outlined), + ]; + @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; return Consumer( - builder: (context, provider, child) { - final currentIndex = provider.gameStatusIndex; - 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 tabWidth = constraints.maxWidth / 4; - return SizedBox( - height: 40, - 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(20), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), - ], - ), - ), - ), - ), - Row( - children: [ - _buildTab(colors, '已通关', Icons.emoji_events_outlined, currentIndex == 0, - () => provider.setGameStatusIndex(0)), - _buildTab(colors, '在玩', Icons.sports_esports_outlined, currentIndex == 1, - () => provider.setGameStatusIndex(1)), - _buildTab(colors, '想玩', Icons.bookmark_outlined, currentIndex == 2, - () => provider.setGameStatusIndex(2)), - _buildTab(colors, '弃游', Icons.cancel_outlined, currentIndex == 3, - () => provider.setGameStatusIndex(3)), - ], - ), - ], - ), - ); - }, - ), - ), + builder: (context, provider, _) { + return StatusBarShell( + tabs: _tabs, + currentIndex: provider.gameStatusIndex, + onChanged: provider.setGameStatusIndex, + style: provider.gameStatusBarStyle, ); }, ); } - - Widget _buildTab(ColorScheme colors, String label, IconData icon, bool isSelected, VoidCallback onTap) { - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: AnimatedOpacity( - opacity: isSelected ? 1.0 : 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: SizedBox.expand( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: isSelected ? colors.onPrimary : colors.onSurface), - const SizedBox(width: 6), - Text(label, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? colors.onPrimary : colors.onSurface, - )), - ], - ), - ), - ), - ), - ); - } } diff --git a/lib/widgets/movie_status_bar.dart b/lib/widgets/movie_status_bar.dart index 0f31865..0cb7f2d 100644 --- a/lib/widgets/movie_status_bar.dart +++ b/lib/widgets/movie_status_bar.dart @@ -1,101 +1,29 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; +import 'status_bar_shell.dart'; -/// 观影状态选择栏 - 平滑过渡动画 +/// 观影状态选择栏 class MovieStatusBar extends StatelessWidget { const MovieStatusBar({super.key}); + static const _tabs = [ + StatusBarTab(label: '已看', icon: Icons.check_circle_outline), + StatusBarTab(label: '在看', icon: Icons.play_circle_outline), + StatusBarTab(label: '想看', icon: Icons.bookmark_outline), + ]; + @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; return Consumer( - builder: (context, provider, child) { - final currentIndex = provider.movieStatusIndex; - 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 tabWidth = constraints.maxWidth / 3; - return SizedBox( - height: 40, - 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(20), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), - ], - ), - ), - ), - ), - Row( - children: [ - _buildTab(colors, '已看', Icons.check_circle_outline, currentIndex == 0, - () => provider.setMovieStatusIndex(0)), - _buildTab(colors, '在看', Icons.play_circle_outline, currentIndex == 1, - () => provider.setMovieStatusIndex(1)), - _buildTab(colors, '想看', Icons.bookmark_outline, currentIndex == 2, - () => provider.setMovieStatusIndex(2)), - ], - ), - ], - ), - ); - }, - ), - ), + builder: (context, provider, _) { + return StatusBarShell( + tabs: _tabs, + currentIndex: provider.movieStatusIndex, + onChanged: provider.setMovieStatusIndex, + style: provider.movieStatusBarStyle, ); }, ); } - - Widget _buildTab(ColorScheme colors, String label, IconData icon, bool isSelected, VoidCallback onTap) { - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: AnimatedOpacity( - opacity: isSelected ? 1.0 : 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: SizedBox.expand( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: isSelected ? colors.onPrimary : colors.onSurface), - const SizedBox(width: 6), - Text(label, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? colors.onPrimary : colors.onSurface, - )), - ], - ), - ), - ), - ), - ); - } } diff --git a/lib/widgets/status_bar_shell.dart b/lib/widgets/status_bar_shell.dart new file mode 100644 index 0000000..85d1826 --- /dev/null +++ b/lib/widgets/status_bar_shell.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; + +/// 通用状态选择栏,支持 4 种样式 +/// 0: 胶囊滑块(默认) +/// 1: 下划线 +/// 2: 芯片组 +/// 3: 下拉 +class StatusBarShell extends StatelessWidget { + const StatusBarShell({ + super.key, + required this.tabs, + required this.currentIndex, + required this.onChanged, + this.style = 0, + }); + + final List tabs; + final int currentIndex; + final ValueChanged onChanged; + final int style; + + @override + Widget build(BuildContext context) { + switch (style) { + case 1: + return _UnderlineBar(tabs: tabs, currentIndex: currentIndex, onChanged: onChanged); + case 2: + return _ChipBar(tabs: tabs, currentIndex: currentIndex, onChanged: onChanged); + case 3: + return _DropdownBar(tabs: tabs, currentIndex: currentIndex, onChanged: onChanged); + default: + return _PillBar(tabs: tabs, currentIndex: currentIndex, onChanged: onChanged); + } + } +} + +class StatusBarTab { + const StatusBarTab({required this.label, required this.icon}); + final String label; + final IconData icon; +} + +// ---------- 0: 胶囊滑块 ---------- +class _PillBar extends StatelessWidget { + const _PillBar({required this.tabs, required this.currentIndex, required this.onChanged}); + final List tabs; + final int currentIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + 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 tabWidth = constraints.maxWidth / tabs.length; + return SizedBox( + height: 40, + 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(20), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + ), + ), + ), + Row( + children: [ + for (int i = 0; i < tabs.length; i++) + _PillItem( + colors: colors, + tab: tabs[i], + isSelected: currentIndex == i, + onTap: () => onChanged(i), + ), + ], + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +class _PillItem extends StatelessWidget { + const _PillItem({required this.colors, required this.tab, required this.isSelected, required this.onTap}); + final ColorScheme colors; + final StatusBarTab tab; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedOpacity( + opacity: isSelected ? 1.0 : 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: SizedBox.expand( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(tab.icon, size: 16, color: isSelected ? colors.onPrimary : colors.onSurface), + const SizedBox(width: 6), + Text(tab.label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? colors.onPrimary : colors.onSurface, + )), + ], + ), + ), + ), + ), + ); + } +} + +// ---------- 1: 下划线 ---------- +class _UnderlineBar extends StatelessWidget { + const _UnderlineBar({required this.tabs, required this.currentIndex, required this.onChanged}); + final List tabs; + final int currentIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + color: colors.surface, + child: Row( + children: [ + for (int i = 0; i < tabs.length; i++) + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(i), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(tabs[i].icon, size: 16, + color: currentIndex == i ? colors.primary : colors.onSurfaceVariant), + const SizedBox(width: 6), + Text(tabs[i].label, + style: TextStyle( + fontSize: 13, + fontWeight: currentIndex == i ? FontWeight.w600 : FontWeight.w500, + color: currentIndex == i ? colors.primary : colors.onSurfaceVariant, + )), + ], + ), + ), + Container( + height: 2, + margin: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: currentIndex == i ? colors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ---------- 2: 芯片组 ---------- +class _ChipBar extends StatelessWidget { + const _ChipBar({required this.tabs, required this.currentIndex, required this.onChanged}); + final List tabs; + final int currentIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + color: colors.surface, + child: Row( + children: [ + for (int i = 0; i < tabs.length; i++) ...[ + if (i != 0) const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: () => onChanged(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: currentIndex == i ? colors.primary : Colors.transparent, + border: Border.all( + color: currentIndex == i ? colors.primary : colors.outlineVariant, + ), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(tabs[i].icon, size: 15, + color: currentIndex == i ? colors.onPrimary : colors.onSurfaceVariant), + const SizedBox(width: 5), + Text(tabs[i].label, + style: TextStyle( + fontSize: 12, + fontWeight: currentIndex == i ? FontWeight.w600 : FontWeight.w500, + color: currentIndex == i ? colors.onPrimary : colors.onSurfaceVariant, + )), + ], + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +// ---------- 3: 下拉 ---------- +class _DropdownBar extends StatelessWidget { + const _DropdownBar({required this.tabs, required this.currentIndex, required this.onChanged}); + final List tabs; + final int currentIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final current = tabs[currentIndex]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: colors.surface, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + PopupMenuButton( + onSelected: onChanged, + itemBuilder: (context) => [ + for (int i = 0; i < tabs.length; i++) + PopupMenuItem( + value: i, + child: Row( + children: [ + Icon(tabs[i].icon, size: 18, + color: i == currentIndex ? colors.primary : colors.onSurfaceVariant), + const SizedBox(width: 8), + Text(tabs[i].label, + style: TextStyle( + fontWeight: i == currentIndex ? FontWeight.w600 : FontWeight.w500, + color: i == currentIndex ? colors.primary : colors.onSurfaceVariant, + )), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(current.icon, size: 16, color: colors.primary), + const SizedBox(width: 6), + Text(current.label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.primary, + )), + const SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down, size: 18, color: colors.onSurfaceVariant), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/top_fade_scrim.dart b/lib/widgets/top_fade_scrim.dart new file mode 100644 index 0000000..f402a50 --- /dev/null +++ b/lib/widgets/top_fade_scrim.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// 列表顶部渐变遮罩带:从 surface 色渐变到透明, +/// 让滚动内容从状态栏下方淡入淡出。 +class TopFadeScrim extends StatelessWidget { + const TopFadeScrim({super.key, this.height = 12}); + + final double height; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return IgnorePointer( + child: Align( + alignment: Alignment.topCenter, + child: Container( + height: height, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.surface, + colors.surface.withValues(alpha: 0), + ], + ), + ), + ), + ), + ); + } +}