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

431 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../providers/app_provider.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/app_overlay.dart';
class GameAddPage extends StatefulWidget {
final VoidCallback? onCancel;
final String? initialStatus;
const GameAddPage({super.key, this.onCancel, this.initialStatus});
@override
State<GameAddPage> createState() => _GameAddPageState();
}
class _GameAddPageState extends State<GameAddPage> {
final _formKey = GlobalKey<FormState>();
final ImagePicker _picker = ImagePicker();
late TextEditingController _titleCtrl;
late TextEditingController _summaryCtrl;
late TextEditingController _ratingCtrl;
late TextEditingController _purchasePriceCtrl;
late TextEditingController _playTimeHoursCtrl;
late TextEditingController _playTimeMinutesCtrl;
List<String> _platforms = [];
List<String> _versions = [];
List<String> _genres = [];
List<String> _purchasePlatforms = [];
String? _coverPath;
String _status = 'want_to_play';
String _category = 'digital';
DateTime? _purchaseDate;
bool _isDownloading = false;
String? _tempId;
static const _categories = [
('数字版', 'digital'), ('卡带版', 'cartridge'), ('光盘版', 'disc'),
];
@override
void initState() {
super.initState();
_tempId = const Uuid().v4();
_status = widget.initialStatus ?? 'want_to_play';
_titleCtrl = TextEditingController();
_summaryCtrl = TextEditingController();
_ratingCtrl = TextEditingController();
_purchasePriceCtrl = TextEditingController();
_playTimeHoursCtrl = TextEditingController();
_playTimeMinutesCtrl = TextEditingController();
}
@override
void dispose() {
_titleCtrl.dispose();
_summaryCtrl.dispose();
_ratingCtrl.dispose();
_purchasePriceCtrl.dispose();
_playTimeHoursCtrl.dispose();
_playTimeMinutesCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final hasCover = _coverPath != null && _coverPath!.isNotEmpty;
return Scaffold(
backgroundColor: colors.surface,
body: Form(
key: _formKey,
child: Column(
children: [
// 顶栏
Container(height: 48,
decoration: BoxDecoration(color: colors.surface,
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
IconButton(icon: Icon(Icons.close, color: colors.onSurface, size: 18),
onPressed: () => widget.onCancel?.call()),
Expanded(child: Text('添加游戏',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
FilledButton.icon(onPressed: _save,
icon: const Icon(Icons.check, size: 16), label: const Text('保存'),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
const SizedBox(width: 12),
]),
),
// 主体:左封面+右表单
Expanded(
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 左侧
Container(width: 240, padding: const EdgeInsets.all(20), child: Column(children: [
GestureDetector(onTap: _showCoverOptions, child: Container(
width: 200, height: 280,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
clipBehavior: Clip.antiAlias,
child: Stack(alignment: Alignment.center, children: [
hasCover ? FadeInLocalImage(path: _coverPath, fit: BoxFit.cover)
: 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))),
]),
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: 8),
child: GestureDetector(onTap: () => setState(() => _coverPath = null),
child: Text('移除封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))))),
const SizedBox(height: 20),
_label('状态', colors), const SizedBox(height: 6),
Container(padding: const EdgeInsets.all(2),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
child: Wrap(spacing: 0, runSpacing: 4, children: [
_statusChip('想玩', 'want_to_play', colors), _statusChip('在玩', 'playing', colors),
_statusChip('通关', 'completed', colors), _statusChip('弃游', 'abandoned', colors),
])),
const SizedBox(height: 16),
_label('评分', colors), const SizedBox(height: 6),
_buildRatingRow(colors),
const SizedBox(height: 16),
_label('分类', colors), const SizedBox(height: 6),
Wrap(spacing: 4, runSpacing: 4, children: _categories.map((c) {
final sel = _category == c.$2;
return GestureDetector(onTap: () => setState(() => _category = c.$2),
child: Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: sel ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)),
child: Text(c.$1, style: TextStyle(fontSize: 11, fontWeight: sel ? FontWeight.w500 : FontWeight.normal,
color: sel ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)))));
}).toList()),
])),
// 右侧表单
Expanded(child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_field('名称', _titleCtrl, hint: '游戏名称', required: true), const SizedBox(height: 16),
_chipField('平台', _platforms, onTap: () async {
final p = context.read<AppProvider>(); final d = p.games.map((g) => g.platforms).toList();
final r = await GenreSelectorPage.show(context: context, title: '选择平台', existingTagsFuture: compute(_collectUnique, d), initialSelected: _platforms, hint: 'Switch、PS5');
if (r != null) setState(() => _platforms = r);
}), const SizedBox(height: 16),
_chipField('版本', _versions, onTap: () async {
final p = context.read<AppProvider>(); final d = p.games.map((g) => g.versions).toList();
final r = await GenreSelectorPage.show(context: context, title: '选择版本', existingTagsFuture: compute(_collectUnique, d), initialSelected: _versions, hint: '如:标准版、豪华版');
if (r != null) setState(() => _versions = r);
}), const SizedBox(height: 16),
_chipField('类型', _genres, onTap: () async {
final p = context.read<AppProvider>();
final tags = await p.getTags('game_genre', excludeHidden: true);
final names = tags.map((t) => t['name'] as String).toList();
if (!mounted) return;
final r = await GenreSelectorPage.show(context: context, title: '选择类型', existingTags: names, initialSelected: _genres, hint: 'RPG、动作');
if (r != null) setState(() => _genres = r);
}), const SizedBox(height: 16),
_chipField('购买平台', _purchasePlatforms, onTap: () async {
final p = context.read<AppProvider>(); final d = p.games.map((g) => g.purchasePlatforms).toList();
final r = await GenreSelectorPage.show(context: context, title: '选择购买平台', existingTagsFuture: compute(_collectUnique, d), initialSelected: _purchasePlatforms, hint: 'Steam、eShop');
if (r != null) setState(() => _purchasePlatforms = r);
}), const SizedBox(height: 16),
_field('购买价格', _purchasePriceCtrl, hint: '298'), const SizedBox(height: 16),
_label('游玩时长', Theme.of(context).colorScheme), const SizedBox(height: 6),
Row(children: [
SizedBox(width: 80, child: TextFormField(controller: _playTimeHoursCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(hintText: '0', hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), isDense: true))),
const SizedBox(width: 6),
Text('小时', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(width: 12),
SizedBox(width: 80, child: TextFormField(controller: _playTimeMinutesCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(hintText: '0', hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), isDense: true))),
const SizedBox(width: 6),
Text('分钟', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
]), const SizedBox(height: 16),
_dateField('购买日期', _purchaseDate, (d) => setState(() => _purchaseDate = d), clearable: true), const SizedBox(height: 16),
_label('简介', colors), const SizedBox(height: 6),
Container(constraints: const BoxConstraints(minHeight: 120),
child: TextFormField(controller: _summaryCtrl, maxLines: null,
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
decoration: InputDecoration(hintText: '写下游戏简介...', hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), contentPadding: const EdgeInsets.all(12)))),
]),
)),
]),
),
],
),
),
);
}
Widget _label(String l, ColorScheme c) => Text(l, style: TextStyle(fontSize: 12, color: c.onSurface.withValues(alpha: 0.4)));
Widget _statusChip(String label, String value, ColorScheme c) {
final sel = _status == value;
return GestureDetector(onTap: () => setState(() => _status = value),
child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(color: sel ? c.surface : Colors.transparent, borderRadius: BorderRadius.circular(6),
boxShadow: sel ? [BoxShadow(color: c.onSurface.withValues(alpha: 0.03), blurRadius: 4, offset: const Offset(0, 2))] : null),
child: Text(label, style: TextStyle(fontSize: 13, fontWeight: sel ? FontWeight.w500 : FontWeight.normal,
color: sel ? c.onSurface : c.onSurface.withValues(alpha: 0.4)))));
}
Widget _buildRatingRow(ColorScheme colors) {
return Row(children: [
...List.generate(5, (i) {
final sv = i + 1; final cr = double.tryParse(_ratingCtrl.text) ?? 0; final sr = cr / 2;
final f = sv <= sr; final h = sv == sr.ceil() && sr % 1 != 0;
return GestureDetector(onTap: () => setState(() => _ratingCtrl.text = (sv * 2).toString()),
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 1),
child: Icon(h ? Icons.star_half : (f ? Icons.star : Icons.star_border), size: 20, color: (f || h) ? 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: _ratingCtrl, keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center, inputFormatters: [_RatingInputFormatter()],
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 (_ratingCtrl.text.isNotEmpty) ...[
const SizedBox(width: 4),
GestureDetector(onTap: () => setState(() => _ratingCtrl.clear()),
child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3))),
],
]);
}
Widget _field(String label, TextEditingController ctrl, {String hint = '', bool required = false}) {
final c = Theme.of(context).colorScheme;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: c.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 6),
TextFormField(controller: ctrl, style: TextStyle(fontSize: 14, color: c.onSurface),
validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null,
decoration: InputDecoration(hintText: hint, hintStyle: TextStyle(color: c.onSurface.withValues(alpha: 0.25)),
filled: true, fillColor: c.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), isDense: true)),
]);
}
Widget _chipField(String label, List<String> chips, {required VoidCallback onTap}) {
final c = Theme.of(context).colorScheme;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 12, color: c.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 6),
GestureDetector(onTap: onTap,
child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(color: c.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
child: chips.isEmpty
? Text('点击选择$label', style: TextStyle(fontSize: 14, color: c.onSurface.withValues(alpha: 0.25)))
: Wrap(spacing: 4, runSpacing: 4, children: chips.map((e) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(4)),
child: Text(e, style: TextStyle(fontSize: 12, color: c.onSurface)))).toList()))),
]);
}
Widget _dateField(String label, DateTime? date, ValueChanged<DateTime?> onChanged, {bool clearable = false}) {
final c = Theme.of(context).colorScheme; final has = date != null;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 12, color: c.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 6),
GestureDetector(onTap: () async {
final picked = await showDatePicker(context: context, initialDate: date ?? DateTime.now(),
firstDate: DateTime(1900), lastDate: DateTime.now().add(const Duration(days: 365 * 5)));
if (picked != null) onChanged(picked);
}, child: Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(color: c.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8)),
child: Row(children: [
Icon(Icons.calendar_today_outlined, size: 14, color: c.onSurface.withValues(alpha: 0.4)),
const SizedBox(width: 8),
Text(has ? '${date!.year}.${date!.month.toString().padLeft(2, '0')}.${date!.day.toString().padLeft(2, '0')}' : '选择日期',
style: TextStyle(fontSize: 14, color: has ? c.onSurface : c.onSurface.withValues(alpha: 0.25))),
const Spacer(),
if (clearable && has) GestureDetector(onTap: () => onChanged(null),
child: Icon(Icons.close, size: 14, color: c.onSurface.withValues(alpha: 0.3))),
]))),
]);
}
void _showCoverOptions() {
final c = Theme.of(context).colorScheme;
appModalBottomSheet(context: context, backgroundColor: c.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(child: Padding(padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 40, height: 4, decoration: BoxDecoration(color: c.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: c.onSurface)))),
const SizedBox(height: 16),
ListTile(leading: Icon(Icons.photo_library_outlined, color: c.onSurface.withValues(alpha: 0.6)),
title: Text('从相册选择'), onTap: () { Navigator.pop(ctx); _pickCover(); }),
ListTile(leading: Icon(Icons.link_outlined, color: c.onSurface.withValues(alpha: 0.6)),
title: Text('网络链接'), onTap: () { Navigator.pop(ctx); _pickCoverFromUrl(); }),
]))));
}
Future<void> _pickCover() async {
try {
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 800, maxHeight: 1200, imageQuality: 85);
if (picked == null) return;
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getGameCoverPath(_tempId!, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(picked.path).copy(targetPath);
if (mounted) setState(() => _coverPath = targetPath);
} catch (e) { if (mounted) ToastUtil.show(context, '选择封面失败: $e'); }
}
Future<void> _pickCoverFromUrl() async {
final ctrl = TextEditingController();
final ok = await appDialog<bool>(context: context, builder: (ctx) {
final c = Theme.of(ctx).colorScheme;
return AlertDialog(backgroundColor: c.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: c.onSurface)),
content: TextField(controller: ctrl, keyboardType: TextInputType.url, style: TextStyle(fontSize: 14, color: c.onSurface),
decoration: InputDecoration(hintText: 'https://example.com/image.jpg', filled: true, fillColor: c.surfaceContainerHigh,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消')),
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
]);
});
final url = ctrl.text.trim(); ctrl.dispose();
if (ok != true || url.isEmpty) return;
await _downloadCover(url);
}
Future<void> _downloadCover(String url) async {
setState(() => _isDownloading = true);
try {
final res = await http.get(Uri.parse(url), headers: {
'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*,*/*;q=0.8', 'Referer': Uri.parse(url).replace(path: '/').toString(),
});
if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getGameCoverPath(_tempId!, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(res.bodyBytes);
if (mounted) setState(() => _coverPath = targetPath);
} catch (e) { if (mounted) ToastUtil.show(context, '下载失败: $e'); }
finally { if (mounted) setState(() => _isDownloading = false); }
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
try {
final gameId = const Uuid().v4();
// 移动封面到正式目录
String? finalCoverPath;
if (_coverPath != null) {
final normalized = _coverPath!.replaceAll('\\', '/');
if (!normalized.contains('/games/$gameId/')) {
final fileName = p.basename(_coverPath!);
final newPath = await ImagePathHelper.instance.getGameCoverPath(gameId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath));
final src = File(_coverPath!);
if (await src.exists()) { await src.rename(newPath); finalCoverPath = newPath; }
// 清理临时目录
final tempDir = Directory(p.dirname(_coverPath!));
if (await tempDir.exists()) { try { await tempDir.delete(recursive: true); } catch (_) {} }
} else { finalCoverPath = _coverPath; }
}
final rating = _ratingCtrl.text.isNotEmpty ? double.tryParse(_ratingCtrl.text) : null;
final purchasePrice = _purchasePriceCtrl.text.trim().isNotEmpty ? _purchasePriceCtrl.text.trim() : null;
final playTimeHours = int.tryParse(_playTimeHoursCtrl.text) ?? 0;
final playTimeMinutes = int.tryParse(_playTimeMinutesCtrl.text) ?? 0;
final now = DateTime.now();
final game = Game(
id: gameId, title: _titleCtrl.text.trim(), coverPath: finalCoverPath,
platforms: _platforms, versions: _versions, genres: _genres,
purchasePlatforms: _purchasePlatforms, purchasePrice: purchasePrice,
playTimeHours: playTimeHours, playTimeMinutes: playTimeMinutes,
summary: _summaryCtrl.text.trim(), rating: rating,
status: _status, category: _category, purchaseDate: _purchaseDate,
createdAt: now, updatedAt: now,
);
await context.read<AppProvider>().addGame(game);
await context.read<AppProvider>().loadGames();
if (!mounted) return;
context.read<AppProvider>().finishAdding();
ToastUtil.show(context, '添加成功');
} catch (e) { if (mounted) ToastUtil.show(context, '保存失败: $e'); }
}
static List<String> _collectUnique(List<List<String>> lists) {
final s = <String>{}; for (final l in lists) { s.addAll(l); } return s.toList()..sort();
}
}
class _RatingInputFormatter 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;
}
}