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:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; import '../../utils/image_path_helper.dart'; import '../../utils/toast_util.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../widgets/genre_selector_page.dart'; /// 角色编辑/添加页面 /// /// entityType: 'movie' / 'book' / 'game' /// entityId: 所属作品 ID /// character: 可空,空=新建 class CharacterFormPage extends StatefulWidget { final String entityType; final String entityId; final dynamic character; // MovieCharacter / BookCharacter / GameCharacter const CharacterFormPage({ super.key, required this.entityType, required this.entityId, this.character, }); @override State createState() => _CharacterFormPageState(); } class _CharacterFormPageState extends State { final _formKey = GlobalKey(); final _nameCtrl = TextEditingController(); final _roleCtrl = TextEditingController(); final _descCtrl = TextEditingController(); final ImagePicker _picker = ImagePicker(); List _aliases = []; List _tags = []; String? _imagePath; bool _isDownloading = false; @override void initState() { super.initState(); if (widget.character != null) { final c = widget.character; _nameCtrl.text = c.name; _roleCtrl.text = c.role ?? ''; _descCtrl.text = c.description ?? ''; _aliases = List.from(c.aliases); _tags = List.from(c.tags); _imagePath = c.imagePath; } } @override void dispose() { _nameCtrl.dispose(); _roleCtrl.dispose(); _descCtrl.dispose(); super.dispose(); } String get _entityLabel => switch (widget.entityType) { 'movie' => '影视', 'book' => '书籍', 'game' => '游戏', _ => '作品', }; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final isEdit = widget.character != 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: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: FilledButton( onPressed: _save, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text('保存'), ), ), ], ), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), children: [ Center(child: _buildImagePicker(colors)), const SizedBox(height: 24), _buildField('名称', _nameCtrl, hint: '角色名称', required: true), const SizedBox(height: 16), _buildField('角色定位', _roleCtrl, hint: '如:男主、女主、反派、配角'), const SizedBox(height: 16), _buildChipField('别名', _aliases, colors, onTap: () async { final result = await GenreSelectorPage.show( context: context, title: '添加别名', existingTags: [], initialSelected: _aliases, hint: '如:曾用名、英文名', ); if (result != null) setState(() => _aliases = result); }), const SizedBox(height: 16), _buildChipField('标签', _tags, colors, onTap: () async { final result = await GenreSelectorPage.show( context: context, title: '添加标签', existingTags: [], initialSelected: _tags, hint: '如:主角、反派', ); if (result != null) setState(() => _tags = result); }), const SizedBox(height: 16), _buildSectionLabel('角色简介', colors), const SizedBox(height: 6), Container( constraints: const BoxConstraints(minHeight: 120), child: TextFormField( controller: _descCtrl, 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), ), ), ), const SizedBox(height: 48), ], ), ), ), ); } Widget _buildImagePicker(ColorScheme colors) { final hasImage = _imagePath != null && _imagePath!.isNotEmpty; return Column( mainAxisSize: MainAxisSize.min, children: [ GestureDetector( onTap: _showImageOptions, child: Container( width: 100, height: 100, decoration: BoxDecoration( color: colors.surfaceContainerHighest, shape: BoxShape.circle, ), clipBehavior: Clip.antiAlias, child: Stack( alignment: Alignment.center, children: [ if (hasImage) FadeInLocalImage(path: _imagePath, fit: BoxFit.cover) else Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.person_add_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)), const SizedBox(height: 4), Text('添加图片', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), ], ), if (_isDownloading) Container( color: Colors.black.withValues(alpha: 0.4), child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ), ], ), ), ), if (hasImage) Padding( padding: const EdgeInsets.only(top: 8), child: GestureDetector( onTap: () => setState(() => _imagePath = null), child: Text('移除图片', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), ), ), ], ); } void _showImageOptions() { final colors = Theme.of(context).colorScheme; showModalBottomSheet( context: context, backgroundColor: colors.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: 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), ListTile( leading: Icon(Icons.photo_library_outlined, color: colors.onSurface.withValues(alpha: 0.6)), title: Text('从相册选择', style: TextStyle(color: colors.onSurface)), onTap: () { Navigator.pop(ctx); _pickImage(); }, ), ListTile( leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)), title: Text('网络链接', style: TextStyle(color: colors.onSurface)), onTap: () { Navigator.pop(ctx); _pickImageFromUrl(); }, ), ], ), ), ), ); } Future _characterId() async { if (widget.character != null) return widget.character.id; return const Uuid().v4(); } Future _pickImage() async { try { final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 600, maxHeight: 600, imageQuality: 85); if (picked == null) return; final fileName = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg'; final charId = await _characterId(); final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName); await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); await File(picked.path).copy(targetPath); if (mounted) setState(() => _imagePath = targetPath); } catch (e) { if (mounted) ToastUtil.show(context, '选择图片失败: $e'); } } Future _pickImageFromUrl() async { String? url; final confirmed = await showDialog(context: context, builder: (ctx) { final urlCtrl = TextEditingController(); 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: urlCtrl, 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), ), ), ], ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), ElevatedButton( onPressed: () { url = urlCtrl.text.trim(); 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('确定'), ), ], ); }); if (confirmed != true || url == null || url!.isEmpty) return; await _downloadImageFromUrl(url!); } Future _downloadImageFromUrl(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 = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg'; final charId = await _characterId(); final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName); await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); await File(targetPath).writeAsBytes(response.bodyBytes); if (!mounted) return; setState(() => _imagePath = targetPath); } catch (e) { debugPrint('角色图片下载失败: $e'); if (mounted) ToastUtil.show(context, '下载失败: $e'); } finally { if (mounted) setState(() => _isDownloading = false); } } Widget _buildSectionLabel(String label, ColorScheme colors) { return Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))); } Widget _buildField(String label, TextEditingController ctrl, {String hint = '', bool required = false}) { final colors = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), const SizedBox(height: 6), TextFormField( controller: ctrl, style: TextStyle(fontSize: 14, color: colors.onSurface), validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null, decoration: InputDecoration( hintText: hint, 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, ), ), ], ); } Widget _buildChipField(String label, List chips, ColorScheme colors, {required VoidCallback onTap}) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: 12, color: colors.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: colors.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), ), child: chips.isEmpty ? Text('点击添加$label', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25))) : Wrap( spacing: 4, runSpacing: 4, children: chips.map((c) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(4)), child: Text(c, style: TextStyle(fontSize: 12, color: colors.onSurface)), )).toList(), ), ), ), ], ); } bool _hasContent() { if (widget.character != null) return true; if (_nameCtrl.text.trim().isNotEmpty) return true; if (_roleCtrl.text.trim().isNotEmpty) return true; if (_descCtrl.text.trim().isNotEmpty) return true; if (_imagePath != null) return true; if (_aliases.isNotEmpty || _tags.isNotEmpty) return true; return false; } Future _confirmLeave() async { if (!_hasContent()) return true; final colors = Theme.of(context).colorScheme; final result = await 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('当前内容未保存,确定要离开吗?', 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('离开'), ), ], ), ); return result ?? false; } Future _save() async { if (!_formKey.currentState!.validate()) return; try { final now = DateTime.now(); final provider = context.read(); final desc = _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(); final role = _roleCtrl.text.trim().isEmpty ? null : _roleCtrl.text.trim(); if (widget.character == null) { // 新建 final newId = const Uuid().v4(); switch (widget.entityType) { case 'movie': await provider.addMovieCharacter(MovieCharacter( id: newId, movieId: widget.entityId, name: _nameCtrl.text.trim(), role: role, aliases: _aliases, tags: _tags, description: desc, imagePath: _imagePath, createdAt: now, updatedAt: now, )); break; case 'book': await provider.addBookCharacter(BookCharacter( id: newId, bookId: widget.entityId, name: _nameCtrl.text.trim(), role: role, aliases: _aliases, tags: _tags, description: desc, imagePath: _imagePath, createdAt: now, updatedAt: now, )); break; case 'game': await provider.addGameCharacter(GameCharacter( id: newId, gameId: widget.entityId, name: _nameCtrl.text.trim(), role: role, aliases: _aliases, tags: _tags, description: desc, imagePath: _imagePath, createdAt: now, updatedAt: now, )); break; } } else { // 编辑 final c = widget.character; switch (widget.entityType) { case 'movie': await provider.updateMovieCharacter((c as MovieCharacter).copyWith( name: _nameCtrl.text.trim(), role: role, aliases: _aliases, tags: _tags, description: desc, imagePath: _imagePath, updatedAt: now, )); break; case 'book': await provider.updateBookCharacter((c as BookCharacter).copyWith( name: _nameCtrl.text.trim(), role: role, aliases: _aliases, tags: _tags, description: desc, imagePath: _imagePath, updatedAt: now, )); break; case 'game': await provider.updateGameCharacter((c as GameCharacter).copyWith( name: _nameCtrl.text.trim(), role: role, aliases: _aliases, tags: _tags, description: desc, imagePath: _imagePath, updatedAt: now, )); break; } } if (!mounted) return; ToastUtil.show(context, widget.character == null ? '添加成功' : '更新成功'); Navigator.pop(context, true); } catch (e) { if (!mounted) return; ToastUtil.show(context, '保存失败: $e'); } } }