添加角色信息编辑

This commit is contained in:
DelLevin-Home
2026-08-09 01:22:35 +08:00
parent df8335b187
commit c95a659921
17 changed files with 2657 additions and 14 deletions

View 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');
}
}
}

View 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),
),
);
}
}