Files
MookNote/lib/pages/game/game_form_page.dart
2026-08-13 15:25:27 +08:00

1419 lines
54 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import '../../providers/app_provider.dart';
import '../../widgets/fade_in_local_image.dart';
import 'package:uuid/uuid.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import '../../utils/image_path_helper.dart';
import '../../widgets/genre_selector_page.dart';
import '../../widgets/text_input_panel.dart';
import '../../widgets/app_overlay.dart';
/// 从多值字段列表中提取去重排序的唯一值(供 compute 使用)
List<String> _collectUnique(List<List<String>> lists) {
final s = <String>{};
for (final l in lists) { s.addAll(l); }
return s.toList()..sort();
}
/// 添加/编辑游戏页面
class GameFormPage extends StatefulWidget {
final Game? game;
final String? initialStatus;
/// 快捷添加预填充字段game 为 null 时生效,保持添加模式)
final Map<String, dynamic>? prefill;
const GameFormPage({super.key, this.game, this.initialStatus, this.prefill});
@override
State<GameFormPage> createState() => _GameFormPageState();
}
class _GameFormPageState extends State<GameFormPage> {
final _formKey = GlobalKey<FormState>();
final ImagePicker _picker = ImagePicker();
late TextEditingController _titleController;
late TextEditingController _ratingController;
late TextEditingController _playTimeHoursController;
late TextEditingController _playTimeMinutesController;
int _playCount = 0;
late TextEditingController _purchasePriceController;
late TextEditingController _summaryController;
List<String> _platforms = [];
List<String> _versions = [];
List<String> _genres = [];
List<String> _developer = [];
List<String> _purchasePlatforms = [];
String? _coverPath;
String _status = 'want_to_play';
String _category = 'digital';
DateTime? _purchaseDate;
DateTime? _releaseDate;
bool _isDownloading = false;
@override
void initState() {
super.initState();
_initializeData();
}
void _initializeData() {
Game? game = widget.game;
if (game != null) {
final appProvider = context.read<AppProvider>();
final latestGame = appProvider.games
.where((g) => g.id == game!.id)
.firstOrNull;
if (latestGame != null) {
game = latestGame;
}
}
_titleController = TextEditingController(text: game?.title ?? '');
_ratingController = TextEditingController(text: game?.rating?.toString() ?? '');
_playTimeHoursController = TextEditingController(text: game?.playTimeHours.toString() ?? '0');
_playTimeMinutesController = TextEditingController(text: game?.playTimeMinutes.toString() ?? '0');
_purchasePriceController = TextEditingController(text: game?.purchasePrice ?? '');
_summaryController = TextEditingController(text: game?.summary ?? '');
if (game != null) {
_platforms = List.from(game.platforms);
_versions = List.from(game.versions);
_genres = List.from(game.genres);
_developer = List.from(game.developer);
_purchasePlatforms = List.from(game.purchasePlatforms);
_coverPath = game.coverPath;
_status = game.status;
_category = game.category;
_purchaseDate = game.purchaseDate;
_releaseDate = game.releaseDate;
_playCount = game.playCount;
} else if (widget.initialStatus != null) {
_status = widget.initialStatus!;
}
// 快捷添加预填充(保持添加模式)
if (game == null && widget.prefill != null) {
_fillFromPrefill(widget.prefill!);
}
}
/// 从快捷添加的预填充 map 填充字段
void _fillFromPrefill(Map<String, dynamic> prefill) {
_titleController.text = prefill['title']?.toString() ?? '';
_ratingController.text = prefill['rating']?.toString() ?? '';
_summaryController.text = prefill['summary']?.toString() ?? '';
_platforms = List<String>.from(prefill['platforms'] ?? const []);
_genres = List<String>.from(prefill['genres'] ?? const []);
_developer = List<String>.from(prefill['developer'] ?? const []);
_coverPath = prefill['coverPath'] as String?;
_releaseDate = prefill['releaseDate'] as DateTime?;
}
@override
void dispose() {
_titleController.dispose();
_ratingController.dispose();
_playTimeHoursController.dispose();
_playTimeMinutesController.dispose();
_purchasePriceController.dispose();
_summaryController.dispose();
super.dispose();
}
Widget _buildActionButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
Color? color,
}) {
final colors = Theme.of(context).colorScheme;
final iconColor = color ?? colors.onSurface;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
color: colors.surface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(icon, color: iconColor, size: 22),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final isEdit = widget.game != null;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _confirmLeave();
if (shouldPop && context.mounted) Navigator.pop(context);
},
child: Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: Text(isEdit ? '编辑游戏' : '添加游戏'),
actions: [
_buildActionButton(
icon: Icons.save_outlined,
onPressed: _saveGame,
tooltip: '保存',
),
const SizedBox(width: 8),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
children: [
Center(child: _buildCoverPicker()),
const SizedBox(height: 20),
_buildStatusRatingRow(),
const SizedBox(height: 24),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
// 名称
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '名称',
value: _titleController.text,
required: true,
icon: Icons.sports_esports_outlined,
onTap: _editTitle,
),
),
// 平台
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '平台',
value: _platforms.isEmpty
? ''
: '${_platforms.length}个:${_platforms.join('')}',
icon: Icons.devices_outlined,
scrollHorizontal: true,
onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.games.map((g) => g.platforms).toList();
final result = await GenreSelectorPage.show(
context: context,
title: '选择平台',
existingTagsFuture: compute(_collectUnique, data),
initialSelected: _platforms,
hint: 'PS5、Switch、Steam',
);
if (!mounted) return;
if (result != null) setState(() => _platforms = result);
},
),
),
// 版本
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '版本',
value: _versions.isEmpty
? ''
: '${_versions.length}个:${_versions.join('')}',
icon: Icons.library_books_outlined,
scrollHorizontal: true,
onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.games.map((g) => g.versions).toList();
final result = await GenreSelectorPage.show(
context: context,
title: '选择版本',
existingTagsFuture: compute(_collectUnique, data),
initialSelected: _versions,
hint: '如:标准版、豪华版',
);
if (!mounted) return;
if (result != null) setState(() => _versions = result);
},
),
),
// 类型
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '类型',
value: _genres.isEmpty
? ''
: '${_genres.length}个:${_genres.join('')}',
icon: Icons.style_outlined,
onTap: () async {
final provider = context.read<AppProvider>();
final tags = await provider.getTags('game_genre', excludeHidden: true);
final existingNames = tags.map((t) => t['name'] as String).toList();
// 补充已有游戏中使用过的类型
final gameGenres = provider.games
.expand((g) => g.genres)
.toSet()
.toList();
for (final g in gameGenres) {
if (!existingNames.contains(g)) existingNames.add(g);
}
if (!mounted) return;
final result = await GenreSelectorPage.show(
context: context,
title: '选择类型',
existingTags: existingNames,
initialSelected: _genres,
hint: 'RPG、动作、冒险',
);
if (!mounted) return;
if (result != null) setState(() => _genres = result);
},
),
),
// 开发者
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '开发者',
value: _developer.isEmpty
? ''
: '${_developer.length}个:${_developer.join('')}',
icon: Icons.code_outlined,
scrollHorizontal: true,
onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.games.map((g) => g.developer).toList();
final result = await GenreSelectorPage.show(
context: context,
title: '选择开发者',
existingTagsFuture: compute(_collectUnique, data),
initialSelected: _developer,
hint: '任天堂、FromSoftware',
);
if (!mounted) return;
if (result != null) setState(() => _developer = result);
},
),
),
// 发售时间
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '发售时间',
value: _releaseDate != null
? '${_releaseDate!.year}.${_releaseDate!.month.toString().padLeft(2, '0')}.${_releaseDate!.day.toString().padLeft(2, '0')}'
: '',
icon: Icons.event_outlined,
trailing: _releaseDate != null
? GestureDetector(
onTap: () => setState(() => _releaseDate = null),
child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)),
)
: null,
onTap: () => _selectReleaseDate(),
),
),
// 游玩时长
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '游玩时长',
value: _buildPlayTimeText(),
icon: Icons.timer_outlined,
onTap: () => _showPlayTimePicker(),
),
),
// 游玩次数
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '游玩次数',
value: _playCount > 0 ? '$_playCount' : '',
icon: Icons.repeat_outlined,
onTap: () => _editPlayCount(),
),
),
// 购买平台
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '购买平台',
value: _purchasePlatforms.isEmpty
? ''
: '${_purchasePlatforms.length}个:${_purchasePlatforms.join('')}',
icon: Icons.store_outlined,
scrollHorizontal: true,
onTap: () async {
final provider = context.read<AppProvider>();
final data = provider.games.map((g) => g.purchasePlatforms).toList();
final result = await GenreSelectorPage.show(
context: context,
title: '选择购买平台',
existingTagsFuture: compute(_collectUnique, data),
initialSelected: _purchasePlatforms,
hint: 'Steam、eShop、PlayStation Store',
);
if (!mounted) return;
if (result != null) setState(() => _purchasePlatforms = result);
},
),
),
// 购买时间
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '购买时间',
value: _purchaseDate != null
? '${_purchaseDate!.year}.${_purchaseDate!.month.toString().padLeft(2, '0')}.${_purchaseDate!.day.toString().padLeft(2, '0')}'
: '',
icon: Icons.calendar_today_outlined,
trailing: _purchaseDate != null
? GestureDetector(
onTap: () => setState(() => _purchaseDate = null),
child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)),
)
: null,
onTap: () => _selectPurchaseDate(),
),
),
// 购买价格
SizedBox(
width: (MediaQuery.of(context).size.width - 52) / 2,
height: 90,
child: _buildInfoCard(
label: '购买价格',
value: _purchasePriceController.text.isNotEmpty ? _purchasePriceController.text : '',
icon: Icons.payments_outlined,
onTap: () async {
final result = await TextInputPanel.show(
context: context,
title: '购买价格',
initialValue: _purchasePriceController.text,
hint: '298元、49.99美元',
keyboardType: TextInputType.text,
);
if (!mounted) return;
if (result != null) setState(() => _purchasePriceController.text = result);
},
),
),
],
),
const SizedBox(height: 12),
// 游戏简介(独占一行)
SizedBox(
width: double.infinity,
child: _buildInfoCard(
label: '游戏简介',
value: _summaryController.text,
icon: Icons.description_outlined,
height: 160,
scrollable: true,
onTap: () => _editSummary(),
),
),
const SizedBox(height: 48),
],
),
),
),
);
}
static const _categoryLabels = {'digital': '数字版', 'cartridge': '卡带', 'disc': '光盘'};
String _buildPlayTimeText() {
final h = int.tryParse(_playTimeHoursController.text) ?? 0;
final m = int.tryParse(_playTimeMinutesController.text) ?? 0;
if (h == 0 && m == 0) return '';
final parts = <String>[];
if (h > 0) parts.add('$h小时');
if (m > 0) parts.add('$m分钟');
return parts.join('');
}
Widget _buildInfoCard({
required String label,
required String value,
required VoidCallback onTap,
bool required = false,
IconData? icon,
Widget? trailing,
double? height,
bool scrollable = false,
bool scrollHorizontal = false,
}) {
final hasValue = value.isNotEmpty;
final colors = Theme.of(context).colorScheme;
Widget buildContent() {
if (scrollable && height != null) {
return Flexible(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Text(
hasValue ? value : '点击填写',
style: TextStyle(
fontSize: 15,
color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25),
fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal,
),
),
),
);
} else if (scrollHorizontal) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Text(
hasValue ? value : '点击填写',
style: TextStyle(
fontSize: 15,
color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25),
fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal,
),
),
);
} else {
return Text(
hasValue ? value : '点击填写',
style: TextStyle(
fontSize: 15,
color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25),
fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
}
return GestureDetector(
onTap: onTap,
child: Container(
height: height,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outline),
boxShadow: [
BoxShadow(
color: colors.onSurface.withValues(alpha: 0.018),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: height != null ? MainAxisSize.max : MainAxisSize.min,
children: [
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
const SizedBox(width: 6),
],
Text(
required ? '$label *' : label,
style: TextStyle(
fontSize: 12,
color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4),
fontWeight: required ? FontWeight.w500 : FontWeight.normal,
),
),
if (trailing != null) ...[
const Spacer(),
trailing,
],
],
),
const SizedBox(height: 8),
buildContent(),
],
),
),
);
}
/// 全屏编辑游戏简介
Future<void> _editSummary() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (_) => _SummaryEditorPage(initialText: _summaryController.text),
),
);
if (!mounted) return;
if (result != null) {
setState(() => _summaryController.text = result);
}
}
/// 状态 + 评分 + 类别合并行
Widget _buildStatusRatingRow() {
final colors = Theme.of(context).colorScheme;
final currentRating = double.tryParse(_ratingController.text) ?? 0;
final starRating = currentRating / 2;
final hasRating = _ratingController.text.isNotEmpty;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Column(
children: [
// 状态
Row(
children: [
Text('状态', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusOption('想玩', 'want_to_play'),
_buildStatusOption('在玩', 'playing'),
_buildStatusOption('已通关', 'completed'),
_buildStatusOption('弃游', 'abandoned'),
],
),
),
],
),
const SizedBox(height: 12),
// 评分
Row(
children: [
Text('评分', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(width: 12),
...List.generate(5, (index) {
final starValue = index + 1;
final isFilled = starValue <= starRating;
final isHalf = starValue == starRating.ceil() && starRating % 1 != 0;
return GestureDetector(
onTap: () => setState(() => _ratingController.text = (starValue * 2).toString()),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Icon(
isHalf ? Icons.star_half : (isFilled ? Icons.star : Icons.star_border),
size: 22,
color: (isFilled || isHalf) ? const Color(0xFFFFB800) : colors.outline,
),
),
);
}),
const SizedBox(width: 8),
Container(
width: 48, height: 28,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: TextFormField(
controller: _ratingController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center,
inputFormatters: [GameRatingInputFormatter()],
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
decoration: InputDecoration(
hintText: '0-10',
hintStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25)),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
),
if (hasRating) ...[
const SizedBox(width: 6),
GestureDetector(
onTap: () => setState(() => _ratingController.clear()),
child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
],
),
const SizedBox(height: 12),
// 类别
Row(
children: [
Text('类别', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(width: 12),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: _categoryLabels.entries.map((e) {
final isSelected = _category == e.key;
return GestureDetector(
onTap: () => setState(() => _category = e.key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
e.value,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5),
),
),
),
);
}).toList(),
),
),
),
],
),
],
),
);
}
Widget _buildStatusOption(String label, String value) {
final isSelected = _status == value;
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => setState(() => _status = value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? colors.surface : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: isSelected
? [
BoxShadow(
color: colors.onSurface.withValues(alpha: 0.03),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
color: isSelected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4),
),
),
),
);
}
Widget _buildCoverPicker() {
final hasCover = _coverPath != null && _coverPath!.isNotEmpty;
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _showCoverOptions,
child: Container(
width: 120,
height: 170,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: Stack(
alignment: Alignment.center,
children: [
if (hasCover)
FadeInLocalImage(path: _coverPath, fit: BoxFit.cover)
else
_buildCoverPlaceholder(),
if (_isDownloading)
Container(
color: Colors.black.withValues(alpha: 0.4),
child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
],
),
),
),
if (hasCover)
Padding(
padding: const EdgeInsets.only(top: 10),
child: GestureDetector(
onTap: () => setState(() => _coverPath = null),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete_outline, size: 14, color: colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 4),
Text('移除封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6))),
],
),
),
),
),
],
);
}
Widget _buildCoverPlaceholder() {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)),
const SizedBox(height: 8),
Text('封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
],
);
}
Future<void> _pickCover() async {
try {
final XFile? pickedFile = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 800,
maxHeight: 1200,
imageQuality: 85,
);
if (pickedFile != null) {
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final gameId = widget.game?.id ?? const Uuid().v4();
final targetPath = await ImagePathHelper.instance.getGameCoverPath(gameId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(pickedFile.path).copy(targetPath);
if (!mounted) return;
setState(() => _coverPath = targetPath);
}
} catch (e) {
if (mounted) {
ToastUtil.show(context, '选择封面失败: $e');
}
}
}
/// 显示封面选择选项
void _showCoverOptions() {
final colors = Theme.of(context).colorScheme;
appModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
final colors = Theme.of(context).colorScheme;
return 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: Align(
alignment: Alignment.centerLeft,
child: Text('添加封面', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
),
),
const SizedBox(height: 16),
_buildCoverOption(
icon: Icons.photo_library_outlined,
title: '从相册选择',
onTap: () { Navigator.pop(context); _pickCover(); },
),
_buildCoverOption(
icon: Icons.link_outlined,
title: '网络链接',
onTap: () { Navigator.pop(context); _pickCoverFromUrl(); },
),
],
),
),
);
},
);
}
Widget _buildCoverOption({required IconData icon, required String title, required VoidCallback onTap}) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
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),
Text(title, style: TextStyle(fontSize: 16, color: colors.onSurface)),
const Spacer(),
Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20),
],
),
),
);
}
/// 从网络链接选择封面
Future<void> _pickCoverFromUrl() async {
final urlController = TextEditingController();
final confirmed = await appDialog<bool>(
context: context,
builder: (ctx) {
final colors = Theme.of(ctx).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: 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,
keyboardType: TextInputType.url,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: 'https://example.com/image.jpg',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
filled: true,
fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)),
),
),
],
),
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.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('确定'),
),
],
);
},
);
final url = urlController.text.trim();
WidgetsBinding.instance.addPostFrameCallback((_) {
urlController.dispose();
});
if (confirmed != true || url.isEmpty) return;
await _downloadCoverFromUrl(url);
}
/// 从URL下载封面图
Future<void> _downloadCoverFromUrl(String url) async {
setState(() => _isDownloading = true);
try {
final response = await http.get(
Uri.parse(url),
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;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 = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final gameId = widget.game?.id ?? const Uuid().v4();
final targetPath = await ImagePathHelper.instance.getGameCoverPath(gameId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(response.bodyBytes);
if (!mounted) return;
setState(() => _coverPath = targetPath);
} catch (e) {
debugPrint('封面下载失败: $e');
if (mounted) ToastUtil.show(context, '下载失败: $e');
} finally {
if (mounted) setState(() => _isDownloading = false);
}
}
/// 编辑游戏名称(弹窗)
Future<void> _editTitle() async {
final controller = TextEditingController(text: _titleController.text);
final result = await appDialog<String>(
context: context,
builder: (ctx) {
final colors = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: colors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('游戏名称', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: TextField(
controller: controller,
autofocus: true,
style: TextStyle(fontSize: 15, color: colors.onSurface),
decoration: InputDecoration(
hintText: '请输入游戏名称',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)),
filled: true,
fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, controller.text),
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('确定'),
),
],
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.dispose();
});
if (!mounted) return;
if (result != null) setState(() => _titleController.text = result.trim());
}
Future<void> _editPlayCount() async {
final controller = TextEditingController(text: _playCount > 0 ? '$_playCount' : '');
final result = await appDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('游玩次数'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
autofocus: true,
decoration: const InputDecoration(hintText: '输入次数'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('确定')),
],
),
);
if (result != null) {
final val = int.tryParse(result) ?? 0;
setState(() => _playCount = val < 0 ? 0 : val);
}
}
void _showPlayTimePicker() {
final colors = Theme.of(context).colorScheme;
final hoursController = TextEditingController(text: _playTimeHoursController.text);
final minutesController = TextEditingController(text: _playTimeMinutesController.text);
appDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('游玩时长', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Row(
children: [
Expanded(
child: TextField(
controller: hoursController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: '小时',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: minutesController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: '分钟',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () {
setState(() {
_playTimeHoursController.text = hoursController.text;
_playTimeMinutesController.text = minutesController.text;
});
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary, foregroundColor: colors.onPrimary, 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),
),
).then((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
hoursController.dispose();
minutesController.dispose();
});
});
}
Future<void> _selectPurchaseDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _purchaseDate ?? DateTime.now(),
firstDate: DateTime(1990),
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
builder: (context, child) => child!,
);
if (!mounted) return;
if (picked != null) {
setState(() => _purchaseDate = picked);
}
}
Future<void> _selectReleaseDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _releaseDate ?? DateTime.now(),
firstDate: DateTime(1970),
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
builder: (context, child) => child!,
);
if (!mounted) return;
if (picked != null) {
setState(() => _releaseDate = picked);
}
}
bool _hasContent() {
if (widget.game != null) return true;
if (_titleController.text.trim().isNotEmpty) return true;
if (_ratingController.text.trim().isNotEmpty) return true;
if (_coverPath != null) return true;
if (_platforms.isNotEmpty || _versions.isNotEmpty || _genres.isNotEmpty || _developer.isNotEmpty) return true;
if (_purchasePlatforms.isNotEmpty) return true;
if (_purchaseDate != null || _releaseDate != null) return true;
if (_purchasePriceController.text.trim().isNotEmpty) return true;
if (_summaryController.text.trim().isNotEmpty) return true;
if (int.tryParse(_playTimeHoursController.text) != null && int.parse(_playTimeHoursController.text) > 0) return true;
if (int.tryParse(_playTimeMinutesController.text) != null && int.parse(_playTimeMinutesController.text) > 0) return true;
return false;
}
Future<bool> _confirmLeave() async {
if (!_hasContent()) return true;
final colors = Theme.of(context).colorScheme;
final result = await appDialog<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),
),
);
return result ?? false;
}
Future<void> _saveGame() async {
if (!_formKey.currentState!.validate()) return;
try {
final rating = _ratingController.text.isNotEmpty
? double.tryParse(_ratingController.text)
: null;
final playTimeHours = int.tryParse(_playTimeHoursController.text) ?? 0;
final playTimeMinutes = int.tryParse(_playTimeMinutesController.text) ?? 0;
final now = DateTime.now();
if (widget.game == null) {
final newGameId = const Uuid().v4();
String? finalCoverPath;
if (_coverPath != null && _coverPath!.isNotEmpty) {
finalCoverPath = await _moveCoverToNewId(_coverPath!, newGameId);
}
final newGame = Game(
id: newGameId,
title: _titleController.text.trim(),
coverPath: finalCoverPath,
rating: rating,
status: _status,
category: _category,
platforms: _platforms,
versions: _versions,
genres: _genres,
developer: _developer,
playTimeHours: playTimeHours,
playTimeMinutes: playTimeMinutes,
playCount: _playCount,
purchasePlatforms: _purchasePlatforms,
purchaseDate: _purchaseDate,
releaseDate: _releaseDate,
purchasePrice: _purchasePriceController.text.trim().isNotEmpty
? _purchasePriceController.text.trim()
: null,
summary: _summaryController.text.trim().isNotEmpty
? _summaryController.text.trim()
: null,
createdAt: now,
updatedAt: now,
);
await context.read<AppProvider>().addGame(newGame);
await context.read<AppProvider>().loadGames();
} else {
final updatedGame = widget.game!.copyWith(
title: _titleController.text.trim(),
coverPath: _coverPath,
rating: rating,
status: _status,
category: _category,
platforms: _platforms,
versions: _versions,
genres: _genres,
developer: _developer,
playTimeHours: playTimeHours,
playTimeMinutes: playTimeMinutes,
playCount: _playCount,
purchasePlatforms: _purchasePlatforms,
purchaseDate: _purchaseDate,
releaseDate: _releaseDate,
purchasePrice: _purchasePriceController.text.trim().isNotEmpty
? _purchasePriceController.text.trim()
: null,
summary: _summaryController.text.trim().isNotEmpty
? _summaryController.text.trim()
: null,
updatedAt: now,
);
await context.read<AppProvider>().updateGame(updatedGame);
}
if (!mounted) return;
ToastUtil.show(context, widget.game == null ? '添加成功' : '更新成功');
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ToastUtil.show(context, '保存失败: $e');
}
}
Future<String?> _moveCoverToNewId(String currentPath, String newGameId) async {
final normalizedPath = currentPath.replaceAll('\\', '/');
if (normalizedPath.contains('/games/$newGameId/')) {
return currentPath;
}
final fileName = p.basename(currentPath);
final newPath = await ImagePathHelper.instance.getGameCoverPath(newGameId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath));
final currentFile = File(currentPath);
if (await currentFile.exists()) {
await currentFile.rename(newPath);
final tempDir = Directory(p.dirname(currentPath));
if (await tempDir.exists()) {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
return newPath;
}
return null;
}
}
/// 评分输入格式化器:只允许 0-10最多1位小数
class GameRatingInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
final text = newValue.text;
if (text.isEmpty) return newValue;
if (!RegExp(r'^\d{0,2}\.?\d{0,1}$').hasMatch(text)) return oldValue;
final n = double.tryParse(text);
if (n != null && n > 10) return oldValue;
return newValue;
}
}
/// 游戏简介全屏编辑页
class _SummaryEditorPage extends StatefulWidget {
final String initialText;
const _SummaryEditorPage({required this.initialText});
@override
State<_SummaryEditorPage> createState() => _SummaryEditorPageState();
}
class _SummaryEditorPageState extends State<_SummaryEditorPage> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('游戏简介'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, _controller.text.trim()),
child: Text('完成', style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary,
)),
),
const SizedBox(width: 8),
],
),
body: TextField(
controller: _controller,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.6),
decoration: InputDecoration(
hintText: '写下游戏简介...',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)),
contentPadding: const EdgeInsets.all(20),
border: InputBorder.none,
),
),
);
}
}