generated from dellevin/template
添加角色信息编辑
This commit is contained in:
553
lib/pages/character/character_form_page.dart
Normal file
553
lib/pages/character/character_form_page.dart
Normal file
@@ -0,0 +1,553 @@
|
||||
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<CharacterFormPage> createState() => _CharacterFormPageState();
|
||||
}
|
||||
|
||||
class _CharacterFormPageState extends State<CharacterFormPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _roleCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
List<String> _aliases = [];
|
||||
List<String> _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<String>.from(c.aliases);
|
||||
_tags = List<String>.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<String> _characterId() async {
|
||||
if (widget.character != null) return widget.character.id;
|
||||
return const Uuid().v4();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _pickImageFromUrl() async {
|
||||
String? url;
|
||||
final confirmed = await showDialog<bool>(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<void> _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<String> 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<bool> _confirmLeave() async {
|
||||
if (!_hasContent()) return true;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final result = 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('离开'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final provider = context.read<AppProvider>();
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
451
lib/pages/character/character_list_page.dart
Normal file
451
lib/pages/character/character_list_page.dart
Normal file
@@ -0,0 +1,451 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.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 'character_form_page.dart';
|
||||
|
||||
/// 影视角色列表页
|
||||
class MovieCharactersPage extends StatefulWidget {
|
||||
final Movie movie;
|
||||
const MovieCharactersPage({super.key, required this.movie});
|
||||
|
||||
@override
|
||||
State<MovieCharactersPage> createState() => _MovieCharactersPageState();
|
||||
}
|
||||
|
||||
class _MovieCharactersPageState extends State<MovieCharactersPage> {
|
||||
List<MovieCharacter> _characters = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
setState(() => _isLoading = true);
|
||||
final list = await context.read<AppProvider>().getMovieCharacters(widget.movie.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_characters = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm([MovieCharacter? c]) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CharacterFormPage(
|
||||
entityType: 'movie',
|
||||
entityId: widget.movie.id,
|
||||
character: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == true) _loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _delete(MovieCharacter c) async {
|
||||
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
|
||||
await ImagePathHelper.instance.deleteCharacterImages(c.id);
|
||||
}
|
||||
await context.read<AppProvider>().deleteMovieCharacter(c.id);
|
||||
_loadCharacters();
|
||||
if (mounted) ToastUtil.show(context, '已删除');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CharacterListScaffold(
|
||||
title: '角色',
|
||||
isLoading: _isLoading,
|
||||
characters: _characters,
|
||||
onAdd: () => _openForm(),
|
||||
onTap: (c) => _openForm(c),
|
||||
onDelete: (c) => _delete(c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 书籍角色列表页
|
||||
class BookCharactersPage extends StatefulWidget {
|
||||
final Book book;
|
||||
const BookCharactersPage({super.key, required this.book});
|
||||
|
||||
@override
|
||||
State<BookCharactersPage> createState() => _BookCharactersPageState();
|
||||
}
|
||||
|
||||
class _BookCharactersPageState extends State<BookCharactersPage> {
|
||||
List<BookCharacter> _characters = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
setState(() => _isLoading = true);
|
||||
final list = await context.read<AppProvider>().getBookCharacters(widget.book.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_characters = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm([BookCharacter? c]) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CharacterFormPage(
|
||||
entityType: 'book',
|
||||
entityId: widget.book.id,
|
||||
character: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == true) _loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _delete(BookCharacter c) async {
|
||||
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
|
||||
await ImagePathHelper.instance.deleteCharacterImages(c.id);
|
||||
}
|
||||
await context.read<AppProvider>().deleteBookCharacter(c.id);
|
||||
_loadCharacters();
|
||||
if (mounted) ToastUtil.show(context, '已删除');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CharacterListScaffold(
|
||||
title: '角色',
|
||||
isLoading: _isLoading,
|
||||
characters: _characters,
|
||||
onAdd: () => _openForm(),
|
||||
onTap: (c) => _openForm(c),
|
||||
onDelete: (c) => _delete(c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 游戏角色列表页
|
||||
class GameCharactersPage extends StatefulWidget {
|
||||
final Game game;
|
||||
const GameCharactersPage({super.key, required this.game});
|
||||
|
||||
@override
|
||||
State<GameCharactersPage> createState() => _GameCharactersPageState();
|
||||
}
|
||||
|
||||
class _GameCharactersPageState extends State<GameCharactersPage> {
|
||||
List<GameCharacter> _characters = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
setState(() => _isLoading = true);
|
||||
final list = await context.read<AppProvider>().getGameCharacters(widget.game.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_characters = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm([GameCharacter? c]) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CharacterFormPage(
|
||||
entityType: 'game',
|
||||
entityId: widget.game.id,
|
||||
character: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == true) _loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _delete(GameCharacter c) async {
|
||||
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
|
||||
await ImagePathHelper.instance.deleteCharacterImages(c.id);
|
||||
}
|
||||
await context.read<AppProvider>().deleteGameCharacter(c.id);
|
||||
_loadCharacters();
|
||||
if (mounted) ToastUtil.show(context, '已删除');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CharacterListScaffold(
|
||||
title: '角色',
|
||||
isLoading: _isLoading,
|
||||
characters: _characters,
|
||||
onAdd: () => _openForm(),
|
||||
onTap: (c) => _openForm(c),
|
||||
onDelete: (c) => _delete(c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用角色列表 UI(接收 dynamic 角色列表,访问 .name/.aliases/.tags/.imagePath/.description)
|
||||
class _CharacterListScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final bool isLoading;
|
||||
final List<dynamic> characters;
|
||||
final VoidCallback onAdd;
|
||||
final void Function(dynamic) onTap;
|
||||
final Future<void> Function(dynamic) onDelete;
|
||||
|
||||
const _CharacterListScaffold({
|
||||
required this.title,
|
||||
required this.isLoading,
|
||||
required this.characters,
|
||||
required this.onAdd,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(title: Text(title)),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: onAdd,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: characters.isEmpty
|
||||
? _buildEmpty(colors)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: characters.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, thickness: 0.5, color: colors.outlineVariant),
|
||||
itemBuilder: (context, index) {
|
||||
final c = characters[index];
|
||||
return _CharacterTile(
|
||||
name: c.name as String,
|
||||
aliases: c.aliases as List<String>,
|
||||
tags: c.tags as List<String>,
|
||||
description: c.description as String?,
|
||||
imagePath: c.imagePath as String?,
|
||||
onTap: () => onTap(c),
|
||||
onDelete: () => _confirmDelete(context, c),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty(ColorScheme colors) {
|
||||
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.people_outline, 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: 8),
|
||||
Text('点击右下角 + 添加角色', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, dynamic c) {
|
||||
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('确定要删除该角色吗?',
|
||||
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: () {
|
||||
Navigator.pop(ctx);
|
||||
onDelete(c);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CharacterTile extends StatelessWidget {
|
||||
final String name;
|
||||
final List<String> aliases;
|
||||
final List<String> tags;
|
||||
final String? description;
|
||||
final String? imagePath;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _CharacterTile({
|
||||
required this.name,
|
||||
required this.aliases,
|
||||
required this.tags,
|
||||
required this.description,
|
||||
required this.imagePath,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Dismissible(
|
||||
key: ValueKey(name + imagePath.toString()),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: colors.error,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: Icon(Icons.delete_outline, color: colors.onError),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
_showDeleteDialog(context);
|
||||
return false;
|
||||
},
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: _buildAvatar(colors),
|
||||
title: Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (aliases.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(aliases.join('、'),
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
),
|
||||
if (tags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: tags.map((t) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(t, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
if (description != null && description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(description!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(ColorScheme colors) {
|
||||
final hasImage = imagePath != null && imagePath!.isNotEmpty;
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasImage
|
||||
? FadeInLocalImage(path: imagePath, fit: BoxFit.cover)
|
||||
: Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name.characters.first : '?',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteDialog(BuildContext context) {
|
||||
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('确定要删除"$name"吗?',
|
||||
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: () {
|
||||
Navigator.pop(ctx);
|
||||
onDelete();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user