From e543618139a6ed1cea815e6eb495a0be6d4281ea Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Wed, 12 Aug 2026 21:01:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=8A=B6=E6=80=81=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/book/book_tab_page.dart | 537 +++++++++++-------- lib/pages/game/game_tab_page.dart | 345 +++++++----- lib/pages/home/home_page.dart | 6 +- lib/pages/movies/movie_tab_page.dart | 450 ++++++++-------- lib/pages/profile/feature_settings_page.dart | 2 +- lib/pages/settings/tag_management_page.dart | 78 +-- lib/widgets/custom_drawer.dart | 138 ++++- lib/widgets/shimmer_skeleton.dart | 7 +- 8 files changed, 957 insertions(+), 606 deletions(-) diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index 979dbbe..e675d91 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -15,7 +15,10 @@ import '../../widgets/detail_placeholder.dart'; import 'book_detail_page.dart'; import 'book_add_page.dart'; -/// 阅读标签页(分页 + 触底加载) +/// 状态索引 → 状态值 +const _bookStatusMap = {0: 'read', 1: 'reading', 2: 'want_to_read', 3: 'abandoned'}; + +/// 阅读标签页(PageView 分页 + 触底加载),左右滑动丝滑切换 class BookTabPage extends StatefulWidget { const BookTabPage({super.key}); @@ -24,147 +27,57 @@ class BookTabPage extends StatefulWidget { } class _BookTabPageState extends State { - int _layoutStyle = 0; - final List _items = []; - bool _hasMore = true; - bool _isLoading = false; - int _offset = 0; - int _lastStatusIndex = -1; - bool _initialized = false; - late ScrollController _scrollController; - AppProvider? _provider; - int _lastScrollSignal = 0; - int _lastEditRefreshCounter = 0; - int _prevBookCount = -1; - int _prevSortMode = -1; - double _dragDelta = 0.0; // 当前拖动偏移量 - - void _onBookTap(Book book) { - if (Breakpoint.isWideContent(context)) { - context.read().selectBook(book); - } else { - Navigator.pushNamed(context, '/book-detail', arguments: book); - } - } - - static const _statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read', 3: 'abandoned'}; + late PageController _pageController; + int _currentPage = 0; // PageView 当前页的唯一真源 + int? _pendingTarget; // 待跟随的页,避免重复调度动画 + int _lastModeSignature = -1; // 编码 wall 模式,检测书架/状态切换 + bool _modeInitialized = false; // 吞掉首次构建的伪"变化" @override void initState() { super.initState(); - _layoutStyle = UserPrefs().bookLayoutStyle; - _scrollController = ScrollController()..addListener(_onScroll); + _pageController = PageController(); + // 应用启动时保存的初始索引(可能 > 0) WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final provider = context.read(); - _provider = provider; - provider.addListener(_onDataChanged); - _loadFirst(); + final p = context.read(); + final initial = _activeIndexFor(p).clamp(0, _pageCountFor(p) - 1); + _currentPage = initial; + if (_pageController.hasClients && initial != 0) _pageController.jumpToPage(initial); }); } @override void dispose() { - _provider?.removeListener(_onDataChanged); - _scrollController.dispose(); + _pageController.dispose(); super.dispose(); } - void _onDataChanged() { - if (!_initialized || !mounted) return; - final provider = context.read(); + int _pageCountFor(AppProvider p) => p.bookshelfMode ? 1 : 4; - // 检查回到顶部信号 - if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) { - _lastScrollSignal = provider.scrollToTopSignal; - if (_scrollController.hasClients) { - _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); - } - } + int _activeIndexFor(AppProvider p) => p.bookshelfMode ? 0 : p.bookStatusIndex; - // 仅在数据实际变化时刷新列表,避免底部导航栏显隐等UI变化误触发重载 - final statusChanged = provider.bookStatusIndex != _lastStatusIndex; - final sortModeChanged = UserPrefs().bookSortMode != _prevSortMode; - final countChanged = provider.books.length != _prevBookCount; - final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; - if (editRefreshed && provider.lastEditedItemId != null) { - // 就地更新被编辑的条目,不重置分页 - _lastEditRefreshCounter = provider.editRefreshCounter; - _prevBookCount = provider.books.length; - final editedId = provider.lastEditedItemId!; - final idx = _items.indexWhere((b) => b.id == editedId); - if (idx != -1) { - final updated = provider.books.where((b) => b.id == editedId).firstOrNull; - if (updated != null) { - setState(() { _items[idx] = updated; }); - } - } - return; - } - if (statusChanged || sortModeChanged || countChanged || editRefreshed) { - _prevSortMode = UserPrefs().bookSortMode; - _prevBookCount = provider.books.length; - _loadFirst(); - } - if (editRefreshed) { - _lastEditRefreshCounter = provider.editRefreshCounter; - } - } - - void _onScroll() { - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { - _loadMore(); - } - } - - Future _loadFirst() async { - final provider = context.read(); - final isWallMode = provider.bookshelfMode; - final statusIdx = provider.bookStatusIndex; - _lastStatusIndex = statusIdx; - _initialized = true; - // 书架模式:不筛选状态,使用用户选择的排序(默认创建时间) - final status = isWallMode ? null : (_statusMap[statusIdx] ?? 'read'); - final sortMode = UserPrefs().bookSortMode; - setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); - final list = await provider.loadBooksPaged(status: status, offset: 0, sortMode: sortMode); - if (!mounted) return; - setState(() { _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; }); - } - - Future _loadMore() async { - if (_isLoading || !_hasMore) return; - setState(() => _isLoading = true); - final provider = context.read(); - final isWallMode = provider.bookshelfMode; - final status = isWallMode ? null : (_statusMap[provider.bookStatusIndex] ?? 'read'); - final sortMode = UserPrefs().bookSortMode; - final list = await provider.loadBooksPaged(status: status, offset: _offset, sortMode: sortMode); - if (!mounted) return; - setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; }); - } - - Future _refresh() async { - await context.read().loadBooks(); - await _loadFirst(); - } + int _modeSignature(AppProvider p) => p.bookshelfMode ? 1 : 0; @override Widget build(BuildContext context) { final isWideContent = Breakpoint.isWideContent(context); final provider = context.watch(); final isWallMode = provider.bookshelfMode; - final masterContent = Column(children: [ - if (!isWallMode) const BookStatusBar(), - Expanded( - child: Stack( - children: [ - _buildBody(context), - if (!isWallMode) const TopFadeScrim(), - ], + + final masterContent = Column( + children: [ + if (!isWallMode) const BookStatusBar(), + Expanded( + child: Stack( + children: [ + _buildPageView(provider), + if (!isWallMode) const TopFadeScrim(), + ], + ), ), - ), - ]); + ], + ); if (!isWideContent) return masterContent; @@ -180,76 +93,264 @@ class _BookTabPageState extends State { ); } - Widget _buildBody(BuildContext context) { - final colors = Theme.of(context).colorScheme; + Widget _buildPageView(AppProvider provider) { + final wall = provider.bookshelfMode; + final pageCount = _pageCountFor(provider); + final mode = wall ? 1 : 0; - // 用 GestureDetector 包裹,左右滑动切换状态 - return GestureDetector( - onHorizontalDragStart: (_) => _dragDelta = 0.0, - onHorizontalDragUpdate: (details) => setState(() => _dragDelta += details.primaryDelta ?? 0), - onHorizontalDragEnd: (details) { - final velocity = details.primaryVelocity; - if ((velocity ?? 0).abs() < 80) { - setState(() => _dragDelta = 0.0); - return; - } - final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个 - final provider = context.read(); - final currentIndex = provider.bookStatusIndex; - final newIndex = (currentIndex + direction + 4) % 4; - setState(() => _dragDelta = 0.0); - provider.setBookStatusIndex(newIndex); - }, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: _dragDelta.clamp(-100.0, 100.0)), - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - builder: (context, value, child) { - return Transform.translate(offset: Offset(value, 0), child: child); - }, - child: Consumer(builder: (context, provider, _) { - if (_initialized && provider.bookStatusIndex != _lastStatusIndex) { - _lastStatusIndex = provider.bookStatusIndex; - WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); - } - final content = () { - if (_items.isEmpty && _isLoading) return _buildSkeleton(); - if (_items.isEmpty) { - return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, - child: ListView(physics: const AlwaysScrollableScrollPhysics(), children: [_buildEmptyState(context, provider.bookStatusIndex)])); - } - return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, - child: _layoutStyle == 1 ? _buildListView() : _buildGridView()); - }(); - return content; - }), + // (a) 首次构建作为基线,不当成模式切换 + if (!_modeInitialized) { + _modeInitialized = true; + _lastModeSignature = _modeSignature(provider); + } + // (b) 模式切换(书架 <-> 状态):跳到第 0 页 + 重置索引 + else if (_modeSignature(provider) != _lastModeSignature) { + _lastModeSignature = _modeSignature(provider); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageController.hasClients) return; + _currentPage = 0; + _pendingTarget = null; + if (!wall) provider.setBookStatusIndex(0); + _pageController.jumpToPage(0); + }); + } + // (c) 外部索引变化(bar 点击等):动画跟随 + final target = _activeIndexFor(provider).clamp(0, pageCount - 1); + if (pageCount > 1 && _pageController.hasClients && target != _currentPage && _pendingTarget != target) { + _pendingTarget = target; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageController.hasClients) return; + _pageController.animateToPage( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }); + } + + return PageView.builder( + controller: _pageController, + itemCount: pageCount, + allowImplicitScrolling: true, // 拖动时预构建相邻页 → 无白色空隙 + onPageChanged: (index) => _onPageChanged(index, provider), + itemBuilder: (context, index) => _BookTabView( + key: ValueKey('$mode-$index'), // 模式切换时全部重建 + index: index, + mode: mode, ), ); } - Widget _buildGridView() { - return LayoutBuilder(builder: (context, constraints) { - final crossAxisCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); - return GridView.builder(controller: _scrollController, - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: crossAxisCount, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16), - itemCount: _items.length + (_hasMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _items.length) return _buildLoadMore(); - final item = _items[index]; - final provider = context.read(); - return BookListItem( - book: item, - selected: Breakpoint.isWideContent(context) && provider.selectedBook?.id == item.id, - onTap: () => _onBookTap(item), - ); - }, - ); + void _onPageChanged(int index, AppProvider provider) { + _currentPage = index; + _pendingTarget = null; + final wall = provider.bookshelfMode; + final cur = wall ? 0 : provider.bookStatusIndex; + // 回显守卫:仅在真实拖动导致索引变化时推送,避免死循环 + if (cur != index) { + if (!wall) provider.setBookStatusIndex(index); + } + } +} + +/// 单页:独立持有列表数据、滚动与分页状态,滑走再滑回保留状态 +class _BookTabView extends StatefulWidget { + final int index; // 0..pageCount-1 + final int mode; // 0=status, 1=wall(书架) + const _BookTabView({super.key, required this.index, required this.mode}); + + @override + State<_BookTabView> createState() => _BookTabViewState(); +} + +class _BookTabViewState extends State<_BookTabView> + with AutomaticKeepAliveClientMixin { + final List _items = []; + bool _hasMore = true; + bool _isLoading = false; + int _offset = 0; + bool _initialized = false; + int _layoutStyle = 0; + late ScrollController _scrollController; + AppProvider? _provider; + int _lastScrollSignal = 0; + int _lastEditRefreshCounter = 0; + int _prevBookCount = -1; + int _prevSortMode = -1; + + String? get _status => widget.mode == 0 ? _bookStatusMap[widget.index] : null; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _layoutStyle = UserPrefs().bookLayoutStyle; + _scrollController = ScrollController()..addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final p = context.read(); + _provider = p; + _lastEditRefreshCounter = p.editRefreshCounter; + _lastScrollSignal = p.scrollToTopSignal; + _prevBookCount = p.books.length; + _prevSortMode = UserPrefs().bookSortMode; + p.addListener(_onDataChanged); + _loadFirst(); }); } + @override + void dispose() { + _provider?.removeListener(_onDataChanged); + _scrollController.dispose(); + super.dispose(); + } + + void _onDataChanged() { + if (!_initialized || !mounted) return; + final p = context.read(); + + // 回到顶部信号:每页滚自己的 controller + if (p.scrollToTopSignal != _lastScrollSignal) { + _lastScrollSignal = p.scrollToTopSignal; + if (_scrollController.hasClients) { + _scrollController.animateTo(0, + duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } + } + + // 就地编辑:更新本页匹配项,不重置分页 + if (p.editRefreshCounter > _lastEditRefreshCounter && p.lastEditedItemId != null) { + _lastEditRefreshCounter = p.editRefreshCounter; + _prevBookCount = p.books.length; + final id = p.lastEditedItemId!; + final i = _items.indexWhere((b) => b.id == id); + if (i != -1) { + final u = p.books.where((b) => b.id == id).firstOrNull; + if (u != null) setState(() => _items[i] = u); + } + return; + } + + // 排序/数量变化才重新拉取(布局变化由 context.select 原地重渲染) + final sortChanged = UserPrefs().bookSortMode != _prevSortMode; + final countChanged = p.books.length != _prevBookCount; + if (sortChanged || countChanged) { + _prevSortMode = UserPrefs().bookSortMode; + _prevBookCount = p.books.length; + _loadFirst(); + } + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + _loadMore(); + } + } + + Future _loadFirst() async { + final provider = context.read(); + final sortMode = UserPrefs().bookSortMode; + _initialized = true; + setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); + final list = await provider.loadBooksPaged(status: _status, offset: 0, sortMode: sortMode); + if (!mounted) return; + setState(() { + _items.clear(); + _items.addAll(list); + _offset = list.length; + _hasMore = list.length >= 20; + _isLoading = false; + }); + } + + Future _loadMore() async { + if (_isLoading || !_hasMore) return; + setState(() => _isLoading = true); + final provider = context.read(); + final sortMode = UserPrefs().bookSortMode; + final list = await provider.loadBooksPaged(status: _status, offset: _offset, sortMode: sortMode); + if (!mounted) return; + setState(() { + _items.addAll(list); + _offset += list.length; + _hasMore = list.length >= 20; + _isLoading = false; + }); + } + + Future _refresh() async { + await context.read().loadBooks(); + await _loadFirst(); + } + + void _onBookTap(Book book) { + if (Breakpoint.isWideContent(context)) { + context.read().selectBook(book); + } else { + Navigator.pushNamed(context, '/book-detail', arguments: book); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + final colors = Theme.of(context).colorScheme; + + final content = () { + if (_items.isEmpty && _isLoading) return _buildSkeleton(_layoutStyle); + if (_items.isEmpty) { + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [_buildEmptyState()], + ), + ); + } + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: _layoutStyle == 1 ? _buildListView() : _buildGridView(), + ); + }(); + return content; + } + + Widget _buildGridView() { + return LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16, + ), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMore(); + final item = _items[index]; + final provider = context.read(); + return BookListItem( + book: item, + selected: Breakpoint.isWideContent(context) && provider.selectedBook?.id == item.id, + onTap: () => _onBookTap(item), + ); + }, + ); + }, + ); + } + Widget _buildListView() { - return ListView.builder(controller: _scrollController, + return ListView.builder( + controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), itemCount: _items.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { @@ -260,10 +361,13 @@ class _BookTabPageState extends State { } Widget _buildLoadMore() { - return Padding(padding: const EdgeInsets.symmetric(vertical: 20), - child: Center(child: _isLoading - ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.primary)) - : Text('没有更多了', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3)))), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: _isLoading + ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.primary)) + : Text('没有更多了', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3))), + ), ); } @@ -272,10 +376,13 @@ class _BookTabPageState extends State { return GestureDetector( onTap: () => _onBookTap(book), onLongPress: () => _showDeleteDialog(context, book), - child: Container(margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), child: Row(children: [ - Container(width: 48, height: 64, + Container( + width: 48, height: 64, decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)), clipBehavior: Clip.antiAlias, child: book.coverPath != null && book.coverPath!.isNotEmpty @@ -287,7 +394,8 @@ class _BookTabPageState extends State { Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - if (book.authors.isNotEmpty) ...[const SizedBox(height: 3), + if (book.authors.isNotEmpty) ...[ + const SizedBox(height: 3), Text(book.authors.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), ], @@ -304,36 +412,47 @@ class _BookTabPageState extends State { void _showDeleteDialog(BuildContext context, Book book) { final colors = Theme.of(context).colorScheme; - showDialog(context: context, builder: (ctx) => AlertDialog( - backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('确定要删除《${book.title}》吗?删除后可在回收站恢复。', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), - ElevatedButton(onPressed: () async { await context.read().removeBook(book.id); if (!ctx.mounted) return; Navigator.pop(ctx); if (mounted) _loadFirst(); }, - style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), - child: const Text('删除'), - ), - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - )); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除《${book.title}》吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () async { + await context.read().removeBook(book.id); + if (!ctx.mounted) return; + Navigator.pop(ctx); + if (mounted) _loadFirst(); + }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); } - Widget _buildSkeleton() => _layoutStyle == 1 ? const MovieSkeletonGrid() : const BookSkeletonGrid(); + Widget _buildSkeleton(int layoutStyle) => layoutStyle == 1 ? const MovieSkeletonGrid() : const BookSkeletonGrid(); - Widget _buildEmptyState(BuildContext context, int statusIndex) { + Widget _buildEmptyState() { final colors = Theme.of(context).colorScheme; - final provider = context.read(); - final isWallMode = provider.bookshelfMode; - final statusText = isWallMode ? '' : ['已读', '在读', '想读', '弃读'][statusIndex]; + final statusText = widget.mode == 1 ? '' : ['已读', '在读', '想读', '弃读'][widget.index]; 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.menu_book_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(widget.mode == 1 ? '暂无书籍' : '暂无$statusText的书籍', + style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), ])); } -} +} \ No newline at end of file diff --git a/lib/pages/game/game_tab_page.dart b/lib/pages/game/game_tab_page.dart index fd7aafc..84e4f57 100644 --- a/lib/pages/game/game_tab_page.dart +++ b/lib/pages/game/game_tab_page.dart @@ -15,7 +15,10 @@ import '../../widgets/detail_placeholder.dart'; import 'game_detail_page.dart'; import 'game_add_page.dart'; -/// 游戏标签页(分页 + 触底加载) +/// 状态索引 → 状态值 +const _gameStatusMap = {0: 'completed', 1: 'playing', 2: 'want_to_play', 3: 'abandoned'}; + +/// 游戏标签页(PageView 分页 + 触底加载),左右滑动丝滑切换 class GameTabPage extends StatefulWidget { const GameTabPage({super.key}); @@ -24,22 +27,160 @@ class GameTabPage extends StatefulWidget { } class _GameTabPageState extends State { + late PageController _pageController; + int _currentPage = 0; // PageView 当前页的唯一真源 + int? _pendingTarget; // 待跟随的页,避免重复调度动画 + int _lastModeSignature = -1; // 编码 wall 模式,检测墙/状态切换 + bool _modeInitialized = false; // 吞掉首次构建的伪"变化" + + @override + void initState() { + super.initState(); + _pageController = PageController(); + // 应用启动时保存的初始索引(可能 > 0) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final p = context.read(); + final initial = _activeIndexFor(p).clamp(0, _pageCountFor(p) - 1); + _currentPage = initial; + if (_pageController.hasClients && initial != 0) _pageController.jumpToPage(initial); + }); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + int _pageCountFor(AppProvider p) => p.gameWallMode ? 1 : 4; + + int _activeIndexFor(AppProvider p) => p.gameWallMode ? 0 : p.gameStatusIndex; + + int _modeSignature(AppProvider p) => p.gameWallMode ? 1 : 0; + + @override + Widget build(BuildContext context) { + final isWideContent = Breakpoint.isWideContent(context); + final provider = context.watch(); + final isWallMode = provider.gameWallMode; + + final masterContent = Column( + children: [ + if (!isWallMode) const GameStatusBar(), + Expanded( + child: Stack( + children: [ + _buildPageView(provider), + if (!isWallMode) const TopFadeScrim(), + ], + ), + ), + ], + ); + + if (!isWideContent) return masterContent; + + final selectedGame = provider.selectedGame; + final detailWidget = provider.isAdding && provider.addingType == 3 + ? GameAddPage(onCancel: () => provider.cancelAdding()) + : selectedGame != null + ? GameDetailPage(game: selectedGame, embedded: true) + : const DetailPlaceholder(icon: Icons.sports_esports_outlined, message: '选择一款游戏查看详情'); + return MasterDetailScaffold( + master: masterContent, + detail: detailWidget, + ); + } + + Widget _buildPageView(AppProvider provider) { + final wall = provider.gameWallMode; + final pageCount = _pageCountFor(provider); + final mode = wall ? 1 : 0; + + // (a) 首次构建作为基线,不当成模式切换 + if (!_modeInitialized) { + _modeInitialized = true; + _lastModeSignature = _modeSignature(provider); + } + // (b) 模式切换(墙 <-> 状态):跳到第 0 页 + 重置索引 + else if (_modeSignature(provider) != _lastModeSignature) { + _lastModeSignature = _modeSignature(provider); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageController.hasClients) return; + _currentPage = 0; + _pendingTarget = null; + if (!wall) provider.setGameStatusIndex(0); + _pageController.jumpToPage(0); + }); + } + // (c) 外部索引变化(bar 点击等):动画跟随 + final target = _activeIndexFor(provider).clamp(0, pageCount - 1); + if (pageCount > 1 && _pageController.hasClients && target != _currentPage && _pendingTarget != target) { + _pendingTarget = target; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageController.hasClients) return; + _pageController.animateToPage( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }); + } + + return PageView.builder( + controller: _pageController, + itemCount: pageCount, + allowImplicitScrolling: true, // 拖动时预构建相邻页 → 无白色空隙 + onPageChanged: (index) => _onPageChanged(index, provider), + itemBuilder: (context, index) => _GameTabView( + key: ValueKey('$mode-$index'), // 模式切换时全部重建 + index: index, + mode: mode, + ), + ); + } + + void _onPageChanged(int index, AppProvider provider) { + _currentPage = index; + _pendingTarget = null; + final wall = provider.gameWallMode; + final cur = wall ? 0 : provider.gameStatusIndex; + // 回显守卫:仅在真实拖动导致索引变化时推送,避免死循环 + if (cur != index) { + if (!wall) provider.setGameStatusIndex(index); + } + } +} + +/// 单页:独立持有列表数据、滚动与分页状态,滑走再滑回保留状态 +class _GameTabView extends StatefulWidget { + final int index; // 0..pageCount-1 + final int mode; // 0=status, 1=wall(墙) + const _GameTabView({super.key, required this.index, required this.mode}); + + @override + State<_GameTabView> createState() => _GameTabViewState(); +} + +class _GameTabViewState extends State<_GameTabView> + with AutomaticKeepAliveClientMixin { final List _items = []; bool _hasMore = true; bool _isLoading = false; int _offset = 0; bool _initialized = false; - int _lastStatusIndex = -1; late ScrollController _scrollController; AppProvider? _provider; int _lastScrollSignal = 0; int _lastEditRefreshCounter = 0; int _prevGameCount = -1; - int _prevLayoutStyle = -1; int _prevSortMode = -1; - double _swipeOffset = 0.0; - static const _statusMap = {0: 'completed', 1: 'playing', 2: 'want_to_play', 3: 'abandoned'}; + String? get _status => widget.mode == 0 ? _gameStatusMap[widget.index] : null; + + @override + bool get wantKeepAlive => true; @override void initState() { @@ -47,9 +188,13 @@ class _GameTabPageState extends State { _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final provider = context.read(); - _provider = provider; - provider.addListener(_onDataChanged); + final p = context.read(); + _provider = p; + _lastEditRefreshCounter = p.editRefreshCounter; + _lastScrollSignal = p.scrollToTopSignal; + _prevGameCount = p.games.length; + _prevSortMode = UserPrefs().gameSortMode; + p.addListener(_onDataChanged); _loadFirst(); }); } @@ -63,34 +208,28 @@ class _GameTabPageState extends State { void _onDataChanged() { if (!_initialized || !mounted) return; - final provider = context.read(); + final p = context.read(); - if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) { - _lastScrollSignal = provider.scrollToTopSignal; + // 回到顶部信号:每页滚自己的 controller + if (p.scrollToTopSignal != _lastScrollSignal) { + _lastScrollSignal = p.scrollToTopSignal; if (_scrollController.hasClients) { - _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + _scrollController.animateTo(0, + duration: const Duration(milliseconds: 300), curve: Curves.easeOut); } } - final statusChanged = provider.gameStatusIndex != _lastStatusIndex; - final layoutChanged = provider.gameLayoutStyle != _prevLayoutStyle; - final countChanged = provider.games.length != _prevGameCount; - final sortModeChanged = UserPrefs().gameSortMode != _prevSortMode; - final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; - if (editRefreshed && provider.lastEditedItemId != null) { - _lastEditRefreshCounter = provider.editRefreshCounter; - _prevGameCount = provider.games.length; - final editedId = provider.lastEditedItemId!; - final idx = _items.indexWhere((g) => g.id == editedId); - final updated = provider.games.where((g) => g.id == editedId).firstOrNull; + // 就地编辑:更新本页匹配项;状态变更则移出本页,不重置分页 + if (p.editRefreshCounter > _lastEditRefreshCounter && p.lastEditedItemId != null) { + _lastEditRefreshCounter = p.editRefreshCounter; + _prevGameCount = p.games.length; + final id = p.lastEditedItemId!; + final idx = _items.indexWhere((g) => g.id == id); + final updated = p.games.where((g) => g.id == id).firstOrNull; if (updated != null) { - final isWallMode = provider.gameWallMode; - final currentStatus = isWallMode ? null : (_statusMap[provider.gameStatusIndex] ?? 'completed'); - if (currentStatus != null && updated.status != currentStatus) { + if (_status != null && updated.status != _status) { // 状态已变更,从当前列表移除 - if (idx != -1) { - setState(() { _items.removeAt(idx); }); - } + if (idx != -1) setState(() { _items.removeAt(idx); }); } else if (idx != -1) { setState(() { _items[idx] = updated; }); } @@ -100,15 +239,15 @@ class _GameTabPageState extends State { } return; } - if (statusChanged || layoutChanged || sortModeChanged || countChanged || editRefreshed) { - _prevLayoutStyle = provider.gameLayoutStyle; + + // 排序/数量变化才重新拉取(布局变化由 context.select 原地重渲染) + final sortChanged = UserPrefs().gameSortMode != _prevSortMode; + final countChanged = p.games.length != _prevGameCount; + if (sortChanged || countChanged) { _prevSortMode = UserPrefs().gameSortMode; - _prevGameCount = provider.games.length; + _prevGameCount = p.games.length; _loadFirst(); } - if (editRefreshed) { - _lastEditRefreshCounter = provider.editRefreshCounter; - } } void _onScroll() { @@ -119,14 +258,10 @@ class _GameTabPageState extends State { Future _loadFirst() async { final provider = context.read(); - final isWallMode = provider.gameWallMode; - final statusIdx = provider.gameStatusIndex; - _lastStatusIndex = statusIdx; - _initialized = true; - final status = isWallMode ? null : (_statusMap[statusIdx] ?? 'completed'); final sortMode = UserPrefs().gameSortMode; + _initialized = true; setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); - final list = await provider.loadGamesPaged(status: status, offset: 0, sortMode: sortMode); + final list = await provider.loadGamesPaged(status: _status, offset: 0, sortMode: sortMode); if (!mounted) return; setState(() { _items.clear(); @@ -141,10 +276,8 @@ class _GameTabPageState extends State { if (_isLoading || !_hasMore) return; setState(() => _isLoading = true); final provider = context.read(); - final isWallMode = provider.gameWallMode; - final status = isWallMode ? null : (_statusMap[provider.gameStatusIndex] ?? 'completed'); final sortMode = UserPrefs().gameSortMode; - final list = await provider.loadGamesPaged(status: status, offset: _offset, sortMode: sortMode); + final list = await provider.loadGamesPaged(status: _status, offset: _offset, sortMode: sortMode); if (!mounted) return; setState(() { _items.addAll(list); @@ -170,95 +303,35 @@ class _GameTabPageState extends State { @override Widget build(BuildContext context) { - final isWideContent = Breakpoint.isWideContent(context); - final provider = context.watch(); - final isWallMode = provider.gameWallMode; - - final masterContent = Column( - children: [ - if (!isWallMode) const GameStatusBar(), - Expanded( - child: Stack( - children: [ - _buildBody(context), - if (!isWallMode) const TopFadeScrim(), - ], - ), - ), - ], - ); - - if (!isWideContent) return masterContent; - - final selectedGame = provider.selectedGame; - final detailWidget = provider.isAdding && provider.addingType == 3 - ? GameAddPage(onCancel: () => provider.cancelAdding()) - : selectedGame != null - ? GameDetailPage(game: selectedGame, embedded: true) - : const DetailPlaceholder(icon: Icons.sports_esports_outlined, message: '选择一款游戏查看详情'); - return MasterDetailScaffold( - master: masterContent, - detail: detailWidget, - ); - } - - Widget _buildBody(BuildContext context) { + super.build(context); final colors = Theme.of(context).colorScheme; - return Consumer( - builder: (context, provider, _) { - if (_initialized && provider.gameStatusIndex != _lastStatusIndex) { - _lastStatusIndex = provider.gameStatusIndex; - WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); - } + final layoutStyle = context.select((p) => p.gameLayoutStyle); - final content = () { - if (_items.isEmpty && _isLoading) return _buildSkeleton(); - if (_items.isEmpty) { - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [_buildEmptyState(context, provider.gameStatusIndex)], - ), - ); - } - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: provider.gameLayoutStyle == 1 ? _buildListView() : provider.gameLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(), - ); - }(); - - return GestureDetector( - onHorizontalDragStart: (_) => _swipeOffset = 0.0, - onHorizontalDragUpdate: (details) => setState(() => _swipeOffset += details.primaryDelta ?? 0), - onHorizontalDragEnd: (details) { - final velocity = details.primaryVelocity; - if ((velocity ?? 0).abs() < 80) { - setState(() => _swipeOffset = 0.0); - return; - } - final direction = (velocity ?? 0) > 0 ? -1 : 1; - final currentIndex = provider.gameStatusIndex; - final newIndex = (currentIndex + direction + 4) % 4; - setState(() => _swipeOffset = 0.0); - provider.setGameStatusIndex(newIndex); - }, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: _swipeOffset.clamp(-100.0, 100.0)), - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - builder: (context, value, child) { - return Transform.translate(offset: Offset(value, 0), child: child); - }, - child: content, + final content = () { + if (_items.isEmpty && _isLoading) return _buildSkeleton(layoutStyle); + if (_items.isEmpty) { + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [_buildEmptyState()], ), ); - }, - ); + } + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: layoutStyle == 1 + ? _buildListView() + : layoutStyle == 2 + ? _buildCoverCardView() + : _buildGridView(), + ); + }(); + return content; } Widget _buildGridView() { @@ -475,8 +548,7 @@ class _GameTabPageState extends State { ); } - Widget _buildSkeleton() { - final layoutStyle = context.read().gameLayoutStyle; + Widget _buildSkeleton(int layoutStyle) { if (layoutStyle == 1) return _buildListSkeleton(); if (layoutStyle == 2) return _buildCoverCardSkeleton(); return const GameSkeletonGrid(); @@ -502,17 +574,16 @@ class _GameTabPageState extends State { ); } - Widget _buildEmptyState(BuildContext context, int statusIndex) { + Widget _buildEmptyState() { final colors = Theme.of(context).colorScheme; - final provider = context.read(); - final isWallMode = provider.gameWallMode; - final statusText = isWallMode ? '' : ['已通关', '在玩', '想玩', '弃游'][statusIndex]; + final statusText = widget.mode == 1 ? '' : ['已通关', '在玩', '想玩', '弃游'][widget.index]; 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.sports_esports_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(widget.mode == 1 ? '暂无游戏' : '暂无$statusText的游戏', + style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), ])); } -} +} \ No newline at end of file diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index f7a1d4d..0d50b5c 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -39,6 +39,7 @@ class _HomePageState extends State { final PageController _pageController = PageController(); bool _isSwitchingPage = false; int _lastNavIndex = 0; + bool _drawerOpen = false; @override void initState() { @@ -185,8 +186,11 @@ class _HomePageState extends State { Widget _buildPhoneLayout(BuildContext context) { return Scaffold( drawer: context.watch().bottomNavIndex != 1 - ? const CustomDrawer() + ? CustomDrawer(isOpen: _drawerOpen) : null, + onDrawerChanged: (isOpen) { + if (_drawerOpen != isOpen) setState(() => _drawerOpen = isOpen); + }, body: Consumer( builder: (context, provider, child) { diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index fbee7ff..67e4ee4 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -16,7 +16,10 @@ import '../../widgets/detail_placeholder.dart'; import 'movie_detail_page.dart'; import 'movie_add_page.dart'; -/// 观影标签页(分页 + 触底加载) +/// 状态索引 → 状态值 +const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; + +/// 观影标签页(PageView 分页 + 触底加载),左右滑动丝滑切换 class MovieTabPage extends StatefulWidget { const MovieTabPage({super.key}); @@ -25,26 +28,171 @@ class MovieTabPage extends StatefulWidget { } class _MovieTabPageState extends State { + late PageController _pageController; + int _currentPage = 0; // PageView 当前页的唯一真源 + int? _pendingTarget; // 待跟随的页,避免重复调度动画 + int _lastModeSignature = -1; // 编码 wall+displayMode,检测模式切换 + bool _modeInitialized = false; // 吞掉首次构建的伪"变化" + + @override + void initState() { + super.initState(); + _pageController = PageController(); + // 应用启动时保存的初始索引(可能 > 0) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final p = context.read(); + final initial = _activeIndexFor(p).clamp(0, _pageCountFor(p) - 1); + _currentPage = initial; + if (_pageController.hasClients && initial != 0) _pageController.jumpToPage(initial); + }); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + int _pageCountFor(AppProvider p) => + p.movieWallMode ? 1 : (p.movieDisplayMode == 1 ? MovieCategoryBar.count : 3); + + int _activeIndexFor(AppProvider p) => + p.movieWallMode ? 0 : (p.movieDisplayMode == 1 ? p.movieCategoryIndex : p.movieStatusIndex); + + int _modeSignature(AppProvider p) => + (p.movieWallMode ? 1 : 0) * 1000 + (p.movieDisplayMode == 1 ? 1 : 0); + + @override + Widget build(BuildContext context) { + final isWideContent = Breakpoint.isWideContent(context); + final provider = context.watch(); + final isWallMode = provider.movieWallMode; + + final masterContent = Column( + children: [ + if (!isWallMode) + provider.movieDisplayMode == 1 + ? const MovieCategoryBar() + : const MovieStatusBar(), + Expanded( + child: Stack( + children: [ + _buildPageView(provider), + if (!isWallMode) const TopFadeScrim(), + ], + ), + ), + ], + ); + + if (!isWideContent) return masterContent; + + final selectedMovie = provider.selectedMovie; + final detailWidget = provider.isAdding && provider.addingType == 0 + ? MovieAddPage(onCancel: () => provider.cancelAdding()) + : selectedMovie != null + ? MovieDetailPage(movie: selectedMovie, embedded: true) + : const DetailPlaceholder(icon: Icons.movie_outlined, message: '选择一部影片查看详情'); + return MasterDetailScaffold( + master: masterContent, + detail: detailWidget, + ); + } + + Widget _buildPageView(AppProvider provider) { + final wall = provider.movieWallMode; + final isCategory = provider.movieDisplayMode == 1; + final pageCount = _pageCountFor(provider); + final mode = wall ? 2 : (isCategory ? 1 : 0); + + // (a) 首次构建作为基线,不当成模式切换 + if (!_modeInitialized) { + _modeInitialized = true; + _lastModeSignature = _modeSignature(provider); + } + // (b) 模式切换(wall 切换 / displayMode 0<->1):跳到第 0 页 + 重置索引 + else if (_modeSignature(provider) != _lastModeSignature) { + _lastModeSignature = _modeSignature(provider); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageController.hasClients) return; + _currentPage = 0; + _pendingTarget = null; + if (isCategory) provider.setMovieCategoryIndex(0); + else if (!wall) provider.setMovieStatusIndex(0); + _pageController.jumpToPage(0); + }); + } + // (c) 外部索引变化(bar 点击等):动画跟随 + final target = _activeIndexFor(provider).clamp(0, pageCount - 1); + if (pageCount > 1 && _pageController.hasClients && target != _currentPage && _pendingTarget != target) { + _pendingTarget = target; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageController.hasClients) return; + _pageController.animateToPage( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }); + } + + return PageView.builder( + controller: _pageController, + itemCount: pageCount, + allowImplicitScrolling: true, // 拖动时预构建相邻页 → 无白色空隙 + onPageChanged: (index) => _onPageChanged(index, provider), + itemBuilder: (context, index) => _MovieTabView( + key: ValueKey('$mode-$index'), // 模式切换时全部重建 + index: index, + mode: mode, + ), + ); + } + + void _onPageChanged(int index, AppProvider provider) { + _currentPage = index; + _pendingTarget = null; + final wall = provider.movieWallMode; + final isCategory = provider.movieDisplayMode == 1; + final cur = wall ? 0 : (isCategory ? provider.movieCategoryIndex : provider.movieStatusIndex); + // 回显守卫:仅在真实拖动导致索引变化时推送,避免死循环 + if (cur != index) { + if (isCategory) provider.setMovieCategoryIndex(index); + else if (!wall) provider.setMovieStatusIndex(index); + } + } +} + +/// 单页:独立持有列表数据、滚动与分页状态,滑走再滑回保留状态 +class _MovieTabView extends StatefulWidget { + final int index; // 0..pageCount-1 + final int mode; // 0=status, 1=category, 2=wall + const _MovieTabView({super.key, required this.index, required this.mode}); + + @override + State<_MovieTabView> createState() => _MovieTabViewState(); +} + +class _MovieTabViewState extends State<_MovieTabView> + with AutomaticKeepAliveClientMixin { final List _items = []; bool _hasMore = true; bool _isLoading = false; int _offset = 0; bool _initialized = false; - int _lastStatusIndex = -1; late ScrollController _scrollController; - AppProvider? _provider; int _lastScrollSignal = 0; int _lastEditRefreshCounter = 0; int _prevMovieCount = -1; - int _prevLayoutStyle = -1; - int _prevCategoryIndex = -1; - int _prevDisplayMode = -1; int _prevSortMode = -1; - double _swipeOffset = 0.0; // 当前拖动偏移量(用于左右滑动切换状态) - int _direction = 1; // 翻页方向:+1 新页从右滑入,-1 新页从左滑入 - int _prevTabIndex = -1; // 上一次的标签索引,用于计算翻页方向 + AppProvider? _provider; - static const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; + String? get _status => widget.mode == 0 ? _statusMap[widget.index] : null; + String? get _category => widget.mode == 1 ? MovieCategoryBar.categoryValue(widget.index) : null; + + @override + bool get wantKeepAlive => true; @override void initState() { @@ -52,9 +200,13 @@ class _MovieTabPageState extends State { _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final provider = context.read(); - _provider = provider; - provider.addListener(_onDataChanged); + final p = context.read(); + _provider = p; + _lastEditRefreshCounter = p.editRefreshCounter; + _lastScrollSignal = p.scrollToTopSignal; + _prevMovieCount = p.movies.length; + _prevSortMode = UserPrefs().movieSortMode; + p.addListener(_onDataChanged); _loadFirst(); }); } @@ -68,49 +220,38 @@ class _MovieTabPageState extends State { void _onDataChanged() { if (!_initialized || !mounted) return; - final provider = context.read(); + final p = context.read(); - // 检查回到顶部信号 - if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) { - _lastScrollSignal = provider.scrollToTopSignal; + // 回到顶部信号:每页滚自己的 controller + if (p.scrollToTopSignal != _lastScrollSignal) { + _lastScrollSignal = p.scrollToTopSignal; if (_scrollController.hasClients) { - _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + _scrollController.animateTo(0, + duration: const Duration(milliseconds: 300), curve: Curves.easeOut); } } - // 仅在数据或布局实际变化时刷新列表,避免底部导航栏显隐等UI变化误触发重载 - final statusChanged = provider.movieStatusIndex != _lastStatusIndex; - final layoutChanged = provider.movieLayoutStyle != _prevLayoutStyle; - final categoryChanged = provider.movieCategoryIndex != _prevCategoryIndex; - final displayModeChanged = provider.movieDisplayMode != _prevDisplayMode; - final sortModeChanged = UserPrefs().movieSortMode != _prevSortMode; - final countChanged = provider.movies.length != _prevMovieCount; - final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; - if (editRefreshed && provider.lastEditedItemId != null) { - // 就地更新被编辑的条目,不重置分页 - _lastEditRefreshCounter = provider.editRefreshCounter; - _prevMovieCount = provider.movies.length; - final editedId = provider.lastEditedItemId!; - final idx = _items.indexWhere((m) => m.id == editedId); - if (idx != -1) { - final updated = provider.movies.where((m) => m.id == editedId).firstOrNull; - if (updated != null) { - setState(() { _items[idx] = updated; }); - } + // 就地编辑:更新本页匹配项,不重置分页 + if (p.editRefreshCounter > _lastEditRefreshCounter && p.lastEditedItemId != null) { + _lastEditRefreshCounter = p.editRefreshCounter; + _prevMovieCount = p.movies.length; + final id = p.lastEditedItemId!; + final i = _items.indexWhere((m) => m.id == id); + if (i != -1) { + final u = p.movies.where((m) => m.id == id).firstOrNull; + if (u != null) setState(() => _items[i] = u); } return; } - if (statusChanged || layoutChanged || categoryChanged || displayModeChanged || sortModeChanged || countChanged || editRefreshed) { - _prevLayoutStyle = provider.movieLayoutStyle; - _prevCategoryIndex = provider.movieCategoryIndex; - _prevDisplayMode = provider.movieDisplayMode; + + // 排序/数量变化才重新拉取(布局变化由 context.select 原地重渲染) + final sortChanged = UserPrefs().movieSortMode != _prevSortMode; + final countChanged = p.movies.length != _prevMovieCount; + if (sortChanged || countChanged) { _prevSortMode = UserPrefs().movieSortMode; - _prevMovieCount = provider.movies.length; + _prevMovieCount = p.movies.length; _loadFirst(); } - if (editRefreshed) { - _lastEditRefreshCounter = provider.editRefreshCounter; - } } void _onScroll() { @@ -121,30 +262,11 @@ 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; - // 影视墙模式:不筛选状态和分类 - // 分类模式:按分类筛选 - // 观看状态模式:按状态筛选 - 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; + _initialized = true; setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); - final list = await provider.loadMoviesPaged(status: status, category: category, offset: 0, sortMode: sortMode); + final list = await provider.loadMoviesPaged( + status: _status, category: _category, offset: 0, sortMode: sortMode); if (!mounted) return; setState(() { _items.clear(); @@ -159,22 +281,9 @@ class _MovieTabPageState extends State { if (_isLoading || !_hasMore) return; setState(() => _isLoading = true); final provider = context.read(); - final isWallMode = provider.movieWallMode; - 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, category: category, offset: _offset, sortMode: sortMode); + final list = await provider.loadMoviesPaged( + status: _status, category: _category, offset: _offset, sortMode: sortMode); if (!mounted) return; setState(() { _items.addAll(list); @@ -200,139 +309,35 @@ class _MovieTabPageState extends State { @override Widget build(BuildContext context) { - final isWideContent = Breakpoint.isWideContent(context); - final provider = context.watch(); - final isWallMode = provider.movieWallMode; - - final masterContent = Column( - children: [ - if (!isWallMode) - provider.movieDisplayMode == 1 - ? const MovieCategoryBar() - : const MovieStatusBar(), - Expanded( - child: Stack( - children: [ - _buildBody(context), - if (!isWallMode) const TopFadeScrim(), - ], - ), - ), - ], - ); - - if (!isWideContent) return masterContent; - - final selectedMovie = provider.selectedMovie; - final detailWidget = provider.isAdding && provider.addingType == 0 - ? MovieAddPage(onCancel: () => provider.cancelAdding()) - : selectedMovie != null - ? MovieDetailPage(movie: selectedMovie, embedded: true) - : const DetailPlaceholder(icon: Icons.movie_outlined, message: '选择一部影片查看详情'); - return MasterDetailScaffold( - master: masterContent, - detail: detailWidget, - ); - } - - Widget _buildBody(BuildContext context) { + super.build(context); final colors = Theme.of(context).colorScheme; - return Consumer( - builder: (context, provider, _) { - // 状态切换时重新加载(跳过首次未初始化的情况) - if (_initialized && provider.movieStatusIndex != _lastStatusIndex) { - _lastStatusIndex = provider.movieStatusIndex; - WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); - } + final layoutStyle = context.select((p) => p.movieLayoutStyle); - final content = () { - if (_items.isEmpty && _isLoading) return _buildSkeleton(); - if (_items.isEmpty) { - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [_buildEmptyState(context, provider.movieStatusIndex)], - ), - ); - } - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(), - ); - }(); - - // 当前标签索引(分类模式或观看状态模式) - final currentIndex = - provider.movieDisplayMode == 1 ? provider.movieCategoryIndex : provider.movieStatusIndex; - // 计算翻页方向(用于动画):根据新旧索引的最短路径 - if (_prevTabIndex != -1 && currentIndex != _prevTabIndex) { - final count = provider.movieDisplayMode == 1 ? MovieCategoryBar.count : 3; - final raw = currentIndex - _prevTabIndex; - _direction = raw.abs() <= count / 2 ? raw.sign : -raw.sign; - } - _prevTabIndex = currentIndex; - - // 用 GestureDetector 包裹,左右滑动切换状态/分类 - return GestureDetector( - onHorizontalDragStart: (_) => _swipeOffset = 0.0, - onHorizontalDragUpdate: (details) => setState(() => _swipeOffset += details.primaryDelta ?? 0), - onHorizontalDragEnd: (details) { - final velocity = details.primaryVelocity; - if ((velocity ?? 0).abs() < 80) { - setState(() => _swipeOffset = 0.0); - return; - } - final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个 - setState(() => _swipeOffset = 0.0); - 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(-80.0, 80.0)), - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - builder: (context, value, child) { - return Transform.translate(offset: Offset(value, 0), child: child); - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - // 新页从滑动方向滑入,旧页朝反向滑出,形成翻页效果 - final isIncoming = child.key == ValueKey(currentIndex); - final dir = (isIncoming ? _direction : -_direction).toDouble(); - final width = MediaQuery.of(context).size.width; - return SlideTransition( - position: Tween( - begin: Offset(dir * width, 0), - end: Offset.zero, - ).animate(animation), - child: child, - ); - }, - child: KeyedSubtree( - key: ValueKey(currentIndex), - child: content, - ), - ), + final content = () { + if (_items.isEmpty && _isLoading) return _buildSkeleton(layoutStyle); + if (_items.isEmpty) { + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [_buildEmptyState()], ), ); - }, - ); + } + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: layoutStyle == 1 + ? _buildListView() + : layoutStyle == 2 + ? _buildCoverCardView() + : _buildGridView(), + ); + }(); + return content; } Widget _buildGridView() { @@ -554,8 +559,7 @@ class _MovieTabPageState extends State { ); } - Widget _buildSkeleton() { - final layoutStyle = context.read().movieLayoutStyle; + Widget _buildSkeleton(int layoutStyle) { if (layoutStyle == 1) return _buildListSkeleton(); if (layoutStyle == 2) return _buildCoverCardSkeleton(); return const MovieSkeletonGrid(); @@ -581,16 +585,16 @@ class _MovieTabPageState extends State { ); } - Widget _buildEmptyState(BuildContext context, int statusIndex) { + Widget _buildEmptyState() { final colors = Theme.of(context).colorScheme; - final provider = context.read(); - final isWallMode = provider.movieWallMode; - final isCategoryMode = provider.movieDisplayMode == 1; - final emptyText = isWallMode - ? '暂无影片' - : isCategoryMode - ? '暂无${MovieCategoryBar.categoryLabel(provider.movieCategoryIndex)}' - : '暂无${['已看', '在看', '想看'][statusIndex]}的影片'; + final String emptyText; + if (widget.mode == 2) { + emptyText = '暂无影片'; + } else if (widget.mode == 1) { + emptyText = '暂无${MovieCategoryBar.categoryLabel(widget.index)}'; + } else { + emptyText = '暂无${['已看', '在看', '想看'][widget.index]}的影片'; + } return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Container(width: 80, height: 80, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), @@ -599,4 +603,4 @@ class _MovieTabPageState extends State { Text(emptyText, style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), ])); } -} +} \ No newline at end of file diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index ec16470..851190a 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -336,7 +336,7 @@ class _FeatureSettingsPageState extends State { endIndent: 24, color: colors.outlineVariant), _buildSwitchItem( - Icons.auto_stories_outlined, 'EPUB阅读', 'EPUB 电子书阅读器', _showEpub, + Icons.auto_stories_outlined, '阅读', 'EPUB 电子书阅读器', _showEpub, (v) async { await _userPrefs.setShowSidebarEpub(v); setState(() => _showEpub = v); diff --git a/lib/pages/settings/tag_management_page.dart b/lib/pages/settings/tag_management_page.dart index b119964..fe9584d 100644 --- a/lib/pages/settings/tag_management_page.dart +++ b/lib/pages/settings/tag_management_page.dart @@ -568,30 +568,29 @@ class _TagManagementPageState extends State { builder: (ctx) { return SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ Center(child: Container( - width: 40, height: 4, margin: const EdgeInsets.only(bottom: 20), + width: 36, height: 4, margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)), )), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + // 紧凑标题 + Padding( + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 4), child: Row( children: [ - Expanded(child: Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface))), + Expanded(child: Text(name, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), if (isHidden) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(4)), + decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)), child: Text('已隐藏', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), ), ], ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), _menuAction( isHidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, isHidden ? '取消隐藏' : '隐藏', @@ -614,6 +613,10 @@ class _TagManagementPageState extends State { Navigator.pop(ctx); _showRenameDialog(tag); }), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Divider(height: 0.5, color: colors.outlineVariant), + ), _menuAction(Icons.delete_outline, '删除', colors, () { Navigator.pop(ctx); _showDeleteDialog(tag); @@ -631,15 +634,22 @@ class _TagManagementPageState extends State { onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), + padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 12), child: Row( children: [ - Icon(icon, size: 20, color: isDestructive ? const Color(0xFFE53935) : colors.onSurface.withValues(alpha: 0.7)), - const SizedBox(width: 14), + Container( + width: 34, height: 34, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: isDestructive ? colors.error : colors.onSurface.withValues(alpha: 0.6)), + ), + const SizedBox(width: 12), Text(title, style: TextStyle( - fontSize: 15, + fontSize: 14, fontWeight: FontWeight.w500, - color: isDestructive ? const Color(0xFFE53935) : colors.onSurface, + color: isDestructive ? colors.error : colors.onSurface, )), ], ), @@ -761,14 +771,14 @@ class _TagManagementPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Center(child: Container( - width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), + width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 12), decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)), )), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text('将「$name」移动到', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + child: Text('移动到「$name」', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), ), - const SizedBox(height: 8), + const SizedBox(height: 4), _moveOption( colors, icon: Icons.home_outlined, @@ -776,20 +786,28 @@ class _TagManagementPageState extends State { selected: currentParent.isEmpty, onTap: () => _doMoveParent(ctx, tagId, type, ''), ), - const SizedBox(height: 4), - Flexible( - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 16), - children: candidates.map((c) => _moveOption( - colors, - icon: Icons.folder_outlined, - title: c['name'] as String, - selected: c['id'] == currentParent, - onTap: () => _doMoveParent(ctx, tagId, type, c['id'] as String), - )).toList(), + if (candidates.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('设为某分类的子级', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ), ), - ), + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 16), + children: candidates.map((c) => _moveOption( + colors, + icon: Icons.folder_outlined, + title: c['name'] as String, + selected: c['id'] == currentParent, + onTap: () => _doMoveParent(ctx, tagId, type, c['id'] as String), + )).toList(), + ), + ), + ], ], ), ), diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 46f0893..f29cb85 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -25,11 +26,15 @@ import '../pages/game/game_form_page.dart'; import '../pages/profile/settings_page.dart'; import '../models/data_models.dart'; import 'fade_in_local_image.dart'; +import 'shimmer_skeleton.dart'; /// 自定义侧边栏 class CustomDrawer extends StatefulWidget { final bool embedded; - const CustomDrawer({super.key, this.embedded = false}); + + /// 抽屉是否处于打开状态(用于延迟构建重内容,避免打开时卡顿) + final bool isOpen; + const CustomDrawer({super.key, this.embedded = false, this.isOpen = false}); @override State createState() => _CustomDrawerState(); @@ -77,6 +82,39 @@ class _CustomDrawerState extends State { void initState() { super.initState(); _loadVersionInfo(); + // 抽屉可能以已打开状态被重建,此时也延迟构建重内容 + if (widget.isOpen) _scheduleDefer(); + } + + Timer? _deferTimer; + + /// 是否已延迟构建重内容(热力图/最近/工具) + bool _deferReady = false; + + void _scheduleDefer() { + _deferTimer?.cancel(); + _deferTimer = Timer(const Duration(milliseconds: 350), () { + if (mounted) setState(() => _deferReady = true); + }); + } + + @override + void didUpdateWidget(CustomDrawer old) { + super.didUpdateWidget(old); + if (widget.isOpen && !old.isOpen) { + _deferReady = false; + _scheduleDefer(); + } else if (!widget.isOpen && old.isOpen) { + // 关闭时重置,下次打开重新延迟构建 + _deferTimer?.cancel(); + _deferReady = false; + } + } + + @override + void dispose() { + _deferTimer?.cancel(); + super.dispose(); } Future _loadVersionInfo() async { @@ -113,15 +151,24 @@ class _CustomDrawerState extends State { ], if (showHeatmap) ...[ const SizedBox(height: 16), - _buildCalendarSection(context), + if (_deferReady) + _buildCalendarSection(context) + else + const _HeatmapSkeleton(), ], if (showRecent) ...[ const SizedBox(height: 16), - _buildRecentSection(context), + if (_deferReady) + _buildRecentSection(context) + else + const _RecentSkeleton(), ], if (showTools) ...[ const SizedBox(height: 16), - _buildToolsCard(context), + if (_deferReady) + _buildToolsCard(context) + else + const _ToolsSkeleton(), ], Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), @@ -845,3 +892,86 @@ class _RecentItem { final String? imagePath; _RecentItem({required this.type, required this.title, required this.date, required this.data, this.imagePath}); } + +// ─── 抽屉骨架屏 ──────────────────────────────────────────────────────── + +/// 热力图骨架屏(模拟月份标签 + 贡献格子) +class _HeatmapSkeleton extends StatelessWidget { + const _HeatmapSkeleton(); + @override + Widget build(BuildContext context) { + final c = Theme.of(context).colorScheme.outlineVariant; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: List.generate(6, (i) => Padding( + padding: const EdgeInsets.only(right: 8), + child: ShimmerSkeleton(width: 22, height: 10, borderRadius: 3, color: c), + )), + ), + const SizedBox(height: 10), + for (var r = 0; r < 7; r++) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + children: List.generate(6, (c2) => Padding( + padding: const EdgeInsets.only(right: 4), + child: ShimmerSkeleton(width: 14, height: 14, borderRadius: 3, color: c), + )), + ), + ), + ], + ); + } +} + +/// 最近添加骨架屏(模拟缩略图 + 标题行) +class _RecentSkeleton extends StatelessWidget { + const _RecentSkeleton(); + @override + Widget build(BuildContext context) { + final c = Theme.of(context).colorScheme.outlineVariant; + return Column( + children: List.generate(4, (i) => Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + children: [ + ShimmerSkeleton(width: 44, height: 44, borderRadius: 8, color: c), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerSkeleton(width: 150, height: 13, borderRadius: 4, color: c), + const SizedBox(height: 6), + ShimmerSkeleton(width: 90, height: 11, borderRadius: 4, color: c), + ], + ), + ), + ], + ), + )), + ); + } +} + +/// 工具卡片骨架屏(模拟图标宫格) +class _ToolsSkeleton extends StatelessWidget { + const _ToolsSkeleton(); + @override + Widget build(BuildContext context) { + final c = Theme.of(context).colorScheme.outlineVariant; + return Wrap( + spacing: 20, + runSpacing: 14, + children: List.generate(8, (i) => Column( + children: [ + ShimmerSkeleton(width: 38, height: 38, borderRadius: 10, color: c), + const SizedBox(height: 6), + ShimmerSkeleton(width: 30, height: 10, borderRadius: 3, color: c), + ], + )), + ); + } +} diff --git a/lib/widgets/shimmer_skeleton.dart b/lib/widgets/shimmer_skeleton.dart index 1b7bf57..02eae12 100644 --- a/lib/widgets/shimmer_skeleton.dart +++ b/lib/widgets/shimmer_skeleton.dart @@ -7,11 +7,15 @@ class ShimmerSkeleton extends StatefulWidget { final double height; final double borderRadius; + /// 覆盖默认颜色(默认 surfaceContainerHighest,在深色背景上对比弱时可用) + final Color? color; + const ShimmerSkeleton({ super.key, required this.width, required this.height, this.borderRadius = 6, + this.color, }); @override @@ -44,6 +48,7 @@ class _ShimmerSkeletonState extends State @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final baseColor = widget.color ?? colors.surfaceContainerHighest; return AnimatedBuilder( animation: _animation, builder: (context, child) { @@ -51,7 +56,7 @@ class _ShimmerSkeletonState extends State width: widget.width, height: widget.height, decoration: BoxDecoration( - color: colors.surfaceContainerHighest.withValues(alpha: _animation.value), + color: baseColor.withValues(alpha: _animation.value), borderRadius: BorderRadius.circular(widget.borderRadius), ), );