新增游戏功能记录模块

This commit is contained in:
DelLevin-Home
2026-07-06 01:33:42 +08:00
parent 0ad3713382
commit 4a7296f00f
32 changed files with 5735 additions and 37 deletions

View File

@@ -0,0 +1,986 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/user_prefs.dart';
import '../../utils/toast_util.dart';
import 'game_reviews_page.dart';
import 'game_screenshots_page.dart';
import 'game_share_page.dart';
/// 游戏详情页 - 极简主义设计
class GameDetailPage extends StatefulWidget {
final Game game;
final bool embedded;
const GameDetailPage({super.key, required this.game, this.embedded = false});
@override
State<GameDetailPage> createState() => _GameDetailPageState();
}
class _GameDetailPageState extends State<GameDetailPage> {
final ValueNotifier<double> _coverOffset = ValueNotifier(0.0);
double _coverDragStartOffset = 0.0;
final ValueNotifier<bool> _draggingCover = ValueNotifier(false);
final GlobalKey _coverImageKey = GlobalKey();
double _coverImageHeight = 0.0;
bool _isLandscapeCover = false;
late int _detailStyle;
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
@override
void initState() {
super.initState();
_detailStyle = UserPrefs().detailPageStyle;
_coverOffset.value = UserPrefs().getCoverOffset(widget.game.id);
_detectCoverAspect();
}
Future<void> _detectCoverAspect() async {
final path = widget.game.coverPath;
if (path == null || path.isEmpty || path.startsWith('http')) return;
final file = File(path);
if (!file.existsSync()) return;
final bytes = await file.readAsBytes();
final codec = await instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final w = frame.image.width;
final h = frame.image.height;
frame.image.dispose();
codec.dispose();
if (w > h && mounted) {
setState(() => _isLandscapeCover = true);
}
}
@override
void dispose() {
_coverOffset.dispose();
_draggingCover.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final game = context.watch<AppProvider>().games
.where((g) => g.id == widget.game.id)
.firstOrNull ?? widget.game;
return _detailStyle == 1
? _buildOverlayStyle(game, colors)
: _buildStandardStyle(game, colors);
}
Widget _buildStandardStyle(Game game, ColorScheme colors) {
final topSafe = MediaQuery.of(context).padding.top;
return Scaffold(
backgroundColor: colors.surface,
body: Stack(
children: [
Padding(
padding: EdgeInsets.only(top: topSafe + 48),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 320,
width: double.infinity,
child: _buildCoverSection(game),
),
_buildBasicInfo(game),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
if (game.platforms.isNotEmpty)
_buildInfoSection('平台', game.platforms.join('')),
if (game.versions.isNotEmpty)
_buildInfoSection('版本', game.versions.join('')),
if (game.genres.isNotEmpty)
_buildGenresSection(game),
if (game.playTimeHours > 0 || game.playTimeMinutes > 0)
_buildInfoSection('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟'),
if (game.purchasePlatforms.isNotEmpty)
_buildInfoSection('购买平台', game.purchasePlatforms.join('')),
if (game.purchaseDate != null)
_buildInfoSection('购买时间', _formatDate(game.purchaseDate!)),
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
_buildInfoSection('购买价格', game.purchasePrice!),
if (game.summary != null && game.summary!.isNotEmpty)
_buildInfoSection('游戏简介', game.summary!),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildExtraSections(game),
const SizedBox(height: 120),
],
),
),
),
Positioned(
top: 0, left: 0, right: 0,
child: Container(
padding: EdgeInsets.only(top: topSafe),
color: colors.surface,
child: SizedBox(
height: 48,
child: Row(children: [
const SizedBox(width: 4),
IconButton(
icon: widget.embedded
? Icon(Icons.close, color: colors.onSurface, size: 18)
: Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18),
onPressed: widget.embedded
? () => context.read<AppProvider>().selectGame(null)
: () => Navigator.pop(context),
),
const SizedBox(width: 4),
Expanded(
child: Text(game.title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
IconButton(
icon: Icon(Icons.tune, color: colors.onSurface, size: 20),
tooltip: '切换样式',
onPressed: _showStylePicker,
),
]),
),
),
),
Positioned(
right: 16,
bottom: 24,
child: _buildFloatingActionButtons(game),
),
],
),
);
}
Widget _buildOverlayStyle(Game game, ColorScheme colors) {
final screenH = MediaQuery.of(context).size.height;
final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty;
_overlayScrollController ??= ScrollController()..addListener(() {
final show = (_overlayScrollController?.offset ?? 0) > 10;
if (_showTitle.value != show) _showTitle.value = show;
});
return Scaffold(
body: Stack(
children: [
// 封面背景
if (hasCover)
Positioned.fill(
child: Image(
image: FileImage(File(game.coverPath!)),
fit: BoxFit.cover, width: double.infinity, height: screenH,
repeat: ImageRepeat.repeatY,
),
)
else
Container(color: colors.surfaceContainerHighest),
// 毛玻璃
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25),
child: Container(color: Colors.black.withValues(alpha: 0.4)),
),
),
// 内容
SafeArea(
child: Column(children: [
// 顶部栏
SizedBox(
height: 48,
child: Row(children: [
const SizedBox(width: 4),
IconButton(
icon: widget.embedded
? const Icon(Icons.close, color: Colors.white, size: 18)
: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
onPressed: widget.embedded
? () => context.read<AppProvider>().selectGame(null)
: () => Navigator.pop(context),
),
ValueListenableBuilder<bool>(
valueListenable: _showTitle,
builder: (_, show, __) => AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
child: Text(game.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.tune, color: Colors.white, size: 20),
tooltip: '切换样式',
onPressed: _showStylePicker,
),
]),
),
Expanded(
child: SingleChildScrollView(
controller: _overlayScrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildOverlayHeader(game),
const SizedBox(height: 20),
if (game.platforms.isNotEmpty)
_buildOverlayInfoRow('平台', game.platforms.join('')),
if (game.versions.isNotEmpty)
_buildOverlayInfoRow('版本', game.versions.join('')),
if (game.genres.isNotEmpty) ...[
const SizedBox(height: 12),
_buildOverlayGenres(game),
],
if (game.playTimeHours > 0 || game.playTimeMinutes > 0)
_buildOverlayInfoRow('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟'),
if (game.purchasePlatforms.isNotEmpty)
_buildOverlayInfoRow('购买平台', game.purchasePlatforms.join('')),
if (game.purchaseDate != null)
_buildOverlayInfoRow('购买时间', _formatDate(game.purchaseDate!)),
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
_buildOverlayInfoRow('购买价格', game.purchasePrice!),
if (game.summary != null && game.summary!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildOverlaySummary(game),
],
const SizedBox(height: 12),
_buildExtraSectionsOverlay(game),
]),
),
),
]),
),
Positioned(right: 16, bottom: 24, child: _buildFloatingActionButtons(game)),
],
),
);
}
Widget _buildOverlayHeader(Game game) {
final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100, height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4))],
),
clipBehavior: Clip.antiAlias,
child: hasCover
? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover)
: Container(color: Colors.white24, child: const Icon(Icons.sports_esports_outlined, color: Colors.white38, size: 32)),
),
const SizedBox(width: 16),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 4),
Text(game.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)),
if (game.genres.isNotEmpty) ...[
const SizedBox(height: 6),
Text(game.genres.join(' / '), style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.6))),
],
const SizedBox(height: 12),
Row(children: [
if (game.rating != null && game.rating! > 0) ...[
const Icon(Icons.star, size: 16, color: Color(0xFFFFB800)),
const SizedBox(width: 4),
Text(game.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
const SizedBox(width: 16),
],
_buildOverlayStatusChip(game.status),
]),
]),
),
],
);
}
Widget _buildOverlayStatusChip(String status) {
final (label, bg) = switch (status) {
'completed' => ('已通关', const Color(0xFF1A1A1A)),
'playing' => ('在玩', const Color(0xFF666666)),
'want_to_play' => ('想玩', const Color(0xFF999999)),
'abandoned' => ('弃游', const Color(0xFF8B4513)),
_ => ('', const Color(0xFF999999)),
};
if (label.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
child: Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white)),
);
}
Widget _buildOverlayInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 72, child: Text(label, style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.5)))),
Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: Colors.white.withValues(alpha: 0.9), height: 1.5))),
]),
);
}
Widget _buildOverlayGenres(Game game) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 72, child: Text('类型', style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.5)))),
Expanded(
child: Wrap(
spacing: 8, runSpacing: 8,
children: game.genres.map((g) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(16)),
child: Text(g, style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.8))),
)).toList(),
),
),
]),
);
}
Widget _buildOverlaySummary(Game game) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('游戏简介', style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.5))),
const SizedBox(height: 8),
Text(game.summary!, style: TextStyle(fontSize: 15, color: Colors.white.withValues(alpha: 0.9), height: 1.6)),
]),
);
}
Widget _buildExtraSectionsOverlay(Game game) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
_buildFrostedExtraItem(
icon: Icons.rate_review_outlined,
title: '游戏评价',
subtitleFuture: context.read<AppProvider>().getGameReviewCount(game.id),
emptyText: '暂无评价',
unit: '条评价',
onTap: () => _navigateToReviews(game),
),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.photo_library_outlined,
title: '游戏截图',
subtitleFuture: context.read<AppProvider>().getGameScreenshotCount(game.id),
emptyText: '暂无截图',
unit: '张截图',
onTap: () => _navigateToScreenshots(game),
),
],
),
);
}
Widget _buildFrostedExtraItem({
required IconData icon,
required String title,
required Future<int> subtitleFuture,
required String emptyText,
required String unit,
required VoidCallback onTap,
}) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(children: [
Icon(icon, size: 20, color: Colors.white.withValues(alpha: 0.7)),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white)),
FutureBuilder<int>(
future: subtitleFuture,
builder: (ctx, snap) {
final count = snap.data ?? 0;
return Text(count > 0 ? '$count $unit' : emptyText,
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5)));
},
),
]),
),
Icon(Icons.chevron_right, size: 16, color: Colors.white.withValues(alpha: 0.3)),
]),
),
),
),
),
),
);
}
Widget _buildFloatingActionButtons(Game game) {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFloatingButton(
icon: Icons.edit_outlined,
onPressed: () => _navigateToEdit(context),
tooltip: '编辑',
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.delete_outline,
onPressed: () => _showDeleteDialog(context),
tooltip: '删除',
backgroundColor: colors.error,
foregroundColor: colors.onError,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.share_outlined,
onPressed: () => _showSharePoster(game),
tooltip: '分享海报',
backgroundColor: const Color(0xFF4CAF50),
foregroundColor: Colors.white,
),
],
);
}
Widget _buildFloatingButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
required Color backgroundColor,
required Color foregroundColor,
}) {
return Tooltip(
message: tooltip,
child: GestureDetector(
onTap: onPressed,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: backgroundColor.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(icon, size: 18, color: foregroundColor),
),
),
);
}
Widget _buildCoverSection(Game game) {
final colors = Theme.of(context).colorScheme;
final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty;
// 横图:直接居中裁剪填满,无需拖拽偏移
if (_isLandscapeCover && hasCover) {
return Stack(
fit: StackFit.expand,
children: [
FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover),
Positioned(
left: 0, right: 0, bottom: 0,
child: IgnorePointer(
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colors.surface.withValues(alpha: 0), colors.surface],
),
),
),
),
),
],
);
}
// 竖图:原有逻辑,支持上下拖拽调整偏移
return LayoutBuilder(
builder: (context, constraints) {
final containerH = constraints.maxHeight;
return GestureDetector(
onLongPressStart: hasCover ? (_) {
HapticFeedback.mediumImpact();
final ctx = _coverImageKey.currentContext;
if (ctx != null) {
final box = ctx.findRenderObject() as RenderBox?;
if (box != null) _coverImageHeight = box.size.height;
}
_draggingCover.value = true;
_coverDragStartOffset = _coverOffset.value;
} : null,
onLongPressMoveUpdate: hasCover ? (d) {
final raw = _coverDragStartOffset + d.offsetFromOrigin.dy;
final imgH = _coverImageHeight > 0 ? _coverImageHeight : containerH;
final minOffset = -(imgH - containerH).clamp(0, double.infinity);
_coverOffset.value = raw.clamp(minOffset, 0.0) as double;
} : null,
onLongPressEnd: hasCover ? (_) {
_draggingCover.value = false;
final offset = _coverOffset.value;
UserPrefs().setCoverOffset(widget.game.id, offset);
context.read<AppProvider>().updateGameCoverOffset(widget.game.id, offset);
} : null,
child: ValueListenableBuilder<double>(
valueListenable: _coverOffset,
builder: (context, offset, _) {
return Stack(
fit: StackFit.expand,
children: [
if (hasCover)
ClipRect(
child: Stack(
children: [
Positioned(
top: offset,
left: 0, right: 0,
child: FadeInLocalImage(
key: _coverImageKey,
path: game.coverPath,
fit: BoxFit.fitWidth,
width: constraints.maxWidth,
),
),
],
),
)
else
_buildCoverPlaceholder(),
ValueListenableBuilder<bool>(
valueListenable: _draggingCover,
builder: (context, dragging, _) {
return Stack(
children: [
if (!dragging)
Positioned(
left: 0, right: 0, bottom: 0,
child: IgnorePointer(
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.surface.withValues(alpha: 0),
colors.surface,
],
),
),
),
),
),
if (dragging) ...[
Positioned.fill(
child: Container(color: Colors.black.withValues(alpha: 0.3)),
),
Positioned(
left: 0, right: 0, bottom: 20,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: const Text('上下滑动调整图片位置',
style: TextStyle(fontSize: 13, color: Colors.white70)),
),
),
),
],
],
);
},
),
],
);
},
),
);
},
);
}
Widget _buildCoverPlaceholder() {
final colors = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.sports_esports_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.25)),
const SizedBox(height: 16),
Text('暂无封面', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
],
),
);
}
Widget _buildBasicInfo(Game game) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
game.title,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3),
),
const SizedBox(height: 16),
Row(
children: [
if (game.rating != null) ...[
const Icon(Icons.star, size: 20, color: Colors.amber),
const SizedBox(width: 4),
Text(game.rating!.toStringAsFixed(1),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(width: 16),
],
_buildStatusTag(game),
const SizedBox(width: 6),
_buildCategoryTag(game),
],
),
],
),
);
}
Widget _buildStatusTag(Game game) {
final colors = Theme.of(context).colorScheme;
String label;
Color bgColor;
Color textColor;
switch (game.status) {
case 'completed':
label = '已通关';
bgColor = colors.primary;
textColor = colors.onPrimary;
case 'playing':
label = '在玩';
bgColor = colors.outlineVariant;
textColor = colors.onSurface.withValues(alpha: 0.6);
case 'want_to_play':
label = '想玩';
bgColor = colors.surfaceContainerHighest;
textColor = colors.onSurface.withValues(alpha: 0.4);
case 'abandoned':
label = '弃游';
bgColor = colors.errorContainer;
textColor = colors.onError;
default:
label = '未知';
bgColor = colors.outlineVariant;
textColor = colors.onSurface.withValues(alpha: 0.25);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 12, color: textColor, fontWeight: FontWeight.w600)),
);
}
Widget _buildCategoryTag(Game game) {
final colors = Theme.of(context).colorScheme;
const labels = {'digital': '数字版', 'cartridge': '卡带', 'disc': '光盘'};
final label = labels[game.category];
if (label == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5), fontWeight: FontWeight.w500)),
);
}
Widget _buildInfoSection(String label, String value) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
),
Expanded(
child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5)),
),
],
),
);
}
Widget _buildGenresSection(Game game) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
),
Expanded(
child: Wrap(
spacing: 8, runSpacing: 8,
children: game.genres.map((g) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)),
child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
)).toList(),
),
),
],
),
);
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
Widget _buildExtraSections(Game game) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4, height: 16,
decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2)),
),
const SizedBox(width: 8),
Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
const SizedBox(height: 16),
_buildExtraSectionItem(
icon: Icons.rate_review_outlined,
title: '游戏评价',
subtitleFuture: context.read<AppProvider>().getGameReviewCount(game.id),
emptyText: '暂无评价',
unit: '条评价',
onTap: () => _navigateToReviews(game),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.photo_library_outlined,
title: '游戏截图',
subtitleFuture: context.read<AppProvider>().getGameScreenshotCount(game.id),
emptyText: '暂无截图',
unit: '张截图',
onTap: () => _navigateToScreenshots(game),
),
],
),
);
}
Widget _buildExtraSectionItem({
required IconData icon,
required String title,
required Future<int> subtitleFuture,
required String emptyText,
required String unit,
required VoidCallback onTap,
}) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 4),
FutureBuilder<int>(
future: subtitleFuture,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count $unit' : emptyText,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)),
);
},
),
],
),
),
Icon(Icons.chevron_right, size: 20, color: colors.onSurface.withValues(alpha: 0.25)),
],
),
),
);
}
void _navigateToReviews(Game game) {
Navigator.push(context, MaterialPageRoute(builder: (_) => GameReviewsPage(game: game)));
}
void _navigateToScreenshots(Game game) {
Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreenshotsPage(game: game)));
}
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
final currentStyle = UserPrefs().detailPageStyle;
const names = ['默认样式', '毛玻璃层叠'];
const icons = [Icons.article_outlined, Icons.blur_on_outlined];
const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片'];
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
const SizedBox(height: 12),
for (int i = 0; i < names.length; i++) ...[
if (i > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i], size: 20, color: currentStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: currentStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)),
subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: currentStyle == i
? Icon(Icons.check_circle, size: 20, color: colors.primary)
: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); },
),
],
const SizedBox(height: 12),
]),
),
);
}
void _showSharePoster(Game game) {
Navigator.push(context, MaterialPageRoute(builder: (_) => GameSharePage(game: game)));
}
void _navigateToEdit(BuildContext context) {
final provider = context.read<AppProvider>();
Navigator.pushNamed(context, '/game-form', arguments: widget.game).then((_) {
provider.setEditRefresh(widget.game.id);
provider.loadGames();
});
}
void _showDeleteDialog(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => 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('确定要删除"${widget.game.title}"吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
final provider = context.read<AppProvider>();
await provider.removeGame(widget.game.id);
if (!mounted) return;
if (widget.embedded) {
Navigator.of(context).pop();
provider.selectGame(null);
} else {
final navigator = Navigator.of(context);
navigator.pop();
navigator.pop();
}
if (mounted) {
ToastUtil.show(context, '已删除');
}
},
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),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/data_models.dart';
import '../../providers/app_provider.dart';
import '../../utils/toast_util.dart';
import '../../widgets/fade_in_local_image.dart';
import 'game_review_form_page.dart';
/// 游戏评价详情页
class GameReviewDetailPage extends StatefulWidget {
final GameReview review;
final String gameId;
const GameReviewDetailPage({super.key, required this.review, required this.gameId});
@override
State<GameReviewDetailPage> createState() => _GameReviewDetailPageState();
}
class _GameReviewDetailPageState extends State<GameReviewDetailPage> {
late GameReview _review;
@override
void initState() {
super.initState();
_review = widget.review;
}
Future<void> _refreshReviewData() async {
final provider = context.read<AppProvider>();
final reviews = await provider.getGameReviews(widget.gameId);
final updatedReview = reviews.where((r) => r.id == widget.review.id).firstOrNull;
if (updatedReview != null && updatedReview.id == _review.id) {
setState(() => _review = updatedReview);
}
}
Game? _getGame() {
return context.read<AppProvider>().games.where((g) => g.id == widget.gameId).firstOrNull;
}
Future<void> _deleteReview() async {
final colors = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
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('确定要删除这条评价吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
if (confirmed == true) {
await context.read<AppProvider>().removeGameReview(_review.id);
if (mounted) { ToastUtil.show(context, '已删除'); Navigator.pop(context); }
}
}
void _navigateToEdit(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => GameReviewFormPage(gameId: widget.gameId, review: _review)),
).then((_) => _refreshReviewData());
}
String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final game = _getGame();
return Scaffold(
backgroundColor: colors.surfaceContainerHigh,
appBar: AppBar(
title: const Text('评价详情'),
actions: [
IconButton(icon: const Icon(Icons.edit_outlined, size: 20), onPressed: () => _navigateToEdit(context), tooltip: '编辑'),
IconButton(icon: Icon(Icons.delete_outline, size: 20, color: colors.error.withValues(alpha: 0.7)), onPressed: _deleteReview, tooltip: '删除'),
const SizedBox(width: 4),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (game != null) _buildGameCard(game, colors),
if (game != null) const SizedBox(height: 16),
_buildContentCard(colors),
const SizedBox(height: 16),
_buildInfoCard(colors),
]),
),
);
}
Widget _buildGameCard(Game game, ColorScheme colors) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
Container(
width: 52, height: 68,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: colors.surfaceContainerHighest),
clipBehavior: Clip.antiAlias,
child: game.coverPath != null
? 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, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 2, overflow: TextOverflow.ellipsis),
if (game.rating != null) ...[const SizedBox(height: 4), Row(children: [
Icon(Icons.star, size: 14, color: const Color(0xFFFFB800)),
const SizedBox(width: 2),
Text('${game.rating}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))),
])],
])),
]),
);
Widget _buildContentCard(ColorScheme colors) => Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(12)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_review.content, style: TextStyle(fontSize: 16, color: colors.onSurface, height: 1.8)),
const SizedBox(height: 16),
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10)),
child: Text(_review.typeText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.primary)),
),
]),
]),
);
Widget _buildInfoCard(ColorScheme colors) => Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(12)),
child: Column(children: [
_infoRow(Icons.person_outline, '评价人', _review.reviewer.isNotEmpty ? _review.reviewer : '匿名', colors),
Divider(height: 24, color: colors.outlineVariant),
if (_review.source.isNotEmpty) ...[
_infoRow(Icons.link, '来源', _review.source, colors),
const Divider(height: 24),
],
_infoRow(Icons.access_time, '时间', _formatDate(_review.createdAt), colors),
]),
);
Widget _infoRow(IconData icon, String label, String value, ColorScheme colors) => Row(children: [
Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.35)),
const SizedBox(width: 10),
Text(label, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
const Spacer(),
Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)),
]);
}

View File

@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/app_provider.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
/// 添加/编辑游戏评价页面
class GameReviewFormPage extends StatefulWidget {
final String gameId;
final GameReview? review;
const GameReviewFormPage({super.key, required this.gameId, this.review});
@override
State<GameReviewFormPage> createState() => _GameReviewFormPageState();
}
class _GameReviewFormPageState extends State<GameReviewFormPage> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _contentController;
late TextEditingController _reviewerController;
late TextEditingController _sourceController;
late int _reviewType;
@override
void initState() {
super.initState();
_contentController = TextEditingController(text: widget.review?.content ?? '');
_reviewerController = TextEditingController(text: widget.review?.reviewer ?? '');
_sourceController = TextEditingController(text: widget.review?.source ?? '');
_reviewType = widget.review?.reviewType ?? 1;
}
@override
void dispose() {
_contentController.dispose();
_reviewerController.dispose();
_sourceController.dispose();
super.dispose();
}
Game? _getGame() {
return context.read<AppProvider>().games.where((g) => g.id == widget.gameId).firstOrNull;
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final isEdit = widget.review != null;
final game = _getGame();
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: Text(isEdit ? '编辑评价' : '写评价')),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (game != null) _buildGameCard(game, colors),
if (game != null) const SizedBox(height: 20),
Text('评价类型', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 8),
_buildTypeSelector(colors),
const SizedBox(height: 20),
_buildMetaField(icon: Icons.person_outline, hint: '评论人(选填)', controller: _reviewerController, colors: colors),
const SizedBox(height: 12),
_buildMetaField(icon: Icons.link, hint: '来源(选填)', controller: _sourceController, colors: colors),
const SizedBox(height: 20),
Text('评论内容', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 8),
_buildContentField(colors),
],
),
),
),
Container(
padding: EdgeInsets.only(
left: 16, right: 16, top: 12,
bottom: MediaQuery.of(context).padding.bottom + 12,
),
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _saveReview,
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text(isEdit ? '更新评价' : '保存评价', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
),
),
],
),
),
);
}
Widget _buildGameCard(Game game, ColorScheme colors) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
child: Row(
children: [
Container(
width: 56, height: 72,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: colors.outlineVariant),
clipBehavior: Clip.antiAlias,
child: game.coverPath != null
? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover,
errorWidget: Icon(Icons.sports_esports_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25)))
: Icon(Icons.sports_esports_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(game.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 2, overflow: TextOverflow.ellipsis),
if (game.rating != null) ...[
const SizedBox(height: 4),
Row(children: [
Icon(Icons.star, size: 14, color: const Color(0xFFFFB800)),
const SizedBox(width: 2),
Text('${game.rating}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))),
]),
],
],
),
),
],
),
);
}
Widget _buildTypeSelector(ColorScheme colors) {
return SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('短评'), icon: Icon(Icons.short_text)),
ButtonSegment(value: 2, label: Text('长评'), icon: Icon(Icons.menu_book)),
],
selected: {_reviewType},
onSelectionChanged: (v) => setState(() => _reviewType = v.first),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colors.primary;
return colors.surfaceContainerHighest;
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colors.onPrimary;
return colors.onSurface.withValues(alpha: 0.6);
}),
iconColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colors.onPrimary;
return colors.onSurface.withValues(alpha: 0.4);
}),
),
);
}
Widget _buildMetaField({required IconData icon, required String hint, required TextEditingController controller, required ColorScheme colors}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Row(
children: [
Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.35)),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
);
}
Widget _buildContentField(ColorScheme colors) {
return Container(
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Column(
children: [
TextFormField(
controller: _contentController,
maxLines: 10, minLines: 6,
textAlignVertical: TextAlignVertical.top,
style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.7),
decoration: InputDecoration(
hintText: '写下你的游戏评价...',
hintStyle: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.25)),
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.all(14),
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '请输入评论内容';
return null;
},
),
Padding(
padding: const EdgeInsets.only(right: 14, bottom: 10),
child: Align(
alignment: Alignment.centerRight,
child: Text('${_contentController.text.length}',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
),
),
],
),
);
}
Future<void> _saveReview() async {
if (!_formKey.currentState!.validate()) return;
try {
final now = DateTime.now();
if (widget.review == null) {
final newReview = GameReview(
id: now.millisecondsSinceEpoch.toString(),
gameId: widget.gameId,
content: _contentController.text.trim(),
reviewer: _reviewerController.text.trim(),
source: _sourceController.text.trim(),
reviewType: _reviewType,
createdAt: now,
updatedAt: now,
);
await context.read<AppProvider>().addGameReview(newReview);
} else {
final updatedReview = widget.review!.copyWith(
content: _contentController.text.trim(),
reviewer: _reviewerController.text.trim(),
source: _sourceController.text.trim(),
reviewType: _reviewType,
updatedAt: now,
);
await context.read<AppProvider>().updateGameReview(updatedReview);
}
if (!mounted) return;
ToastUtil.show(context, widget.review == null ? '添加成功' : '更新成功');
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ToastUtil.show(context, '保存失败: $e');
}
}
}

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import 'game_review_form_page.dart';
import 'game_review_detail_page.dart';
/// 游戏评价列表页面
class GameReviewsPage extends StatefulWidget {
final Game game;
const GameReviewsPage({super.key, required this.game});
@override
State<GameReviewsPage> createState() => _GameReviewsPageState();
}
class _GameReviewsPageState extends State<GameReviewsPage> {
List<GameReview> _reviews = [];
List<GameReview> _filteredReviews = [];
bool _isLoading = true;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadReviews();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadReviews() async {
setState(() => _isLoading = true);
final reviews = await context.read<AppProvider>().getGameReviews(widget.game.id);
setState(() {
_reviews = reviews;
_filteredReviews = reviews;
_isLoading = false;
});
}
void _toggleSearch() {
setState(() {
_isSearching = !_isSearching;
if (!_isSearching) {
_searchController.clear();
_filteredReviews = _reviews;
}
});
}
void _onSearchChanged(String query) {
setState(() {
if (query.isEmpty) {
_filteredReviews = _reviews;
} else {
final lowerQuery = query.toLowerCase();
_filteredReviews = _reviews.where((review) {
return review.content.toLowerCase().contains(lowerQuery) ||
review.reviewer.toLowerCase().contains(lowerQuery) ||
review.source.toLowerCase().contains(lowerQuery);
}).toList();
}
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: _isSearching
? TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: '搜索评价内容、评论人、来源...',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)),
border: InputBorder.none,
),
style: TextStyle(color: colors.onSurface),
onChanged: _onSearchChanged,
)
: const Text('游戏评价'),
actions: [
IconButton(
icon: Icon(_isSearching ? Icons.close : Icons.search),
onPressed: _toggleSearch,
),
const SizedBox(width: 8),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _navigateToAddReview(),
icon: const Icon(Icons.add, size: 20),
label: const Text('添加评价'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredReviews.isEmpty
? _buildEmptyState()
: _buildReviewList(),
);
}
Widget _buildEmptyState() {
final colors = Theme.of(context).colorScheme;
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.rate_review_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(height: 20),
Text('暂无评价', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 24),
],
),
);
}
Widget _buildReviewList() {
return MasonryGridView.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
padding: const EdgeInsets.all(12),
itemCount: _filteredReviews.length,
itemBuilder: (context, index) {
final review = _filteredReviews[index];
return _buildReviewCard(review);
},
);
}
Widget _buildReviewCard(GameReview review) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: () => _navigateToReviewDetail(review),
onLongPress: () => _showDeleteDialog(review),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: review.reviewType == 1 ? colors.surface : colors.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
review.typeText,
style: TextStyle(
fontSize: 10,
color: review.reviewType == 1
? colors.onSurface.withValues(alpha: 0.6)
: colors.onPrimary,
),
),
),
const SizedBox(height: 8),
Text(
review.content,
maxLines: review.reviewType == 1 ? 4 : 8,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: colors.onSurface, height: 1.5),
),
const SizedBox(height: 12),
if (review.reviewer.isNotEmpty)
Text(review.reviewer,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6)),
overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Row(
children: [
if (review.source.isNotEmpty)
Expanded(
child: Text(review.source,
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4)),
overflow: TextOverflow.ellipsis),
),
Text(_formatDate(review.createdAt),
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
],
),
],
),
),
);
}
String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
}
void _navigateToAddReview() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => GameReviewFormPage(gameId: widget.game.id)),
).then((_) => _loadReviews());
}
void _navigateToReviewDetail(GameReview review) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => GameReviewDetailPage(review: review, gameId: widget.game.id)),
).then((_) => _loadReviews());
}
void _showDeleteDialog(GameReview review) {
showDialog(
context: context,
builder: (context) {
final colors = Theme.of(context).colorScheme;
return 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('确定要删除这条评价吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeGameReview(review.id);
Navigator.pop(context);
_loadReviews();
ToastUtil.show(context, '已删除');
},
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),
);
},
);
}
}

View File

@@ -0,0 +1,366 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:http/http.dart' as http;
import '../../providers/app_provider.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import '../../utils/image_path_helper.dart';
import 'screenshot_gallery_page.dart';
/// 游戏截图页面
class GameScreenshotsPage extends StatefulWidget {
final Game game;
const GameScreenshotsPage({super.key, required this.game});
@override
State<GameScreenshotsPage> createState() => _GameScreenshotsPageState();
}
class _GameScreenshotsPageState extends State<GameScreenshotsPage> {
final ImagePicker _picker = ImagePicker();
List<GameScreenshot> _screenshots = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadScreenshots();
}
Future<void> _loadScreenshots() async {
setState(() => _isLoading = true);
final screenshots = await context.read<AppProvider>().getGameScreenshots(widget.game.id);
setState(() {
_screenshots = screenshots;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('游戏截图')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _pickScreenshot,
icon: const Icon(Icons.add_photo_alternate, size: 20),
label: const Text('添加截图'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _screenshots.isEmpty
? _buildEmptyState()
: _buildScreenshotGrid(),
);
}
Widget _buildEmptyState() {
final colors = Theme.of(context).colorScheme;
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.photo_library_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(height: 20),
Text('暂无截图', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 24),
],
),
);
}
Widget _buildScreenshotGrid() {
return MasonryGridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemCount: _screenshots.length,
itemBuilder: (context, index) {
final screenshot = _screenshots[index];
return _buildScreenshotItem(screenshot, index);
},
);
}
Widget _buildScreenshotItem(GameScreenshot screenshot, int index) {
final heights = [180.0, 220.0, 160.0, 200.0, 240.0, 190.0];
final height = heights[index % heights.length];
return GestureDetector(
onTap: () => _showScreenshotDetail(screenshot),
onLongPress: () => _showDeleteDialog(screenshot),
child: Container(
height: height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 2))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
children: [
FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.cover),
Positioned(
bottom: 0, left: 0, right: 0,
child: Container(
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.3)],
),
),
),
),
],
),
),
),
);
}
void _showScreenshotDetail(GameScreenshot screenshot) {
final initialIndex = _screenshots.indexWhere((s) => s.id == screenshot.id);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ScreenshotGalleryPage(
screenshots: _screenshots,
initialIndex: initialIndex >= 0 ? initialIndex : 0,
),
),
);
}
Future<void> _pickScreenshot() async {
final result = await showModalBottomSheet<int>(
context: context,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(color: colors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16))),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(children: [
Text('添加截图', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
),
const SizedBox(height: 16),
_buildAddOption(colors: colors, icon: Icons.photo_library_outlined, title: '从相册选择', subtitle: '选择本地图片', onTap: () => Navigator.pop(context, 0)),
_buildAddOption(colors: colors, icon: Icons.link_outlined, title: '网络链接', subtitle: '输入图片URL地址', onTap: () => Navigator.pop(context, 1)),
],
),
),
),
);
},
);
if (result == null) return;
if (result == 0) {
await _pickFromGallery();
} else if (result == 1) {
await _pickFromUrl();
}
}
Widget _buildAddOption({required ColorScheme colors, required IconData icon, required String title, required String subtitle, required VoidCallback onTap}) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
child: Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: colors.onSurface)),
const SizedBox(height: 2),
Text(subtitle, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
],
),
),
Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20),
],
),
),
);
}
Future<void> _pickFromGallery() async {
try {
final XFile? pickedFile = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1200, maxHeight: 1800, imageQuality: 85,
);
if (pickedFile != null) {
final fileName = 'screenshot_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getGameScreenshotImgPath(widget.game.id, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(pickedFile.path).copy(targetPath);
final newScreenshot = GameScreenshot(
id: DateTime.now().millisecondsSinceEpoch.toString(),
gameId: widget.game.id,
screenshotPath: targetPath,
createdAt: DateTime.now(),
);
await context.read<AppProvider>().addGameScreenshot(newScreenshot);
_loadScreenshots();
if (mounted) ToastUtil.show(context, '添加成功');
}
} catch (e) {
if (mounted) ToastUtil.show(context, '添加截图失败: $e');
}
}
Future<void> _pickFromUrl() async {
final urlController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
final colors = Theme.of(context).colorScheme;
return AlertDialog(
backgroundColor: colors.surface, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('添加网络图片'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(height: 12),
TextField(
controller: urlController,
decoration: InputDecoration(
hintText: 'https://example.com/image.jpg',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
border: const UnderlineInputBorder(),
enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.outline)),
focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.primary)),
),
style: const TextStyle(fontSize: 14),
keyboardType: TextInputType.url,
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text('确定', style: TextStyle(color: colors.onSurface))),
],
);
},
);
if (confirmed != true) return;
final url = urlController.text.trim();
WidgetsBinding.instance.addPostFrameCallback((_) {
urlController.dispose();
});
if (url.isEmpty) { if (mounted) ToastUtil.show(context, '请输入图片链接'); return; }
try {
await _downloadAndSaveScreenshot(url);
} catch (e) {
if (mounted) ToastUtil.show(context, '添加失败: $e');
}
}
Future<void> _downloadAndSaveScreenshot(String url) async {
try {
final response = await http.get(
Uri.parse(url),
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Referer': Uri.parse(url).replace(path: '/').toString(),
},
);
if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}');
final contentType = response.headers['content-type'];
if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片');
if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大');
final fileName = 'screenshot_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getGameScreenshotImgPath(widget.game.id, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(response.bodyBytes);
final newScreenshot = GameScreenshot(
id: DateTime.now().millisecondsSinceEpoch.toString(),
gameId: widget.game.id,
screenshotPath: targetPath,
createdAt: DateTime.now(),
);
await context.read<AppProvider>().addGameScreenshot(newScreenshot);
_loadScreenshots();
if (mounted) ToastUtil.show(context, '添加成功');
} catch (e) {
throw Exception('下载图片失败: $e');
}
}
void _showDeleteDialog(GameScreenshot screenshot) {
showDialog(
context: context,
builder: (context) {
final colors = Theme.of(context).colorScheme;
return 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('确定要删除这张截图吗?',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeGameScreenshot(screenshot.id);
Navigator.pop(context);
_loadScreenshots();
ToastUtil.show(context, '已删除');
},
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),
);
},
);
}
}

View File

@@ -0,0 +1,262 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import '../../widgets/fade_in_local_image.dart';
class GameSharePage extends StatefulWidget {
final Game game;
const GameSharePage({super.key, required this.game});
@override
State<GameSharePage> createState() => _GameSharePageState();
}
class _GameSharePageState extends State<GameSharePage> {
final GlobalKey _posterKey = GlobalKey();
bool _isGenerating = false;
int _currentStyle = 0;
static const _styleNames = ['海报', '游戏卡'];
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surfaceContainerHighest,
appBar: AppBar(
backgroundColor: colors.surface,
elevation: 0,
leading: IconButton(icon: Icon(Icons.close, color: colors.onSurface), onPressed: () => Navigator.pop(context)),
title: Text('分享海报', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
centerTitle: true,
actions: [
IconButton(icon: Icon(Icons.palette_outlined, color: colors.onSurface, size: 22), tooltip: '选择样式', onPressed: _showStylePicker),
TextButton(
onPressed: _isGenerating ? null : _generateAndShare,
child: _isGenerating
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text('分享', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
),
const SizedBox(width: 4),
],
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: RepaintBoundary(
key: _posterKey,
child: _currentStyle == 1 ? _buildGameCard() : _buildPosterWidget(),
),
),
),
);
}
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
const icons = [Icons.image_outlined, Icons.sports_esports_outlined];
const subtitles = ['简约海报风格', '游戏信息卡风格'];
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(alignment: Alignment.centerLeft, child: Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
const SizedBox(height: 12),
for (int i = 0; i < _styleNames.length; i++) ...[
if (i > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i], size: 20, color: _currentStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(_styleNames[i], style: TextStyle(fontSize: 13, fontWeight: _currentStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)),
subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: _currentStyle == i
? Icon(Icons.check_circle, size: 20, color: colors.primary)
: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () { setState(() => _currentStyle = i); Navigator.pop(ctx); },
),
],
const SizedBox(height: 12),
]),
),
);
}
// ─── 样式 0海报 ───
Widget _buildPosterWidget() {
final colors = Theme.of(context).colorScheme;
final game = widget.game;
final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty;
return Container(
width: 320,
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))]),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (hasCover)
ClipRRect(borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: FadeInLocalImage(path: game.coverPath, width: 320, height: 200, fit: BoxFit.cover)),
Padding(padding: const EdgeInsets.all(20), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(game.title, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colors.onSurface)),
const SizedBox(height: 16),
if (game.rating != null && game.rating! > 0) ...[
Row(children: [
const Icon(Icons.star, size: 18, color: Color(0xFFFFB800)),
const SizedBox(width: 4),
Text(game.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
const SizedBox(width: 4),
Text('/ 10', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
]),
const SizedBox(height: 12),
],
if (game.platforms.isNotEmpty) _infoRow('平台', game.platforms.join(' / '), colors),
if (game.genres.isNotEmpty) _infoRow('类型', game.genres.join(' / '), colors),
if (game.playTimeHours > 0 || game.playTimeMinutes > 0)
_infoRow('时长', '${game.playTimeHours}${game.playTimeMinutes}', colors),
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
_infoRow('价格', game.purchasePrice!, colors),
if (game.summary != null && game.summary!.isNotEmpty) ...[
const SizedBox(height: 16),
Text('简介', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 8),
Text(game.summary!, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6), maxLines: 5, overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 20),
Divider(height: 1, color: colors.outline),
const SizedBox(height: 12),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.sports_esports_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 6),
Text('来自 MookNote', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
]),
]),
),
]),
);
}
Widget _infoRow(String label, String value, ColorScheme colors) {
return Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('$label', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
Expanded(child: Text(value, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75)))),
]));
}
// ─── 样式 1游戏卡 ───
Widget _buildGameCard() {
final game = widget.game;
const c = Color(0xFF2D2D2D);
return Container(
width: 300,
decoration: BoxDecoration(color: const Color(0xFFFFFBF5), borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 16, offset: const Offset(0, 6))]),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Padding(padding: const EdgeInsets.all(16), child: Column(children: [
if (game.coverPath != null && game.coverPath!.isNotEmpty)
ClipRRect(borderRadius: BorderRadius.circular(4),
child: FadeInLocalImage(path: game.coverPath, width: 268, height: 160, fit: BoxFit.cover)),
const SizedBox(height: 12),
Text(game.title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: c, letterSpacing: 1)),
])),
_dashedLine(c.withValues(alpha: 0.15)),
Padding(padding: const EdgeInsets.fromLTRB(20, 14, 20, 16), child: Column(children: [
_classicRow('PLATFORM', game.platforms.isNotEmpty ? game.platforms.join(', ') : '--'),
const SizedBox(height: 10),
_classicRow('GENRE', game.genres.isNotEmpty ? game.genres.join(' / ') : '--'),
const SizedBox(height: 10),
_classicRow('STATUS', _statusEN(game.status)),
if (game.playTimeHours > 0 || game.playTimeMinutes > 0) ...[
const SizedBox(height: 10),
_classicRow('PLAY TIME', '${game.playTimeHours}h ${game.playTimeMinutes}m'),
],
if (game.rating != null && game.rating! > 0) ...[
const SizedBox(height: 10),
_classicRow('RATING', '${game.rating!.toStringAsFixed(1)} / 10'),
],
const SizedBox(height: 14),
Row(children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(border: Border.all(color: c.withValues(alpha: 0.2), width: 0.5)),
child: Text(_statusEN(game.status), style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 2, color: c.withValues(alpha: 0.5)))),
const Spacer(),
Icon(Icons.sports_esports_outlined, size: 12, color: c.withValues(alpha: 0.3)),
const SizedBox(width: 4),
Text('MookNote', style: TextStyle(fontSize: 9, letterSpacing: 1, color: c.withValues(alpha: 0.3))),
]),
]),
),
]),
);
}
Widget _classicRow(String label, String value) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 80, child: Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 1.5, color: const Color(0xFF2D2D2D).withValues(alpha: 0.35)))),
Expanded(child: Text(value, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF2D2D2D), height: 1.4))),
]);
}
Widget _dashedLine(Color color) {
return Padding(padding: const EdgeInsets.symmetric(horizontal: 8),
child: CustomPaint(size: const Size(double.infinity, 1), painter: _DashedLinePainter(color: color)));
}
String _statusEN(String s) {
switch (s) {
case 'completed': return 'COMPLETED';
case 'playing': return 'PLAYING';
case 'want_to_play': return 'WISHLIST';
case 'abandoned': return 'DROPPED';
default: return s.toUpperCase();
}
}
Future<void> _generateAndShare() async {
setState(() => _isGenerating = true);
try {
final boundary = _posterKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) throw Exception('无法获取海报边界');
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) throw Exception('无法生成图片数据');
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/game_poster_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(byteData.buffer.asUint8List());
await Share.shareXFiles([XFile(file.path)], text: '分享游戏:${widget.game.title}');
} catch (e) {
if (mounted) ToastUtil.show(context, '生成海报失败:$e');
} finally {
if (mounted) setState(() => _isGenerating = false);
}
}
}
class _DashedLinePainter extends CustomPainter {
final Color color;
final double dashWidth;
final double dashSpace;
_DashedLinePainter({required this.color, this.dashWidth = 4, this.dashSpace = 4});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color..strokeWidth = 1..style = PaintingStyle.stroke;
double x = 0;
while (x < size.width) { canvas.drawLine(Offset(x, 0), Offset(x + dashWidth, 0), paint); x += dashWidth + dashSpace; }
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,503 @@
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/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';
/// 游戏标签页(分页 + 触底加载)
class GameTabPage extends StatefulWidget {
const GameTabPage({super.key});
@override
State<GameTabPage> createState() => _GameTabPageState();
}
class _GameTabPageState extends State<GameTabPage> {
final List<Game> _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;
double _swipeOffset = 0.0;
static const _statusMap = {0: 'completed', 1: 'playing', 2: 'want_to_play', 3: 'abandoned'};
@override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<AppProvider>();
_provider = provider;
provider.addListener(_onDataChanged);
_loadFirst();
});
}
@override
void dispose() {
_provider?.removeListener(_onDataChanged);
_scrollController.dispose();
super.dispose();
}
void _onDataChanged() {
if (!_initialized || !mounted) return;
final provider = context.read<AppProvider>();
if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) {
_lastScrollSignal = provider.scrollToTopSignal;
if (_scrollController.hasClients) {
_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 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 (updated != null) {
final isWallMode = provider.gameWallMode;
final currentStatus = isWallMode ? null : (_statusMap[provider.gameStatusIndex] ?? 'completed');
if (currentStatus != null && updated.status != currentStatus) {
// 状态已变更,从当前列表移除
if (idx != -1) {
setState(() { _items.removeAt(idx); });
}
} else if (idx != -1) {
setState(() { _items[idx] = updated; });
}
} else if (idx != -1) {
// 游戏已被删除,从列表移除
setState(() { _items.removeAt(idx); });
}
return;
}
if (statusChanged || layoutChanged || countChanged || editRefreshed) {
_prevLayoutStyle = provider.gameLayoutStyle;
_prevGameCount = provider.games.length;
_loadFirst();
}
if (editRefreshed) {
_lastEditRefreshCounter = provider.editRefreshCounter;
}
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadFirst() async {
final provider = context.read<AppProvider>();
final isWallMode = provider.gameWallMode;
final statusIdx = provider.gameStatusIndex;
_lastStatusIndex = statusIdx;
_initialized = true;
final status = isWallMode ? null : (_statusMap[statusIdx] ?? 'completed');
final sortMode = UserPrefs().gameSortMode;
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 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);
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) {
final colors = Theme.of(context).colorScheme;
final isWideContent = Breakpoint.isWideContent(context);
final provider = context.watch<AppProvider>();
final isWallMode = provider.gameWallMode;
final masterContent = Column(
children: [
if (!isWallMode) const GameStatusBar(),
if (!isWallMode) Divider(height: 0.5, thickness: 0.5, color: colors.outlineVariant),
Expanded(child: _buildBody(context)),
],
);
if (!isWideContent) return masterContent;
final selectedGame = provider.selectedGame;
return MasterDetailScaffold(
master: masterContent,
detail: selectedGame != null
? GameDetailPage(game: selectedGame, embedded: true)
: const DetailPlaceholder(icon: Icons.sports_esports_outlined, message: '选择一款游戏查看详情'),
);
}
Widget _buildBody(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Consumer<AppProvider>(
builder: (context, provider, _) {
if (_initialized && provider.gameStatusIndex != _lastStatusIndex) {
_lastStatusIndex = provider.gameStatusIndex;
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.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<double>(
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,
),
);
},
);
}
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;
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('确定要删除《${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);
Navigator.pop(ctx);
_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() {
final layoutStyle = context.read<AppProvider>().gameLayoutStyle;
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(BuildContext context, int statusIndex) {
final colors = Theme.of(context).colorScheme;
final provider = context.read<AppProvider>();
final isWallMode = provider.gameWallMode;
final statusText = isWallMode ? '' : ['已通关', '在玩', '想玩', '弃游'][statusIndex];
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width: 80, height: 80,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
child: Icon(Icons.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))),
]));
}
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../models/data_models.dart';
import '../../widgets/fade_in_local_image.dart';
/// 游戏截图画廊页面 - 支持左右滑动浏览
class ScreenshotGalleryPage extends StatefulWidget {
final List<GameScreenshot> screenshots;
final int initialIndex;
const ScreenshotGalleryPage({super.key, required this.screenshots, required this.initialIndex});
@override
State<ScreenshotGalleryPage> createState() => _ScreenshotGalleryPageState();
}
class _ScreenshotGalleryPageState extends State<ScreenshotGalleryPage> {
late PageController _pageController;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: widget.screenshots.length,
onPageChanged: (index) => setState(() => _currentIndex = index),
itemBuilder: (context, index) {
final screenshot = widget.screenshots[index];
return InteractiveViewer(
minScale: 0.5,
maxScale: 3.0,
child: Center(
child: FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.contain),
),
);
},
),
Positioned(
top: 0, left: 0, right: 0,
child: SafeArea(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.7), Colors.transparent],
),
),
child: Row(
children: [
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back, color: Colors.white)),
const Spacer(),
Text('${_currentIndex + 1} / ${widget.screenshots.length}',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500)),
const Spacer(),
const SizedBox(width: 48),
],
),
),
),
),
if (widget.screenshots.length > 1)
Positioned(
bottom: 20, left: 0, right: 0,
child: SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
widget.screenshots.length,
(index) => Container(
width: 8, height: 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == _currentIndex ? Colors.white : Colors.white.withValues(alpha: 0.4),
),
),
),
),
),
),
],
),
);
}
}

View File

@@ -6,6 +6,7 @@ import '../../services/sync/webdav_service.dart';
import '../movies/movie_tab_page.dart';
import '../book/book_tab_page.dart';
import '../note/note_tab_page.dart';
import '../game/game_tab_page.dart';
import '../online_search/search_page.dart';
import '../online_search/online_search_page.dart';
import '../sync/webdav_sync_page.dart';
@@ -24,6 +25,7 @@ class _MainContentPageState extends State<MainContentPage> {
bool _showMovieTab = true;
bool _showBookTab = true;
bool _showNoteTab = true;
bool _showGameTab = true;
late PageController _pageController;
bool _isTabTap = false;
@@ -47,6 +49,7 @@ class _MainContentPageState extends State<MainContentPage> {
_showMovieTab = _userPrefs.showMovieTab;
_showBookTab = _userPrefs.showBookTab;
_showNoteTab = _userPrefs.showNoteTab;
_showGameTab = _userPrefs.showGameTab;
});
}
@@ -61,6 +64,7 @@ class _MainContentPageState extends State<MainContentPage> {
if (_showMovieTab) tabs.add(_TabItem('影视', 0));
if (_showBookTab) tabs.add(_TabItem('阅读', 1));
if (_showNoteTab) tabs.add(_TabItem('笔记', 2));
if (_showGameTab) tabs.add(_TabItem('游戏', 3));
return tabs;
}
@@ -134,6 +138,7 @@ class _MainContentPageState extends State<MainContentPage> {
case 0: return '影视';
case 1: return '阅读';
case 2: return '笔记';
case 3: return '游戏';
default: return 'MookNote';
}
}
@@ -222,6 +227,7 @@ class _MainContentPageState extends State<MainContentPage> {
await provider.loadMovies();
await provider.loadBooks();
await provider.loadNotes();
await provider.loadGames();
}
if (context.mounted) {
_showResultDialog(context, title: result.success ? '同步成功' : '同步失败', message: result.message.isNotEmpty ? result.message : (result.success ? '同步成功' : '同步失败'), isSuccess: result.success, details: {'uploaded': result.uploadedFiles + result.uploadedImages, 'downloaded': result.downloadedFiles + result.downloadedImages});
@@ -313,7 +319,16 @@ class _MainContentPageState extends State<MainContentPage> {
(0, '按更新时间排序', Icons.update),
(1, '按创建时间排序', Icons.calendar_today_outlined),
], (v) { UserPrefs().setNoteSortMode(v); context.read<AppProvider>().loadNotes(); })
: null,
: tab.label == '游戏'
? () {
final isWallMode = UserPrefs().gameWallMode;
_showSortMenu(context, isWallMode ? '游戏墙排序' : '游戏排序', UserPrefs().gameSortMode, [
(0, '按更新时间排序', Icons.update),
(1, '按创建时间排序', Icons.calendar_today_outlined),
(2, '按评分排序', Icons.star_outline),
], (v) { UserPrefs().setGameSortMode(v); context.read<AppProvider>().loadGames(); });
}
: null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [
@@ -434,6 +449,7 @@ class _MainContentPageState extends State<MainContentPage> {
if (_showMovieTab) const MovieTabPage(),
if (_showBookTab) const BookTabPage(),
if (_showNoteTab) const NoteTabPage(),
if (_showGameTab) const GameTabPage(),
],
);
},
@@ -445,6 +461,7 @@ class _MainContentPageState extends State<MainContentPage> {
case '影视': return Icons.movie_outlined;
case '阅读': return Icons.menu_book_outlined;
case '笔记': return Icons.note_outlined;
case '游戏': return Icons.sports_esports_outlined;
default: return Icons.circle;
}
}

View File

@@ -6,6 +6,7 @@ import '../../models/data_models.dart';
import '../movies/movie_detail_page.dart';
import '../book/book_detail_page.dart';
import '../note/note_detail_page.dart';
import '../game/game_detail_page.dart';
import '../../widgets/fade_in_local_image.dart';
/// 搜索页面
@@ -23,6 +24,7 @@ class _SearchPageState extends State<SearchPage> {
bool _showMovies = true;
bool _showBooks = true;
bool _showNotes = true;
bool _showGames = true;
List<_SearchResult> _results = [];
bool _hasSearched = false;
@@ -92,6 +94,16 @@ class _SearchPageState extends State<SearchPage> {
}
}
}
if (_showGames) {
for (final game in provider.games.where((g) => !g.isDeleted)) {
if (game.title.toLowerCase().contains(lowerKeyword) ||
game.genres.any((g) => g.toLowerCase().contains(lowerKeyword)) ||
game.platforms.any((p) => p.toLowerCase().contains(lowerKeyword)) ||
game.versions.any((v) => v.toLowerCase().contains(lowerKeyword))) {
results.add(_SearchResult(type: 'game', data: game));
}
}
}
setState(() {
_results = results;
_hasSearched = true;
@@ -120,6 +132,11 @@ class _SearchPageState extends State<SearchPage> {
if (t.toLowerCase().contains(lowerKeyword)) tagSet.add(t);
}
}
for (final g in provider.games.where((g) => !g.isDeleted)) {
for (final genre in g.genres) {
if (genre.toLowerCase().contains(lowerKeyword)) tagSet.add(genre);
}
}
return tagSet.toList()..sort();
}
@@ -138,6 +155,9 @@ class _SearchPageState extends State<SearchPage> {
for (final n in provider.notes.where((n) => !n.isDeleted)) {
if (n.tags.contains(tag)) results.add(_SearchResult(type: 'note', data: n));
}
for (final g in provider.games.where((g) => !g.isDeleted)) {
if (g.genres.contains(tag)) results.add(_SearchResult(type: 'game', data: g));
}
_results = results;
});
}
@@ -203,12 +223,13 @@ class _SearchPageState extends State<SearchPage> {
Widget _buildFilterRow() {
final keyword = _searchController.text.trim();
final provider = context.read<AppProvider>();
int movieCount = 0, bookCount = 0, noteCount = 0;
int movieCount = 0, bookCount = 0, noteCount = 0, gameCount = 0;
if (keyword.isNotEmpty) {
final kw = keyword.toLowerCase();
movieCount = provider.movies.where((m) => !m.isDeleted && (m.title.toLowerCase().contains(kw) || m.alternateTitles.any((t) => t.toLowerCase().contains(kw)) || (m.summary?.toLowerCase().contains(kw) ?? false) || m.genres.any((g) => g.toLowerCase().contains(kw)) || m.directors.any((d) => d.toLowerCase().contains(kw)) || m.writers.any((w) => w.toLowerCase().contains(kw)) || m.actors.any((a) => a.toLowerCase().contains(kw)))).length;
bookCount = provider.books.where((b) => !b.isDeleted && (b.title.toLowerCase().contains(kw) || b.alternateTitles.any((t) => t.toLowerCase().contains(kw)) || (b.summary?.toLowerCase().contains(kw) ?? false) || b.authors.any((a) => a.toLowerCase().contains(kw)))).length;
noteCount = provider.notes.where((n) => !n.isDeleted && (n.title.toLowerCase().contains(kw) || n.content.toLowerCase().contains(kw) || n.tags.any((t) => t.toLowerCase().contains(kw)))).length;
gameCount = provider.games.where((g) => !g.isDeleted && (g.title.toLowerCase().contains(kw) || g.genres.any((e) => e.toLowerCase().contains(kw)) || g.platforms.any((p) => p.toLowerCase().contains(kw)) || g.versions.any((v) => v.toLowerCase().contains(kw)))).length;
}
return Padding(
@@ -219,6 +240,8 @@ class _SearchPageState extends State<SearchPage> {
_filterChip('书籍', Icons.menu_book_outlined, _showBooks, bookCount, () { setState(() { _showBooks = !_showBooks; _performSearch(); }); }),
const SizedBox(width: 8),
_filterChip('笔记', Icons.note_outlined, _showNotes, noteCount, () { setState(() { _showNotes = !_showNotes; _performSearch(); }); }),
const SizedBox(width: 8),
_filterChip('游戏', Icons.sports_esports_outlined, _showGames, gameCount, () { setState(() { _showGames = !_showGames; _performSearch(); }); }),
]),
);
}
@@ -304,6 +327,7 @@ class _SearchPageState extends State<SearchPage> {
case 'movie': return _buildMovieItem(item.data as Movie);
case 'book': return _buildBookItem(item.data as Book);
case 'note': return _buildNoteItem(item.data as Note);
case 'game': return _buildGameItem(item.data as Game);
default: return const SizedBox.shrink();
}
},
@@ -479,6 +503,46 @@ class _SearchPageState extends State<SearchPage> {
);
}
Widget _buildGameItem(Game game) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => GameDetailPage(game: game))),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
_posterThumb(game.coverPath, Icons.sports_esports_outlined),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
_typeBadge('游戏'),
const Spacer(),
_statusBadge(game.status, colors),
]),
const SizedBox(height: 6),
Text(game.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 4),
Row(children: [
if (game.rating != null) ...[
Icon(Icons.star, size: 13, color: const Color(0xFFFFB800)),
const SizedBox(width: 2),
Text('${game.rating}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: const Color(0xFFFFB800))),
const SizedBox(width: 8),
],
if (game.platforms.isNotEmpty)
Expanded(child: Text(game.platforms.take(2).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)))),
]),
]),
),
const SizedBox(width: 4),
Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.15), size: 18),
]),
),
);
}
Widget _posterThumb(String? path, IconData fallback) {
final colors = Theme.of(context).colorScheme;
return Container(
@@ -503,9 +567,10 @@ class _SearchPageState extends State<SearchPage> {
Widget _statusBadge(String status, ColorScheme colors) {
final (label, bg, fg) = switch (status) {
'watched' || 'read' => ('已看' , colors.primary, colors.onPrimary),
'watching' || 'reading' => ('在看', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)),
'want_to_watch' || 'want_to_read' => ('想看', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)),
'watched' || 'read' || 'completed' => ('已看' , colors.primary, colors.onPrimary),
'watching' || 'reading' || 'playing' => ('在看', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)),
'want_to_watch' || 'want_to_read' || 'want_to_play' => ('想看', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)),
'abandoned' => ('弃游', colors.errorContainer, colors.onError),
_ => ('', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.3)),
};
if (label.isEmpty) return const SizedBox.shrink();

View File

@@ -16,6 +16,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
bool _showMovieTab = true;
bool _showBookTab = true;
bool _showNoteTab = true;
bool _showGameTab = true;
int _defaultTabIndex = 0;
// 侧边栏
@@ -40,6 +41,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
_showMovieTab = _userPrefs.showMovieTab;
_showBookTab = _userPrefs.showBookTab;
_showNoteTab = _userPrefs.showNoteTab;
_showGameTab = _userPrefs.showGameTab;
_defaultTabIndex = _userPrefs.defaultMainTabIndex;
_showHeatmap = _userPrefs.showSidebarHeatmap;
_showRecent = _userPrefs.showSidebarRecent;
@@ -58,6 +60,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
if (_showMovieTab) count++;
if (_showBookTab) count++;
if (_showNoteTab) count++;
if (_showGameTab) count++;
return count;
}
@@ -66,12 +69,14 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
(0, '影视', Icons.movie_outlined),
(1, '阅读', Icons.menu_book_outlined),
(2, '笔记', Icons.note_outlined),
(3, '游戏', Icons.sports_esports_outlined),
];
return all.where((t) {
return switch (t.$1) {
0 => _showMovieTab,
1 => _showBookTab,
2 => _showNoteTab,
3 => _showGameTab,
_ => false,
};
}).toList();
@@ -121,6 +126,18 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
});
}
Future<void> _toggleGameTab(bool value) async {
if (!value && _enabledTabCount <= 1) {
ToastUtil.show(context, '至少保留一个标签页');
return;
}
await _userPrefs.setShowGameTab(value);
setState(() {
_showGameTab = value;
_fixDefaultTabIndex();
});
}
@override
Widget build(BuildContext context) {
@@ -162,6 +179,13 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSwitchItem(Icons.sports_esports_outlined, '游戏', '记录和管理游戏记录', _showGameTab,
_toggleGameTab),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
// ── 侧边栏:信息模块 ──
_buildSectionHeader('侧边栏 · 信息模块'),
_buildSwitchItem(
@@ -383,28 +407,34 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
color: colors.onSurface)))),
const SizedBox(height: 16),
for (final t in enabled)
ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(t.$3,
color: colors.onSurface.withValues(alpha: 0.6))),
title: Text(t.$2,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
trailing: _defaultTabIndex == t.$1
? Icon(Icons.check, color: colors.onSurface, size: 20)
: null,
InkWell(
onTap: () async {
await _userPrefs.setDefaultMainTabIndex(t.$1);
setState(() => _defaultTabIndex = t.$1);
Navigator.pop(ctx);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8)),
child: Icon(t.$3, size: 16,
color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(width: 12),
Text(t.$2,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const Spacer(),
if (_defaultTabIndex == t.$1)
Icon(Icons.check_circle, size: 20, color: colors.primary),
]),
),
),
const SizedBox(height: 8),
],

View File

@@ -1085,6 +1085,14 @@ class _SettingsPageState extends State<SettingsPage> {
if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath);
}
}
for (final game in provider.games) {
if (game.coverPath?.isNotEmpty == true) paths.add(game.coverPath!);
}
for (final gameId in provider.games.map((g) => g.id)) {
for (final screenshot in await provider.getGameScreenshots(gameId)) {
if (screenshot.screenshotPath.isNotEmpty) paths.add(screenshot.screenshotPath);
}
}
return paths;
}

View File

@@ -12,7 +12,7 @@ class RecycleBinPage extends StatefulWidget {
State<RecycleBinPage> createState() => _RecycleBinPageState();
}
enum _ItemType { movie, book, note, movieReview, bookReview, bookExcerpt }
enum _ItemType { movie, book, note, game, movieReview, bookReview, bookExcerpt, gameReview }
class _DeletedItem {
final _ItemType type;
@@ -70,6 +70,22 @@ class _DeletedItem {
icon = Icons.format_quote_outlined,
typeLabel = '书摘';
_DeletedItem.game(Game g)
: type = _ItemType.game,
id = g.id,
title = g.title,
subtitle = '删除于 ${g.updatedAt.year}.${g.updatedAt.month.toString().padLeft(2, '0')}.${g.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.sports_esports_outlined,
typeLabel = '游戏';
_DeletedItem.gameReview(GameReview r)
: type = _ItemType.gameReview,
id = r.id,
title = r.content.isNotEmpty ? r.content : '游戏评价',
subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.rate_review_outlined,
typeLabel = '游戏评价';
}
class _RecycleBinPageState extends State<RecycleBinPage> {
@@ -92,18 +108,22 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
final movies = await provider.getDeletedMovies();
final books = await provider.getDeletedBooks();
final notes = await provider.getDeletedNotes();
final games = await provider.getDeletedGames();
final movieReviews = await provider.getDeletedMovieReviews();
final bookReviews = await provider.getDeletedBookReviews();
final bookExcerpts = await provider.getDeletedBookExcerpts();
final gameReviews = await provider.getDeletedGameReviews();
if (!mounted) return;
setState(() {
_allItems = [
for (final m in movies) _DeletedItem.movie(m),
for (final b in books) _DeletedItem.book(b),
for (final n in notes) _DeletedItem.note(n),
for (final g in games) _DeletedItem.game(g),
for (final r in movieReviews) _DeletedItem.movieReview(r),
for (final r in bookReviews) _DeletedItem.bookReview(r),
for (final e in bookExcerpts) _DeletedItem.bookExcerpt(e),
for (final r in gameReviews) _DeletedItem.gameReview(r),
];
_isLoading = false;
});
@@ -175,9 +195,11 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
_filterChip('影视', _ItemType.movie),
_filterChip('书籍', _ItemType.book),
_filterChip('笔记', _ItemType.note),
_filterChip('游戏', _ItemType.game),
_filterChip('影评', _ItemType.movieReview),
_filterChip('书评', _ItemType.bookReview),
_filterChip('书摘', _ItemType.bookExcerpt),
_filterChip('游戏评价', _ItemType.gameReview),
],
),
);
@@ -391,6 +413,9 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
case _ItemType.note:
await provider.restoreNote(item.id);
if (mounted) ToastUtil.show(context, '笔记已恢复');
case _ItemType.game:
await provider.restoreGame(item.id);
if (mounted) ToastUtil.show(context, '游戏已恢复');
case _ItemType.movieReview:
await provider.restoreMovieReview(item.id);
if (mounted) ToastUtil.show(context, '影评已恢复');
@@ -400,6 +425,9 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
case _ItemType.bookExcerpt:
await provider.restoreBookExcerpt(item.id);
if (mounted) ToastUtil.show(context, '书摘已恢复');
case _ItemType.gameReview:
await provider.restoreGameReview(item.id);
if (mounted) ToastUtil.show(context, '游戏评价已恢复');
}
_loadDeletedItems();
}
@@ -415,12 +443,16 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
await provider.permanentDeleteBook(item.id);
case _ItemType.note:
await provider.permanentDeleteNote(item.id);
case _ItemType.game:
await provider.permanentDeleteGame(item.id);
case _ItemType.movieReview:
await provider.permanentDeleteMovieReview(item.id);
case _ItemType.bookReview:
await provider.permanentDeleteBookReview(item.id);
case _ItemType.bookExcerpt:
await provider.permanentDeleteBookExcerpt(item.id);
case _ItemType.gameReview:
await provider.permanentDeleteGameReview(item.id);
}
_loadDeletedItems();
if (mounted) ToastUtil.show(context, '已彻底删除');

View File

@@ -15,9 +15,9 @@ class _TagManagementPageState extends State<TagManagementPage> {
int _currentIndex = 0;
bool _isSyncing = false;
static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag'];
static const _typeLabels = ['影视类型', '书籍类型', '笔记标签'];
static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined];
static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag', 'game_genre'];
static const _typeLabels = ['影视类型', '书籍类型', '笔记标签', '游戏类型'];
static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined, Icons.sports_esports_outlined];
final Map<String, List<Map<String, dynamic>>> _tagCache = {};
Map<String, int> _usageCounts = {};
@@ -63,6 +63,11 @@ class _TagManagementPageState extends State<TagManagementPage> {
counts[t] = (counts[t] ?? 0) + 1;
}
}
for (final g in provider.games.where((g) => !g.isDeleted)) {
for (final genre in g.genres) {
counts[genre] = (counts[genre] ?? 0) + 1;
}
}
_usageCounts = counts;
}
@@ -121,7 +126,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
children: [
// 弹出的类别胶囊按钮
if (_showTypePicker) ...[
...[0, 1, 2].where((i) => i != _currentIndex).map((i) => Padding(
...[0, 1, 2, 3].where((i) => i != _currentIndex).map((i) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GestureDetector(
onTap: () {
@@ -420,7 +425,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
final idx = _tabTypes.indexOf(type);
final icon = _typeIcons[idx];
final label = _typeLabels[idx];
final hints = ['同步或手动添加影视类型', '同步或手动添加书籍类型', '同步或手动添加笔记标签'];
final hints = ['同步或手动添加影视类型', '同步或手动添加书籍类型', '同步或手动添加笔记标签', '同步或手动添加游戏类型'];
return Center(
key: ValueKey('empty_$type'),
@@ -544,6 +549,10 @@ class _TagManagementPageState extends State<TagManagementPage> {
for (final b in provider.books.where((b) => !b.isDeleted && b.genres.contains(tagName))) {
items.add((title: b.title, subtitle: b.authors.take(2).join(' / '), type: '书籍'));
}
} else if (_currentType == 'game_genre') {
for (final g in provider.games.where((g) => !g.isDeleted && g.genres.contains(tagName))) {
items.add((title: g.title, subtitle: g.platforms.take(2).join(' / '), type: '游戏'));
}
} else {
for (final n in provider.notes.where((n) => !n.isDeleted && n.tags.contains(tagName))) {
items.add((title: n.title.isNotEmpty ? n.title : '随手记', subtitle: null, type: '笔记'));

View File

@@ -471,6 +471,7 @@ class _BackupPageState extends State<BackupPage> {
await context.read<AppProvider>().loadMovies();
await context.read<AppProvider>().loadBooks();
await context.read<AppProvider>().loadNotes();
await context.read<AppProvider>().loadGames();
if (!mounted) return;

View File

@@ -144,6 +144,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
await provider.loadMovies();
await provider.loadBooks();
await provider.loadNotes();
await provider.loadGames();
if (mounted) _showResultDialog('同步成功', details);
} else {
_showResultDialog('同步成功', details);