generated from dellevin/template
优化新增人物功能
This commit is contained in:
File diff suppressed because one or more lines are too long
434
lib/widgets/person_info_sheet.dart
Normal file
434
lib/widgets/person_info_sheet.dart
Normal file
@@ -0,0 +1,434 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../pages/people/person_detail_page.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import 'fade_in_local_image.dart';
|
||||
|
||||
/// 人物信息浮动面板(底部 ModalBottomSheet)
|
||||
/// 展示人物基本信息 + 关联作品,点击「查看全部」跳转到原详情页
|
||||
class PersonInfoSheet extends StatefulWidget {
|
||||
final Person person;
|
||||
|
||||
const PersonInfoSheet({super.key, required this.person});
|
||||
|
||||
static Future<void> show(BuildContext context, Person person) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => PersonInfoSheet(person: person),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<PersonInfoSheet> createState() => _PersonInfoSheetState();
|
||||
}
|
||||
|
||||
class _PersonInfoSheetState extends State<PersonInfoSheet> {
|
||||
List<MoviePerson> _moviePeople = [];
|
||||
List<BookPerson> _bookPeople = [];
|
||||
List<GamePerson> _gamePeople = [];
|
||||
bool _loading = true;
|
||||
bool _summaryExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRelations();
|
||||
}
|
||||
|
||||
Future<void> _loadRelations() async {
|
||||
final provider = context.read<AppProvider>();
|
||||
final personId = widget.person.id;
|
||||
final results = await Future.wait([
|
||||
provider.getPersonMovies(personId),
|
||||
provider.getPersonBooks(personId),
|
||||
provider.getPersonGames(personId),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_moviePeople = results[0] as List<MoviePerson>;
|
||||
_bookPeople = results[1] as List<BookPerson>;
|
||||
_gamePeople = results[2] as List<GamePerson>;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final person = context.watch<AppProvider>().people
|
||||
.where((p) => p.id == widget.person.id)
|
||||
.firstOrNull ?? widget.person;
|
||||
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.8;
|
||||
|
||||
return SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 拖拽条
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.onSurface.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 顶部:头像 + 名字 + 查看全部
|
||||
_buildHeader(person, colors),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 详细信息
|
||||
if (person.gender != null) _buildInfoRow('性别', _genderLabel(person.gender!), colors),
|
||||
if (person.occupation.isNotEmpty) _buildInfoRow('职业', person.occupation.join(' / '), colors),
|
||||
if (person.birthPlace != null) _buildInfoRow('出生地', person.birthPlace!, colors),
|
||||
if (person.birthDate != null) _buildInfoRow('出生日期', _formatDate(person.birthDate!), colors),
|
||||
if (person.alternateNames.isNotEmpty) _buildInfoRow('其他名称', person.alternateNames.join('、'), colors),
|
||||
|
||||
// 简介
|
||||
if (person.summary != null && person.summary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildSectionTitle('简介', colors),
|
||||
const SizedBox(height: 8),
|
||||
_buildSummary(person.summary!, colors),
|
||||
],
|
||||
|
||||
// 作品
|
||||
if (!_loading) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionTitle('作品', colors),
|
||||
const SizedBox(height: 8),
|
||||
_buildWorksSection(colors),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(Person person, ColorScheme colors) {
|
||||
final hasPhoto = person.photoPath != null && person.photoPath!.isNotEmpty;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasPhoto
|
||||
? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover)
|
||||
: Center(
|
||||
child: Text(
|
||||
person.name.isNotEmpty ? person.name[0] : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
person.name,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (person.occupation.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
person.occupation.join(' / '),
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (_) => PersonDetailPage(person: person),
|
||||
));
|
||||
},
|
||||
icon: const Icon(Icons.arrow_outward, size: 16),
|
||||
label: const Text('详情', style: TextStyle(fontSize: 13)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colors.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
minimumSize: const Size(0, 0),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title, ColorScheme colors) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value, ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummary(String summary, ColorScheme colors) {
|
||||
const int previewLimit = 80;
|
||||
final needsToggle = summary.length > previewLimit;
|
||||
final displayText = _summaryExpanded || !needsToggle
|
||||
? summary
|
||||
: '${summary.substring(0, previewLimit)}…';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayText, style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.7)),
|
||||
if (needsToggle) ...[
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _summaryExpanded = !_summaryExpanded),
|
||||
child: Text(
|
||||
_summaryExpanded ? '收起' : '展开',
|
||||
style: TextStyle(fontSize: 12, color: colors.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorksSection(ColorScheme colors) {
|
||||
final provider = context.read<AppProvider>();
|
||||
|
||||
final movieGroups = <String, List<MoviePerson>>{};
|
||||
for (final mp in _moviePeople) {
|
||||
movieGroups.putIfAbsent(mp.movieId, () => []).add(mp);
|
||||
}
|
||||
final bookGroups = <String, List<BookPerson>>{};
|
||||
for (final bp in _bookPeople) {
|
||||
bookGroups.putIfAbsent(bp.bookId, () => []).add(bp);
|
||||
}
|
||||
final gameGroups = <String, List<GamePerson>>{};
|
||||
for (final gp in _gamePeople) {
|
||||
gameGroups.putIfAbsent(gp.gameId, () => []).add(gp);
|
||||
}
|
||||
|
||||
final totalWorks = movieGroups.length + bookGroups.length + gameGroups.length;
|
||||
if (totalWorks == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Text('暂无关联作品', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String joinRoles(List<String> roles) => roles.join(' / ');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (movieGroups.isNotEmpty) ...[
|
||||
Text('影视 (${movieGroups.length})', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
...movieGroups.entries.map((entry) {
|
||||
final movie = provider.movies.where((m) => m.id == entry.key).firstOrNull;
|
||||
if (movie == null) return const SizedBox.shrink();
|
||||
final seen = <String>{};
|
||||
final roles = <String>[];
|
||||
for (final mp in entry.value) {
|
||||
final key = '${mp.roleType}|${mp.characterName ?? ''}';
|
||||
if (!seen.add(key)) continue;
|
||||
final label = _roleTypeLabel(mp.roleType);
|
||||
if (mp.characterName != null && mp.characterName!.isNotEmpty) {
|
||||
roles.add('$label 饰 ${mp.characterName}');
|
||||
} else {
|
||||
roles.add(label);
|
||||
}
|
||||
}
|
||||
return _buildWorkItem(
|
||||
title: movie.title,
|
||||
subtitle: joinRoles(roles),
|
||||
posterPath: movie.posterPath,
|
||||
colors: colors,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (bookGroups.isNotEmpty) ...[
|
||||
Text('书籍 (${bookGroups.length})', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
...bookGroups.entries.map((entry) {
|
||||
final book = provider.books.where((b) => b.id == entry.key).firstOrNull;
|
||||
if (book == null) return const SizedBox.shrink();
|
||||
final seen = <String>{};
|
||||
final roles = <String>[];
|
||||
for (final bp in entry.value) {
|
||||
if (!seen.add(bp.roleType)) continue;
|
||||
roles.add(_roleTypeLabel(bp.roleType));
|
||||
}
|
||||
return _buildWorkItem(
|
||||
title: book.title,
|
||||
subtitle: joinRoles(roles),
|
||||
posterPath: book.coverPath,
|
||||
colors: colors,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (gameGroups.isNotEmpty) ...[
|
||||
Text('游戏 (${gameGroups.length})', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
...gameGroups.entries.map((entry) {
|
||||
final game = provider.games.where((g) => g.id == entry.key).firstOrNull;
|
||||
if (game == null) return const SizedBox.shrink();
|
||||
final seen = <String>{};
|
||||
final roles = <String>[];
|
||||
for (final gp in entry.value) {
|
||||
if (!seen.add(gp.roleType)) continue;
|
||||
roles.add(_roleTypeLabel(gp.roleType));
|
||||
}
|
||||
return _buildWorkItem(
|
||||
title: game.title,
|
||||
subtitle: joinRoles(roles),
|
||||
posterPath: game.coverPath,
|
||||
colors: colors,
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkItem({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
String? posterPath,
|
||||
required ColorScheme colors,
|
||||
}) {
|
||||
final hasPoster = posterPath != null && posterPath.isNotEmpty;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasPoster
|
||||
? FadeInLocalImage(path: posterPath, fit: BoxFit.cover)
|
||||
: Center(child: Icon(Icons.movie_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2))),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _genderLabel(String gender) {
|
||||
return switch (gender) {
|
||||
'male' => '男',
|
||||
'female' => '女',
|
||||
'other' => '其他',
|
||||
_ => gender,
|
||||
};
|
||||
}
|
||||
|
||||
String _roleTypeLabel(String roleType) {
|
||||
return switch (roleType) {
|
||||
'director' => '导演',
|
||||
'writer' => '编剧',
|
||||
'actor' => '演员',
|
||||
'author' => '作者',
|
||||
'translator' => '译者',
|
||||
'developer' => '开发者',
|
||||
_ => roleType,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.year}年${date.month.toString().padLeft(2, '0')}月${date.day.toString().padLeft(2, '0')}日';
|
||||
}
|
||||
}
|
||||
269
lib/widgets/work_people_section.dart
Normal file
269
lib/widgets/work_people_section.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import 'fade_in_local_image.dart';
|
||||
import 'person_info_sheet.dart';
|
||||
|
||||
/// 作品详情页使用的"关联人物"区块
|
||||
/// 根据 workType + workId 加载关联的 Person 列表并展示
|
||||
class WorkPeopleSection extends StatefulWidget {
|
||||
final String workId;
|
||||
final String workType; // 'movie' / 'book' / 'game'
|
||||
|
||||
const WorkPeopleSection({
|
||||
super.key,
|
||||
required this.workId,
|
||||
required this.workType,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WorkPeopleSection> createState() => _WorkPeopleSectionState();
|
||||
}
|
||||
|
||||
class _WorkPeopleSectionState extends State<WorkPeopleSection> {
|
||||
List<_PersonRole> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final provider = context.read<AppProvider>();
|
||||
// 确保 people 已加载,否则 person 查找全部失败
|
||||
if (provider.people.isEmpty) {
|
||||
await provider.loadPeople();
|
||||
}
|
||||
final people = provider.people;
|
||||
|
||||
// 按 personId 聚合:同一个人可能有多条关联(导演/编剧/演员等)
|
||||
final Map<String, _PersonRole> byPerson = {};
|
||||
void addRelation(dynamic r, {bool withCharacter = false}) {
|
||||
final personId = r.personId as String;
|
||||
final roleType = r.roleType as String;
|
||||
final person = people.where((p) => p.id == personId).firstOrNull;
|
||||
if (person == null) return;
|
||||
final existing = byPerson[personId];
|
||||
if (existing != null) {
|
||||
existing.roleTypes.add(roleType);
|
||||
if (withCharacter) {
|
||||
final c = r.characterName as String?;
|
||||
if (c != null && c.isNotEmpty) existing.characterNames.add(c);
|
||||
}
|
||||
} else {
|
||||
final characterNames = <String>[];
|
||||
if (withCharacter) {
|
||||
final c = r.characterName as String?;
|
||||
if (c != null && c.isNotEmpty) characterNames.add(c);
|
||||
}
|
||||
byPerson[personId] = _PersonRole(
|
||||
person: person,
|
||||
roleTypes: [roleType],
|
||||
characterNames: characterNames,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (widget.workType) {
|
||||
case 'movie':
|
||||
final rels = await provider.getMoviePeople(widget.workId);
|
||||
for (final r in rels) {
|
||||
addRelation(r, withCharacter: true);
|
||||
}
|
||||
break;
|
||||
case 'book':
|
||||
final rels = await provider.getBookPeople(widget.workId);
|
||||
for (final r in rels) {
|
||||
addRelation(r);
|
||||
}
|
||||
break;
|
||||
case 'game':
|
||||
final rels = await provider.getGamePeople(widget.workId);
|
||||
for (final r in rels) {
|
||||
addRelation(r);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 按 sortOrder 保留首次出现的顺序
|
||||
final items = byPerson.values.toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
String _roleLabel(String roleType) {
|
||||
return switch (roleType) {
|
||||
'director' => '导演',
|
||||
'writer' => '编剧',
|
||||
'actor' => '演员',
|
||||
'author' => '作者',
|
||||
'translator' => '译者',
|
||||
'developer' => '开发者',
|
||||
_ => roleType,
|
||||
};
|
||||
}
|
||||
|
||||
/// 角色排序权重:演员放最后
|
||||
int _roleWeight(String roleType) {
|
||||
return switch (roleType) {
|
||||
'director' => 0,
|
||||
'writer' => 1,
|
||||
'author' => 2,
|
||||
'translator' => 3,
|
||||
'developer' => 4,
|
||||
'actor' => 99,
|
||||
_ => 50,
|
||||
};
|
||||
}
|
||||
|
||||
/// 拼接角色描述:
|
||||
/// 「导演 / 编剧」
|
||||
/// 「导演 / 演员 饰 唐僧 / 演员 饰 孙悟空」
|
||||
String _buildRoleText(_PersonRole item) {
|
||||
final parts = <String>[];
|
||||
// 非演员角色:去重后按权重排序
|
||||
final nonActor = item.roleTypes.where((r) => r != 'actor').toSet().toList()
|
||||
..sort((a, b) => _roleWeight(a).compareTo(_roleWeight(b)));
|
||||
parts.addAll(nonActor.map(_roleLabel));
|
||||
|
||||
// 演员角色:每个饰演角色名单独成段
|
||||
final isActor = item.roleTypes.contains('actor');
|
||||
if (isActor) {
|
||||
if (item.characterNames.isEmpty) {
|
||||
parts.add('演员');
|
||||
} else {
|
||||
for (final c in item.characterNames) {
|
||||
parts.add('演员 饰 $c');
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (_items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.onSurface,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'人物',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _items.map((item) {
|
||||
final idx = _items.indexOf(item);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: idx == 0 ? 0 : 16),
|
||||
child: _buildPersonChip(item, colors),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPersonChip(_PersonRole item, ColorScheme colors) {
|
||||
final person = item.person;
|
||||
final hasPhoto = person.photoPath != null && person.photoPath!.isNotEmpty;
|
||||
return GestureDetector(
|
||||
onTap: () => PersonInfoSheet.show(context, person),
|
||||
child: SizedBox(
|
||||
width: 84,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasPhoto
|
||||
? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover)
|
||||
: Center(
|
||||
child: Text(
|
||||
person.name.isNotEmpty ? person.name[0] : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
person.name,
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_buildRoleText(item),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colors.onSurface.withValues(alpha: 0.4),
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonRole {
|
||||
final Person person;
|
||||
final List<String> roleTypes;
|
||||
final List<String> characterNames;
|
||||
|
||||
_PersonRole({
|
||||
required this.person,
|
||||
required this.roleTypes,
|
||||
required this.characterNames,
|
||||
});
|
||||
}
|
||||
737
lib/widgets/work_selector_page.dart
Normal file
737
lib/widgets/work_selector_page.dart
Normal file
@@ -0,0 +1,737 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import 'fade_in_local_image.dart';
|
||||
|
||||
/// 人物详情页使用的作品关联结果(按媒体类型分组)
|
||||
class WorkSelectionResult {
|
||||
final List<MoviePerson> movies;
|
||||
final List<BookPerson> books;
|
||||
final List<GamePerson> games;
|
||||
|
||||
WorkSelectionResult({required this.movies, required this.books, required this.games});
|
||||
}
|
||||
|
||||
/// 一条"作品 + 角色"选择(内部用)
|
||||
class _WorkRoleEntry {
|
||||
final String workId; // movieId / bookId / gameId
|
||||
final String workType; // 'movie' / 'book' / 'game'
|
||||
final String roleType;
|
||||
final String? characterName; // 仅影视 actor
|
||||
|
||||
_WorkRoleEntry({
|
||||
required this.workId,
|
||||
required this.workType,
|
||||
required this.roleType,
|
||||
this.characterName,
|
||||
});
|
||||
}
|
||||
|
||||
/// 作品选择独立页面(人物详情页使用)
|
||||
/// 选择影视/书籍/游戏作品并为每个作品分配 1~N 个角色
|
||||
/// 支持按作品标题或人物名称搜索
|
||||
class WorkSelectorPage extends StatefulWidget {
|
||||
final String personId;
|
||||
final List<MoviePerson> initialMovies;
|
||||
final List<BookPerson> initialBooks;
|
||||
final List<GamePerson> initialGames;
|
||||
|
||||
const WorkSelectorPage({
|
||||
super.key,
|
||||
required this.personId,
|
||||
required this.initialMovies,
|
||||
required this.initialBooks,
|
||||
required this.initialGames,
|
||||
});
|
||||
|
||||
static Future<WorkSelectionResult?> show({
|
||||
required BuildContext context,
|
||||
required String personId,
|
||||
required List<MoviePerson> initialMovies,
|
||||
required List<BookPerson> initialBooks,
|
||||
required List<GamePerson> initialGames,
|
||||
}) {
|
||||
return Navigator.push<WorkSelectionResult>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => WorkSelectorPage(
|
||||
personId: personId,
|
||||
initialMovies: initialMovies,
|
||||
initialBooks: initialBooks,
|
||||
initialGames: initialGames,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<WorkSelectorPage> createState() => _WorkSelectorPageState();
|
||||
}
|
||||
|
||||
class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
||||
/// 所有已选的"作品+角色"条目(影视/书籍/游戏混在一起,靠 workType 区分)
|
||||
final List<_WorkRoleEntry> _entries = [];
|
||||
|
||||
/// 当前 Tab:0=影视 1=书籍 2=游戏
|
||||
int _tabIndex = 0;
|
||||
String _query = '';
|
||||
|
||||
/// 搜索模式:0=按作品标题 1=按人物名称
|
||||
int _searchMode = 0;
|
||||
|
||||
/// 人物名称搜索结果:personId → 该人物在当前 Tab 作品类型下的关联作品 ID 集合
|
||||
List<Person> _matchedPeople = [];
|
||||
bool _searching = false;
|
||||
|
||||
static const _movieRoles = [('director', '导演'), ('writer', '编剧'), ('actor', '演员')];
|
||||
static const _bookRoles = [('author', '作者'), ('translator', '译者')];
|
||||
static const _gameRoles = [('developer', '开发者')];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final mp in widget.initialMovies) {
|
||||
_entries.add(_WorkRoleEntry(workId: mp.movieId, workType: 'movie', roleType: mp.roleType, characterName: mp.characterName));
|
||||
}
|
||||
for (final bp in widget.initialBooks) {
|
||||
_entries.add(_WorkRoleEntry(workId: bp.bookId, workType: 'book', roleType: bp.roleType));
|
||||
}
|
||||
for (final gp in widget.initialGames) {
|
||||
_entries.add(_WorkRoleEntry(workId: gp.gameId, workType: 'game', roleType: gp.roleType));
|
||||
}
|
||||
}
|
||||
|
||||
String get _currentWorkType => switch (_tabIndex) { 0 => 'movie', 1 => 'book', 2 => 'game', _ => 'movie' };
|
||||
List<(String, String)> get _currentRoleOptions => switch (_tabIndex) { 0 => _movieRoles, 1 => _bookRoles, 2 => _gameRoles, _ => _movieRoles };
|
||||
|
||||
/// 按人物名称搜索:找到匹配的人物,再反查他们参与的当前 Tab 类型作品
|
||||
Future<void> _searchByPerson(String keyword) async {
|
||||
final trimmed = keyword.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
setState(() {
|
||||
_matchedPeople = [];
|
||||
_searching = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() => _searching = true);
|
||||
final provider = context.read<AppProvider>();
|
||||
final people = await provider.searchPeople(trimmed);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_matchedPeople = people;
|
||||
_searching = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取人物在当前 Tab 类型下的作品 ID 集合
|
||||
Future<Set<String>> _getPersonWorkIds(Person person) async {
|
||||
final provider = context.read<AppProvider>();
|
||||
switch (_currentWorkType) {
|
||||
case 'movie':
|
||||
final rels = await provider.getPersonMovies(person.id);
|
||||
return rels.map((r) => r.movieId).toSet();
|
||||
case 'book':
|
||||
final rels = await provider.getPersonBooks(person.id);
|
||||
return rels.map((r) => r.bookId).toSet();
|
||||
case 'game':
|
||||
final rels = await provider.getPersonGames(person.id);
|
||||
return rels.map((r) => r.gameId).toSet();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void _changeRole(_WorkRoleEntry entry, String roleType) {
|
||||
setState(() {
|
||||
final idx = _entries.indexOf(entry);
|
||||
if (idx < 0) return;
|
||||
_entries[idx] = _WorkRoleEntry(
|
||||
workId: entry.workId,
|
||||
workType: entry.workType,
|
||||
roleType: roleType,
|
||||
characterName: entry.workType == 'movie' && roleType == 'actor' ? entry.characterName : null,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _editCharacterName(_WorkRoleEntry entry, String name) {
|
||||
setState(() {
|
||||
final idx = _entries.indexOf(entry);
|
||||
if (idx < 0) return;
|
||||
_entries[idx] = _WorkRoleEntry(
|
||||
workId: entry.workId,
|
||||
workType: entry.workType,
|
||||
roleType: entry.roleType,
|
||||
characterName: name.isEmpty ? null : name,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _removeEntry(_WorkRoleEntry entry) {
|
||||
setState(() => _entries.remove(entry));
|
||||
}
|
||||
|
||||
/// 为已选作品追加一个新角色条目(多角色)
|
||||
void _addRoleToWork(String workId) {
|
||||
final usedRoles = _entries
|
||||
.where((e) => e.workType == _currentWorkType && e.workId == workId)
|
||||
.map((e) => e.roleType)
|
||||
.toSet();
|
||||
final nextRole = _currentRoleOptions.firstWhere((r) => !usedRoles.contains(r.$1), orElse: () => _currentRoleOptions.first);
|
||||
setState(() {
|
||||
_entries.add(_WorkRoleEntry(
|
||||
workId: workId,
|
||||
workType: _currentWorkType,
|
||||
roleType: nextRole.$1,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
void _onConfirm() {
|
||||
final movies = _entries
|
||||
.where((e) => e.workType == 'movie')
|
||||
.map((e) => MoviePerson(
|
||||
id: const Uuid().v4(),
|
||||
movieId: e.workId,
|
||||
personId: widget.personId,
|
||||
roleType: e.roleType,
|
||||
characterName: e.characterName,
|
||||
sortOrder: 0,
|
||||
))
|
||||
.toList();
|
||||
final books = _entries
|
||||
.where((e) => e.workType == 'book')
|
||||
.map((e) => BookPerson(
|
||||
id: const Uuid().v4(),
|
||||
bookId: e.workId,
|
||||
personId: widget.personId,
|
||||
roleType: e.roleType,
|
||||
sortOrder: 0,
|
||||
))
|
||||
.toList();
|
||||
final games = _entries
|
||||
.where((e) => e.workType == 'game')
|
||||
.map((e) => GamePerson(
|
||||
id: const Uuid().v4(),
|
||||
gameId: e.workId,
|
||||
personId: widget.personId,
|
||||
roleType: e.roleType,
|
||||
sortOrder: 0,
|
||||
))
|
||||
.toList();
|
||||
Navigator.pop(context, WorkSelectionResult(movies: movies, books: books, games: games));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final provider = context.watch<AppProvider>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
title: const Text('关联作品'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _onConfirm,
|
||||
child: Text('完成', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Tab 切换
|
||||
_buildTabs(colors),
|
||||
// 搜索模式切换
|
||||
_buildSearchModeToggle(colors),
|
||||
// 搜索框
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: TextField(
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
cursorColor: colors.primary,
|
||||
decoration: InputDecoration(
|
||||
hintText: _searchMode == 0 ? '搜索作品标题' : '搜索人物名称',
|
||||
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHigh,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)),
|
||||
prefixIcon: Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
suffixIcon: _query.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_query = '';
|
||||
_matchedPeople = [];
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (v) {
|
||||
setState(() => _query = v);
|
||||
if (_searchMode == 1) {
|
||||
_searchByPerson(v);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// 可选作品列表
|
||||
Expanded(child: _searchMode == 0 ? _buildAvailableList(provider, colors) : _buildPersonSearchList(provider, colors)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabs(ColorScheme colors) {
|
||||
const labels = ['影视', '书籍', '游戏'];
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: List.generate(labels.length, (i) {
|
||||
final selected = _tabIndex == i;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() { _tabIndex = i; _query = ''; _matchedPeople = []; }),
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(right: i < 2 ? 8 : 0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? colors.primary : colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labels[i],
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchModeToggle(ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildModeChip(0, '按作品', colors),
|
||||
const SizedBox(width: 8),
|
||||
_buildModeChip(1, '按人物', colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeChip(int mode, String label, ColorScheme colors) {
|
||||
final selected = _searchMode == mode;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_searchMode = mode;
|
||||
_query = '';
|
||||
_matchedPeople = [];
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? colors.primary : colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.w500 : FontWeight.normal,
|
||||
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 按人物名称搜索结果列表
|
||||
Widget _buildPersonSearchList(AppProvider provider, ColorScheme colors) {
|
||||
if (_query.trim().isEmpty) {
|
||||
return Center(
|
||||
child: Text('输入人物名称搜索作品', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
);
|
||||
}
|
||||
if (_searching) {
|
||||
return Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.onSurface.withValues(alpha: 0.3)));
|
||||
}
|
||||
if (_matchedPeople.isEmpty) {
|
||||
return Center(
|
||||
child: Text('未找到匹配的人物', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: _matchedPeople.length,
|
||||
itemBuilder: (_, i) => _buildPersonItem(_matchedPeople[i], provider, colors),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPersonItem(Person person, AppProvider provider, ColorScheme colors) {
|
||||
return FutureBuilder<Set<String>>(
|
||||
future: _getPersonWorkIds(person),
|
||||
builder: (ctx, snapshot) {
|
||||
final workIds = snapshot.data ?? {};
|
||||
final works = _getWorksByIds(workIds, provider);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 人物名
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: colors.surface, shape: BoxShape.circle),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: person.photoPath != null && person.photoPath!.isNotEmpty
|
||||
? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover)
|
||||
: Center(
|
||||
child: Text(
|
||||
person.name.isNotEmpty ? person.name[0] : '?',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(person.name, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
if (person.occupation.isNotEmpty)
|
||||
Text(person.occupation.join(' / '),
|
||||
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (works.isEmpty)
|
||||
Text('暂无该类型作品', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
],
|
||||
),
|
||||
// 该人物在当前 Tab 下的作品列表
|
||||
...works.map((work) => _buildPersonWorkItem(work, person, colors)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据 ID 集合获取当前 Tab 类型的作品列表 (id, title, coverPath)
|
||||
List<(String, String, String?)> _getWorksByIds(Set<String> ids, AppProvider provider) {
|
||||
switch (_currentWorkType) {
|
||||
case 'movie':
|
||||
return provider.movies
|
||||
.where((m) => !m.isDeleted && ids.contains(m.id))
|
||||
.map((m) => (m.id, m.title, m.posterPath))
|
||||
.toList();
|
||||
case 'book':
|
||||
return provider.books
|
||||
.where((b) => !b.isDeleted && ids.contains(b.id))
|
||||
.map((b) => (b.id, b.title, b.coverPath))
|
||||
.toList();
|
||||
case 'game':
|
||||
return provider.games
|
||||
.where((g) => !g.isDeleted && ids.contains(g.id))
|
||||
.map((g) => (g.id, g.title, g.coverPath))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Widget _buildPersonWorkItem((String, String, String?) work, Person person, ColorScheme colors) {
|
||||
final id = work.$1;
|
||||
final title = work.$2;
|
||||
final coverPath = work.$3;
|
||||
final workEntries = _entries.where((e) => e.workType == _currentWorkType && e.workId == id).toList();
|
||||
final canAddMore = workEntries.length < _currentRoleOptions.length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_buildCover(coverPath, 32, colors),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(title, style: TextStyle(fontSize: 13, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (canAddMore)
|
||||
GestureDetector(
|
||||
onTap: () => _addRoleToWork(id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: 14, color: colors.primary),
|
||||
const SizedBox(width: 2),
|
||||
Text('角色', style: TextStyle(fontSize: 11, color: colors.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.check_circle, size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
],
|
||||
),
|
||||
...workEntries.map((entry) => _buildRoleRow(entry, colors)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoleDropdown(_WorkRoleEntry entry, ColorScheme colors) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: DropdownButton<String>(
|
||||
value: entry.roleType,
|
||||
underline: const SizedBox.shrink(),
|
||||
isDense: true,
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface),
|
||||
items: _roleOptionsFor(entry.workType)
|
||||
.map((r) => DropdownMenuItem(value: r.$1, child: Text(r.$2, style: const TextStyle(fontSize: 12))))
|
||||
.toList(),
|
||||
onChanged: (v) { if (v != null) _changeRole(entry, v); },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<(String, String)> _roleOptionsFor(String workType) {
|
||||
return switch (workType) { 'movie' => _movieRoles, 'book' => _bookRoles, 'game' => _gameRoles, _ => _movieRoles };
|
||||
}
|
||||
|
||||
Widget _buildCover(String? path, double size, ColorScheme colors) {
|
||||
final has = path != null && path.isNotEmpty;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
width: size * 0.72,
|
||||
height: size,
|
||||
child: has
|
||||
? FadeInLocalImage(path: path, fit: BoxFit.cover)
|
||||
: Container(color: colors.surfaceContainerHighest, child: Icon(Icons.movie_outlined, size: 12, color: colors.onSurface.withValues(alpha: 0.2))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCharacterNameDialog(_WorkRoleEntry entry, String? current) {
|
||||
final ctrl = TextEditingController(text: current ?? '');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final colors = Theme.of(ctx).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colors.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('饰演角色', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
style: TextStyle(fontSize: 15, color: colors.onSurface),
|
||||
cursorColor: colors.primary,
|
||||
decoration: InputDecoration(
|
||||
hintText: '如:关羽',
|
||||
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHigh,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)),
|
||||
),
|
||||
onSubmitted: (v) { Navigator.pop(ctx); _editCharacterName(entry, v.trim()); },
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(
|
||||
onPressed: () { Navigator.pop(ctx); _editCharacterName(entry, ctrl.text.trim()); },
|
||||
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvailableList(AppProvider provider, ColorScheme colors) {
|
||||
final query = _query.toLowerCase();
|
||||
final workType = _currentWorkType;
|
||||
|
||||
List<(String id, String title, String? coverPath)> works;
|
||||
switch (workType) {
|
||||
case 'movie':
|
||||
works = provider.movies
|
||||
.where((m) => !m.isDeleted && (query.isEmpty || m.title.toLowerCase().contains(query)))
|
||||
.map((m) => (m.id, m.title, m.posterPath))
|
||||
.toList();
|
||||
break;
|
||||
case 'book':
|
||||
works = provider.books
|
||||
.where((b) => !b.isDeleted && (query.isEmpty || b.title.toLowerCase().contains(query)))
|
||||
.map((b) => (b.id, b.title, b.coverPath))
|
||||
.toList();
|
||||
break;
|
||||
case 'game':
|
||||
works = provider.games
|
||||
.where((g) => !g.isDeleted && (query.isEmpty || g.title.toLowerCase().contains(query)))
|
||||
.map((g) => (g.id, g.title, g.coverPath))
|
||||
.toList();
|
||||
break;
|
||||
default:
|
||||
works = [];
|
||||
}
|
||||
|
||||
if (works.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
query.isEmpty ? '暂无可选作品' : '无匹配结果',
|
||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: works.length,
|
||||
itemBuilder: (_, i) => _buildAvailableItem(works[i], colors),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvailableItem((String id, String title, String? coverPath) work, ColorScheme colors) {
|
||||
final id = work.$1;
|
||||
final title = work.$2;
|
||||
final coverPath = work.$3;
|
||||
final workEntries = _entries.where((e) => e.workType == _currentWorkType && e.workId == id).toList();
|
||||
final canAddMore = workEntries.length < _currentRoleOptions.length;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 作品标题行
|
||||
Row(
|
||||
children: [
|
||||
_buildCover(coverPath, 36, colors),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (canAddMore)
|
||||
GestureDetector(
|
||||
onTap: () => _addRoleToWork(id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: 14, color: colors.primary),
|
||||
const SizedBox(width: 2),
|
||||
Text('角色', style: TextStyle(fontSize: 11, color: colors.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.check_circle, size: 18, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
],
|
||||
),
|
||||
// 已选角色行
|
||||
...workEntries.map((entry) => _buildRoleRow(entry, colors)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 角色行:职业下拉 + 角色名(影视演员)+ 编辑 + 删除
|
||||
Widget _buildRoleRow(_WorkRoleEntry entry, ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 46),
|
||||
child: Row(
|
||||
children: [
|
||||
// 职业标签/下拉
|
||||
_buildRoleDropdown(entry, colors),
|
||||
const SizedBox(width: 8),
|
||||
// 角色名(仅影视演员)
|
||||
if (entry.workType == 'movie' && entry.roleType == 'actor') ...[
|
||||
GestureDetector(
|
||||
onTap: () => _showCharacterNameDialog(entry, entry.characterName),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(6), border: Border.all(color: colors.outlineVariant, width: 0.5)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
entry.characterName != null && entry.characterName!.isNotEmpty ? '饰 ${entry.characterName}' : '设置角色',
|
||||
style: TextStyle(fontSize: 12, color: entry.characterName != null ? colors.onSurface : colors.onSurface.withValues(alpha: 0.3)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.edit, size: 12, color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
const Spacer(),
|
||||
// 删除
|
||||
GestureDetector(
|
||||
onTap: () => _removeEntry(entry),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user