Files
MookNote/lib/pages/game/game_tab_page.dart
2026-08-12 22:28:33 +08:00

590 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/data_models.dart';
import '../../providers/app_provider.dart';
import '../../utils/user_prefs.dart';
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';
import '../../widgets/detail_placeholder.dart';
import 'game_detail_page.dart';
import 'game_add_page.dart';
import '../../widgets/app_overlay.dart';
/// 状态索引 → 状态值
const _gameStatusMap = {0: 'completed', 1: 'playing', 2: 'want_to_play', 3: 'abandoned'};
/// 游戏标签页PageView 分页 + 触底加载),左右滑动丝滑切换
class GameTabPage extends StatefulWidget {
const GameTabPage({super.key});
@override
State<GameTabPage> createState() => _GameTabPageState();
}
class _GameTabPageState extends State<GameTabPage> {
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<AppProvider>();
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<AppProvider>();
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<Game> _items = [];
bool _hasMore = true;
bool _isLoading = false;
int _offset = 0;
bool _initialized = false;
late ScrollController _scrollController;
AppProvider? _provider;
int _lastScrollSignal = 0;
int _lastEditRefreshCounter = 0;
int _prevGameCount = -1;
int _prevSortMode = -1;
String? get _status => widget.mode == 0 ? _gameStatusMap[widget.index] : null;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = context.read<AppProvider>();
_provider = p;
_lastEditRefreshCounter = p.editRefreshCounter;
_lastScrollSignal = p.scrollToTopSignal;
_prevGameCount = p.games.length;
_prevSortMode = UserPrefs().gameSortMode;
p.addListener(_onDataChanged);
_loadFirst();
});
}
@override
void dispose() {
_provider?.removeListener(_onDataChanged);
_scrollController.dispose();
super.dispose();
}
void _onDataChanged() {
if (!_initialized || !mounted) return;
final p = context.read<AppProvider>();
// 回到顶部信号:每页滚自己的 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;
_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) {
if (_status != null && updated.status != _status) {
// 状态已变更,从当前列表移除
if (idx != -1) setState(() { _items.removeAt(idx); });
} else if (idx != -1) {
setState(() { _items[idx] = updated; });
}
} else if (idx != -1) {
// 游戏已被删除,从列表移除
setState(() { _items.removeAt(idx); });
}
return;
}
// 排序/数量变化才重新拉取(布局变化由 context.select 原地重渲染)
final sortChanged = UserPrefs().gameSortMode != _prevSortMode;
final countChanged = p.games.length != _prevGameCount;
if (sortChanged || countChanged) {
_prevSortMode = UserPrefs().gameSortMode;
_prevGameCount = p.games.length;
_loadFirst();
}
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadFirst() async {
final provider = context.read<AppProvider>();
final sortMode = UserPrefs().gameSortMode;
_initialized = true;
setState(() { _isLoading = true; _offset = 0; _hasMore = true; });
final list = await provider.loadGamesPaged(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<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
final sortMode = UserPrefs().gameSortMode;
final list = await provider.loadGamesPaged(status: _status, offset: _offset, sortMode: sortMode);
if (!mounted) return;
setState(() {
_items.addAll(list);
_offset += list.length;
_hasMore = list.length >= 20;
_isLoading = false;
});
}
Future<void> _refresh() async {
final provider = context.read<AppProvider>();
await provider.loadGames();
await _loadFirst();
}
void _onGameTap(Game game) {
if (Breakpoint.isWideContent(context)) {
context.read<AppProvider>().selectGame(game);
} else {
Navigator.pushNamed(context, '/game-detail', arguments: game);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
final colors = Theme.of(context).colorScheme;
final layoutStyle = context.select<AppProvider, int>((p) => p.gameLayoutStyle);
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() {
return LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110);
final isWideContent = Breakpoint.isWideContent(context);
final provider = context.read<AppProvider>();
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 _buildLoadMoreIndicator();
final item = _items[index];
return GameListItem(
game: item,
selected: isWideContent && provider.selectedGame?.id == item.id,
onTap: () => _onGameTap(item),
);
},
);
},
);
}
Widget _buildListView() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) return _buildLoadMoreIndicator();
return _buildListCard(_items[index]);
},
);
}
Widget _buildLoadMoreIndicator() {
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))),
),
);
}
Widget _buildListCard(Game game) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => _onGameTap(game),
onLongPress: () => _showDeleteDialog(context, game),
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,
decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)),
clipBehavior: Clip.antiAlias,
child: game.coverPath != null && game.coverPath!.isNotEmpty
? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover,
errorWidget: Icon(Icons.sports_esports_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)))
: Icon(Icons.sports_esports_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(game.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 3),
Text(_buildSubtitle(game), maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
const SizedBox(height: 6),
if (game.rating != null) AnimatedStarRating(rating: game.rating!, starSize: 12, showNumber: true)
else const SizedBox(height: 14),
])),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20),
]),
),
);
}
String _buildSubtitle(Game game) {
final parts = <String>[];
if (game.platforms.isNotEmpty) parts.add(game.platforms.take(2).join(''));
if (game.genres.isNotEmpty) parts.add(game.genres.take(2).join(''));
if (game.playTimeHours > 0 || game.playTimeMinutes > 0) {
parts.add('${game.playTimeHours}${game.playTimeMinutes}');
}
return parts.join(' · ');
}
Widget _buildCoverCardView() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) return _buildLoadMoreIndicator();
return _buildCoverCard(_items[index]);
},
);
}
Widget _buildCoverCard(Game game) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => _onGameTap(game),
onLongPress: () => _showDeleteDialog(context, game),
child: Container(
height: 200,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: colors.surfaceContainerHigh,
),
clipBehavior: Clip.antiAlias,
child: Stack(fit: StackFit.expand, children: [
if (game.coverPath != null && game.coverPath!.isNotEmpty)
FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover,
errorWidget: Container(color: colors.surfaceContainerHighest))
else
Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.sports_esports_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.15))),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.75)],
stops: const [0.4, 1.0],
),
),
),
),
Positioned(
left: 14, right: 14, bottom: 14,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(game.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white)),
const SizedBox(height: 4),
Row(children: [
Expanded(
child: Text(_buildSubtitle(game), maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
),
if (game.rating != null) ...[
const SizedBox(width: 8),
Icon(Icons.star_rounded, size: 16, color: Colors.amber.shade400),
const SizedBox(width: 2),
Text(game.rating!.toStringAsFixed(1),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
],
]),
]),
),
]),
),
);
}
Widget _buildCoverCardSkeleton() {
final colors = Theme.of(context).colorScheme;
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: 4,
itemBuilder: (_, __) => Container(
height: 200,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
),
);
}
void _showDeleteDialog(BuildContext context, Game game) {
final colors = Theme.of(context).colorScheme;
appDialog(
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('确定要删除《${game.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<AppProvider>().removeGame(game.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(int layoutStyle) {
if (layoutStyle == 1) return _buildListSkeleton();
if (layoutStyle == 2) return _buildCoverCardSkeleton();
return const GameSkeletonGrid();
}
Widget _buildListSkeleton() {
final colors = Theme.of(context).colorScheme;
return ListView.builder(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), itemCount: 6,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: const Row(children: [
ShimmerSkeleton(width: 48, height: 64, borderRadius: 6), SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ShimmerSkeleton(width: 160, height: 16), SizedBox(height: 6),
ShimmerSkeleton(width: 100, height: 12), SizedBox(height: 6),
ShimmerSkeleton(width: 70, height: 12),
])),
SizedBox(width: 8), ShimmerSkeleton(width: 20, height: 20, borderRadius: 10),
]),
),
);
}
Widget _buildEmptyState() {
final colors = Theme.of(context).colorScheme;
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(widget.mode == 1 ? '暂无游戏' : '暂无$statusText的游戏',
style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
]));
}
}