generated from dellevin/template
界面功能优化
This commit is contained in:
@@ -146,6 +146,7 @@ class _PersonListPageState extends State<PersonListPage> {
|
|||||||
|
|
||||||
Widget _buildIndexBar(ColorScheme colors) {
|
Widget _buildIndexBar(ColorScheme colors) {
|
||||||
final present = _letterFirstIndex.keys.toSet();
|
final present = _letterFirstIndex.keys.toSet();
|
||||||
|
final barKey = GlobalKey();
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: 2,
|
right: 2,
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -155,42 +156,64 @@ class _PersonListPageState extends State<PersonListPage> {
|
|||||||
final maxH = constraints.maxHeight;
|
final maxH = constraints.maxHeight;
|
||||||
const barPadding = 4.0;
|
const barPadding = 4.0;
|
||||||
final available = maxH - barPadding * 2;
|
final available = maxH - barPadding * 2;
|
||||||
final itemH = (available / _allLetters.length).clamp(8.0, 16.0);
|
final itemH = (available / _allLetters.length).clamp(12.0, 24.0);
|
||||||
|
|
||||||
|
String letterAtY(double y) {
|
||||||
|
final localY = (y - barPadding).clamp(0.0, available);
|
||||||
|
final i = (localY / itemH).floor().clamp(0, _allLetters.length - 1);
|
||||||
|
return _allLetters[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleAt(Offset globalPos) {
|
||||||
|
final robj = barKey.currentContext?.findRenderObject();
|
||||||
|
if (robj is! RenderBox) return;
|
||||||
|
final local = robj.globalToLocal(globalPos);
|
||||||
|
final letter = letterAtY(local.dy);
|
||||||
|
if (!present.contains(letter)) return;
|
||||||
|
if (_activeLetterNotifier.value != letter) {
|
||||||
|
_activeLetterNotifier.value = letter;
|
||||||
|
_jumpToLetter(letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
key: barKey,
|
||||||
padding: const EdgeInsets.symmetric(vertical: barPadding),
|
padding: const EdgeInsets.symmetric(vertical: barPadding),
|
||||||
child: ValueListenableBuilder<String>(
|
child: ValueListenableBuilder<String>(
|
||||||
valueListenable: _activeLetterNotifier,
|
valueListenable: _activeLetterNotifier,
|
||||||
builder: (context, activeLetter, _) {
|
builder: (context, activeLetter, _) {
|
||||||
return Column(
|
return GestureDetector(
|
||||||
mainAxisSize: MainAxisSize.min,
|
behavior: HitTestBehavior.opaque,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onVerticalDragDown: (d) => handleAt(d.globalPosition),
|
||||||
children: [
|
onVerticalDragUpdate: (d) => handleAt(d.globalPosition),
|
||||||
for (final letter in _allLetters)
|
onTapDown: (d) => handleAt(d.globalPosition),
|
||||||
GestureDetector(
|
child: Container(
|
||||||
behavior: HitTestBehavior.opaque,
|
width: itemH + 12,
|
||||||
onTap: () {
|
color: Colors.transparent,
|
||||||
if (!present.contains(letter)) return;
|
child: Column(
|
||||||
_activeLetterNotifier.value = letter;
|
mainAxisSize: MainAxisSize.min,
|
||||||
_jumpToLetter(letter);
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
},
|
children: [
|
||||||
child: SizedBox(
|
for (final letter in _allLetters)
|
||||||
height: itemH,
|
SizedBox(
|
||||||
width: itemH + 8,
|
height: itemH,
|
||||||
child: AnimatedDefaultTextStyle(
|
width: itemH + 12,
|
||||||
duration: const Duration(milliseconds: 150),
|
child: AnimatedDefaultTextStyle(
|
||||||
curve: Curves.easeOut,
|
duration: const Duration(milliseconds: 150),
|
||||||
style: TextStyle(
|
curve: Curves.easeOut,
|
||||||
fontSize: activeLetter == letter ? itemH * 1.1 : itemH * 0.65,
|
style: TextStyle(
|
||||||
fontWeight: activeLetter == letter ? FontWeight.w800 : FontWeight.w500,
|
fontSize: activeLetter == letter ? itemH * 0.95 : itemH * 0.7,
|
||||||
color: present.contains(letter)
|
fontWeight: activeLetter == letter ? FontWeight.w800 : FontWeight.w500,
|
||||||
? (activeLetter == letter ? colors.primary : colors.onSurface.withValues(alpha: 0.7))
|
color: present.contains(letter)
|
||||||
: colors.onSurface.withValues(alpha: 0.25),
|
? (activeLetter == letter ? colors.primary : colors.onSurface.withValues(alpha: 0.7))
|
||||||
|
: colors.onSurface.withValues(alpha: 0.25),
|
||||||
|
),
|
||||||
|
child: Text(letter, textAlign: TextAlign.center),
|
||||||
),
|
),
|
||||||
child: Text(letter, textAlign: TextAlign.center),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1506,12 +1506,38 @@ class _ProfilePageState extends State<ProfilePage> with RouteAware {
|
|||||||
provider.notes.where((n) => !n.isDeleted).toList());
|
provider.notes.where((n) => !n.isDeleted).toList());
|
||||||
}
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ToastUtil.show(context, '已导出到 ${file.path}');
|
_showExportResult(context, true, '已导出到 ${file.path}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ToastUtil.show(context, '导出失败:$e');
|
_showExportResult(context, false, '导出失败:$e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showExportResult(BuildContext context, bool success, String message) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
icon: Icon(
|
||||||
|
success ? Icons.check_circle_outline : Icons.error_outline,
|
||||||
|
color: success ? colors.primary : colors.error,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||||
|
),
|
||||||
|
actionsAlignment: MainAxisAlignment.center,
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WatchlistItem {
|
class _WatchlistItem {
|
||||||
|
|||||||
@@ -964,7 +964,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
final totalSize = imageInfo.$2 + epubInfo.$2 + tempInfo.$2 + emptyDirInfo.$2;
|
final totalSize = imageInfo.$2 + epubInfo.$2 + tempInfo.$2 + emptyDirInfo.$2;
|
||||||
final totalCount = imageInfo.$1 + epubInfo.$1 + tempInfo.$1 + emptyDirInfo.$1;
|
final totalCount = imageInfo.$1 + epubInfo.$1 + tempInfo.$1 + emptyDirInfo.$1;
|
||||||
if (totalCount == 0) {
|
if (totalCount == 0) {
|
||||||
ToastUtil.show(pageContext, '没有需要清理的缓存');
|
_showCacheResult(pageContext, true, '没有需要清理的缓存', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,19 +1044,46 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
builder: (_) => const Center(child: CircularProgressIndicator()));
|
builder: (_) => const Center(child: CircularProgressIndicator()));
|
||||||
final result = await CacheCleaner.instance.clean();
|
final result = await CacheCleaner.instance.clean();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
if (context.mounted) {
|
if (!context.mounted) return;
|
||||||
if (result.total == 0) {
|
final success = result.total > 0;
|
||||||
ToastUtil.show(context, '没有需要清理的缓存');
|
_showCacheResult(
|
||||||
} else {
|
context, true, success ? result.description : '没有需要清理的缓存', success);
|
||||||
ToastUtil.show(context, result.description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
if (context.mounted) ToastUtil.show(context, '清理失败: $e');
|
if (!context.mounted) return;
|
||||||
|
_showCacheResult(context, false, '清理失败: $e', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showCacheResult(
|
||||||
|
BuildContext context, bool success, String message, bool cleaned) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
icon: Icon(
|
||||||
|
success
|
||||||
|
? (cleaned ? Icons.check_circle_outline : Icons.info_outline)
|
||||||
|
: Icons.error_outline,
|
||||||
|
color: success ? colors.primary : colors.error,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||||
|
),
|
||||||
|
actionsAlignment: MainAxisAlignment.center,
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 直接查 DB 收集所有图片路径(含软删除记录,与 CacheCleaner 保持一致)
|
/// 直接查 DB 收集所有图片路径(含软删除记录,与 CacheCleaner 保持一致)
|
||||||
Future<Set<String>> _getAllDbImagePaths() async {
|
Future<Set<String>> _getAllDbImagePaths() async {
|
||||||
final db = await DatabaseHelper.instance.database;
|
final db = await DatabaseHelper.instance.database;
|
||||||
|
|||||||
@@ -78,11 +78,13 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
int _tabIndex = 0;
|
int _tabIndex = 0;
|
||||||
String _query = '';
|
String _query = '';
|
||||||
|
|
||||||
/// 搜索模式:0=按作品标题 1=按人物名称
|
/// 当前人物(widget.personId)在当前 Tab 类型下已关联的作品 ID 集合
|
||||||
int _searchMode = 0;
|
/// 这些作品在列表中置顶并高亮显示
|
||||||
|
Set<String> _currentPersonWorkIds = {};
|
||||||
|
|
||||||
/// 人物名称搜索结果:personId → 该人物在当前 Tab 作品类型下的关联作品 ID 集合
|
/// 搜索关键字命中的人物所关联的作品 ID 集合(当前 Tab 类型)
|
||||||
List<Person> _matchedPeople = [];
|
/// 用于"按人物名称搜索"时把这些作品也纳入结果
|
||||||
|
Set<String> _searchMatchedWorkIds = {};
|
||||||
bool _searching = false;
|
bool _searching = false;
|
||||||
|
|
||||||
static const _movieRoles = [('director', '导演'), ('writer', '编剧'), ('actor', '演员'), ('voiceActor', '配音')];
|
static const _movieRoles = [('director', '导演'), ('writer', '编剧'), ('actor', '演员'), ('voiceActor', '配音')];
|
||||||
@@ -101,17 +103,38 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
for (final gp in widget.initialGames) {
|
for (final gp in widget.initialGames) {
|
||||||
_entries.add(_WorkRoleEntry(workId: gp.gameId, workType: 'game', roleType: gp.roleType));
|
_entries.add(_WorkRoleEntry(workId: gp.gameId, workType: 'game', roleType: gp.roleType));
|
||||||
}
|
}
|
||||||
|
_loadCurrentPersonWorkIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _currentWorkType => switch (_tabIndex) { 0 => 'movie', 1 => 'book', 2 => 'game', _ => 'movie' };
|
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 };
|
List<(String, String)> get _currentRoleOptions => switch (_tabIndex) { 0 => _movieRoles, 1 => _bookRoles, 2 => _gameRoles, _ => _movieRoles };
|
||||||
|
|
||||||
/// 按人物名称搜索:找到匹配的人物,再反查他们参与的当前 Tab 类型作品
|
/// 加载当前人物在当前 Tab 类型下的作品 ID 集合
|
||||||
Future<void> _searchByPerson(String keyword) async {
|
Future<void> _loadCurrentPersonWorkIds() async {
|
||||||
final trimmed = keyword.trim();
|
final provider = context.read<AppProvider>();
|
||||||
|
Set<String> ids;
|
||||||
|
switch (_currentWorkType) {
|
||||||
|
case 'movie':
|
||||||
|
ids = (await provider.getPersonMovies(widget.personId)).map((r) => r.movieId).toSet();
|
||||||
|
case 'book':
|
||||||
|
ids = (await provider.getPersonBooks(widget.personId)).map((r) => r.bookId).toSet();
|
||||||
|
case 'game':
|
||||||
|
ids = (await provider.getPersonGames(widget.personId)).map((r) => r.gameId).toSet();
|
||||||
|
default:
|
||||||
|
ids = {};
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _currentPersonWorkIds = ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索:同时匹配作品标题与人物名称
|
||||||
|
/// 匹配人物时,反查该人物在当前 Tab 类型下的作品 ID,纳入结果
|
||||||
|
Future<void> _onSearchChanged(String v) async {
|
||||||
|
setState(() => _query = v);
|
||||||
|
final trimmed = v.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_matchedPeople = [];
|
_searchMatchedWorkIds = {};
|
||||||
_searching = false;
|
_searching = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -119,30 +142,24 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
setState(() => _searching = true);
|
setState(() => _searching = true);
|
||||||
final provider = context.read<AppProvider>();
|
final provider = context.read<AppProvider>();
|
||||||
final people = await provider.searchPeople(trimmed);
|
final people = await provider.searchPeople(trimmed);
|
||||||
|
final Set<String> matched = {};
|
||||||
|
for (final p in people) {
|
||||||
|
switch (_currentWorkType) {
|
||||||
|
case 'movie':
|
||||||
|
matched.addAll((await provider.getPersonMovies(p.id)).map((r) => r.movieId));
|
||||||
|
case 'book':
|
||||||
|
matched.addAll((await provider.getPersonBooks(p.id)).map((r) => r.bookId));
|
||||||
|
case 'game':
|
||||||
|
matched.addAll((await provider.getPersonGames(p.id)).map((r) => r.gameId));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_matchedPeople = people;
|
_searchMatchedWorkIds = matched;
|
||||||
_searching = false;
|
_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) {
|
void _changeRole(_WorkRoleEntry entry, String roleType) {
|
||||||
setState(() {
|
setState(() {
|
||||||
final idx = _entries.indexOf(entry);
|
final idx = _entries.indexOf(entry);
|
||||||
@@ -244,16 +261,14 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
children: [
|
children: [
|
||||||
// Tab 切换
|
// Tab 切换
|
||||||
_buildTabs(colors),
|
_buildTabs(colors),
|
||||||
// 搜索模式切换
|
// 搜索框(同时匹配作品标题和人物名称)
|
||||||
_buildSearchModeToggle(colors),
|
|
||||||
// 搜索框
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||||
cursorColor: colors.primary,
|
cursorColor: colors.primary,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: _searchMode == 0 ? '搜索作品标题' : '搜索人物名称',
|
hintText: '搜索作品标题或人物名称',
|
||||||
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
|
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colors.surfaceContainerHigh,
|
fillColor: colors.surfaceContainerHigh,
|
||||||
@@ -268,22 +283,19 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_query = '';
|
_query = '';
|
||||||
_matchedPeople = [];
|
_searchMatchedWorkIds = {};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
onChanged: (v) {
|
onChanged: _onSearchChanged,
|
||||||
setState(() => _query = v);
|
|
||||||
if (_searchMode == 1) {
|
|
||||||
_searchByPerson(v);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 可选作品列表
|
// 可选作品列表(当前人物作品置顶 + 高亮)
|
||||||
Expanded(child: _searchMode == 0 ? _buildAvailableList(provider, colors) : _buildPersonSearchList(provider, colors)),
|
Expanded(child: _buildAvailableList(provider, colors)),
|
||||||
|
if (_searching)
|
||||||
|
LinearProgressIndicator(minHeight: 1, backgroundColor: Colors.transparent, color: colors.primary.withValues(alpha: 0.4)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -298,7 +310,10 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
final selected = _tabIndex == i;
|
final selected = _tabIndex == i;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => setState(() { _tabIndex = i; _query = ''; _matchedPeople = []; }),
|
onTap: () {
|
||||||
|
setState(() { _tabIndex = i; _query = ''; _searchMatchedWorkIds = {}; });
|
||||||
|
_loadCurrentPersonWorkIds();
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: EdgeInsets.only(right: i < 2 ? 8 : 0),
|
margin: EdgeInsets.only(right: i < 2 ? 8 : 0),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
@@ -323,199 +338,6 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
Widget _buildRoleDropdown(_WorkRoleEntry entry, ColorScheme colors) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
@@ -602,22 +424,28 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
final workType = _currentWorkType;
|
final workType = _currentWorkType;
|
||||||
|
|
||||||
List<(String id, String title, String? coverPath)> works;
|
List<(String id, String title, String? coverPath)> works;
|
||||||
|
bool matchSearchOrTitle(String id, String title) {
|
||||||
|
if (query.isEmpty) return true;
|
||||||
|
// 标题命中,或按人物名称搜索命中的作品
|
||||||
|
return title.toLowerCase().contains(query) || _searchMatchedWorkIds.contains(id);
|
||||||
|
}
|
||||||
|
|
||||||
switch (workType) {
|
switch (workType) {
|
||||||
case 'movie':
|
case 'movie':
|
||||||
works = provider.movies
|
works = provider.movies
|
||||||
.where((m) => !m.isDeleted && (query.isEmpty || m.title.toLowerCase().contains(query)))
|
.where((m) => !m.isDeleted && matchSearchOrTitle(m.id, m.title))
|
||||||
.map((m) => (m.id, m.title, m.posterPath))
|
.map((m) => (m.id, m.title, m.posterPath))
|
||||||
.toList();
|
.toList();
|
||||||
break;
|
break;
|
||||||
case 'book':
|
case 'book':
|
||||||
works = provider.books
|
works = provider.books
|
||||||
.where((b) => !b.isDeleted && (query.isEmpty || b.title.toLowerCase().contains(query)))
|
.where((b) => !b.isDeleted && matchSearchOrTitle(b.id, b.title))
|
||||||
.map((b) => (b.id, b.title, b.coverPath))
|
.map((b) => (b.id, b.title, b.coverPath))
|
||||||
.toList();
|
.toList();
|
||||||
break;
|
break;
|
||||||
case 'game':
|
case 'game':
|
||||||
works = provider.games
|
works = provider.games
|
||||||
.where((g) => !g.isDeleted && (query.isEmpty || g.title.toLowerCase().contains(query)))
|
.where((g) => !g.isDeleted && matchSearchOrTitle(g.id, g.title))
|
||||||
.map((g) => (g.id, g.title, g.coverPath))
|
.map((g) => (g.id, g.title, g.coverPath))
|
||||||
.toList();
|
.toList();
|
||||||
break;
|
break;
|
||||||
@@ -625,6 +453,14 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
works = [];
|
works = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前人物的作品置顶,其余按标题排序
|
||||||
|
works.sort((a, b) {
|
||||||
|
final aPinned = _currentPersonWorkIds.contains(a.$1) ? 0 : 1;
|
||||||
|
final bPinned = _currentPersonWorkIds.contains(b.$1) ? 0 : 1;
|
||||||
|
if (aPinned != bPinned) return aPinned - bPinned;
|
||||||
|
return a.$2.compareTo(b.$2);
|
||||||
|
});
|
||||||
|
|
||||||
if (works.isEmpty) {
|
if (works.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -647,6 +483,7 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
final coverPath = work.$3;
|
final coverPath = work.$3;
|
||||||
final workEntries = _entries.where((e) => e.workType == _currentWorkType && e.workId == id).toList();
|
final workEntries = _entries.where((e) => e.workType == _currentWorkType && e.workId == id).toList();
|
||||||
final canAddMore = workEntries.length < _currentRoleOptions.length;
|
final canAddMore = workEntries.length < _currentRoleOptions.length;
|
||||||
|
final isCurrentPerson = _currentPersonWorkIds.contains(id);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -664,7 +501,24 @@ class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
|||||||
_buildCover(coverPath, 36, colors),
|
_buildCover(coverPath, 36, colors),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
if (isCurrentPerson) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.primary.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text('参演', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: colors.primary)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (canAddMore)
|
if (canAddMore)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
|||||||
Reference in New Issue
Block a user