diff --git a/lib/data/character/book_character_dao.dart b/lib/data/character/book_character_dao.dart new file mode 100644 index 0000000..93ac124 --- /dev/null +++ b/lib/data/character/book_character_dao.dart @@ -0,0 +1,85 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 书籍角色数据访问对象 +class BookCharacterDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[BookCharacterDao] $op error: $e'); + rethrow; + } + } + + /// 获取书籍的所有角色 + Future> getByBookId(String bookId) => _wrap('getByBookId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'book_characters', + where: 'book_id = ? AND is_deleted = 0', + whereArgs: [bookId], + orderBy: 'sort_order, created_at', + ); + return maps.map((m) => BookCharacter.fromJson(m)).toList(); + }); + + /// 根据ID获取角色 + Future getById(String id) => _wrap('getById', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'book_characters', + where: 'id = ? AND is_deleted = 0', + whereArgs: [id], + ); + if (maps.isEmpty) return null; + return BookCharacter.fromJson(maps.first); + }); + + /// 添加角色 + Future insert(BookCharacter character) => _wrap('insert', () async { + final db = await _dbHelper.database; + return await db.insert('book_characters', character.toJson()); + }); + + /// 更新角色 + Future update(BookCharacter character) => _wrap('update', () async { + final db = await _dbHelper.database; + return await db.update( + 'book_characters', + character.toJson(), + where: 'id = ?', + whereArgs: [character.id], + ); + }); + + /// 软删除角色 + Future delete(String id) => _wrap('delete', () async { + final db = await _dbHelper.database; + return await db.update( + 'book_characters', + {'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + /// 获取书籍的角色数量 + Future getCount(String bookId) => _wrap('getCount', () async { + final db = await _dbHelper.database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM book_characters WHERE book_id = ? AND is_deleted = 0', + [bookId], + ); + return result.first['count'] as int? ?? 0; + }); + + /// 彻底删除角色 + Future permanentDelete(String id) => _wrap('permanentDelete', () async { + final db = await _dbHelper.database; + await db.delete('book_characters', where: 'id = ?', whereArgs: [id]); + }); +} diff --git a/lib/data/character/game_character_dao.dart b/lib/data/character/game_character_dao.dart new file mode 100644 index 0000000..07aa831 --- /dev/null +++ b/lib/data/character/game_character_dao.dart @@ -0,0 +1,85 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 游戏角色数据访问对象 +class GameCharacterDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[GameCharacterDao] $op error: $e'); + rethrow; + } + } + + /// 获取游戏的所有角色 + Future> getByGameId(String gameId) => _wrap('getByGameId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'game_characters', + where: 'game_id = ? AND is_deleted = 0', + whereArgs: [gameId], + orderBy: 'sort_order, created_at', + ); + return maps.map((m) => GameCharacter.fromJson(m)).toList(); + }); + + /// 根据ID获取角色 + Future getById(String id) => _wrap('getById', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'game_characters', + where: 'id = ? AND is_deleted = 0', + whereArgs: [id], + ); + if (maps.isEmpty) return null; + return GameCharacter.fromJson(maps.first); + }); + + /// 添加角色 + Future insert(GameCharacter character) => _wrap('insert', () async { + final db = await _dbHelper.database; + return await db.insert('game_characters', character.toJson()); + }); + + /// 更新角色 + Future update(GameCharacter character) => _wrap('update', () async { + final db = await _dbHelper.database; + return await db.update( + 'game_characters', + character.toJson(), + where: 'id = ?', + whereArgs: [character.id], + ); + }); + + /// 软删除角色 + Future delete(String id) => _wrap('delete', () async { + final db = await _dbHelper.database; + return await db.update( + 'game_characters', + {'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + /// 获取游戏的角色数量 + Future getCount(String gameId) => _wrap('getCount', () async { + final db = await _dbHelper.database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM game_characters WHERE game_id = ? AND is_deleted = 0', + [gameId], + ); + return result.first['count'] as int? ?? 0; + }); + + /// 彻底删除角色 + Future permanentDelete(String id) => _wrap('permanentDelete', () async { + final db = await _dbHelper.database; + await db.delete('game_characters', where: 'id = ?', whereArgs: [id]); + }); +} diff --git a/lib/data/character/movie_character_dao.dart b/lib/data/character/movie_character_dao.dart new file mode 100644 index 0000000..1fcff6a --- /dev/null +++ b/lib/data/character/movie_character_dao.dart @@ -0,0 +1,85 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 影视角色数据访问对象 +class MovieCharacterDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[MovieCharacterDao] $op error: $e'); + rethrow; + } + } + + /// 获取影视的所有角色 + Future> getByMovieId(String movieId) => _wrap('getByMovieId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'movie_characters', + where: 'movie_id = ? AND is_deleted = 0', + whereArgs: [movieId], + orderBy: 'sort_order, created_at', + ); + return maps.map((m) => MovieCharacter.fromJson(m)).toList(); + }); + + /// 根据ID获取角色 + Future getById(String id) => _wrap('getById', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'movie_characters', + where: 'id = ? AND is_deleted = 0', + whereArgs: [id], + ); + if (maps.isEmpty) return null; + return MovieCharacter.fromJson(maps.first); + }); + + /// 添加角色 + Future insert(MovieCharacter character) => _wrap('insert', () async { + final db = await _dbHelper.database; + return await db.insert('movie_characters', character.toJson()); + }); + + /// 更新角色 + Future update(MovieCharacter character) => _wrap('update', () async { + final db = await _dbHelper.database; + return await db.update( + 'movie_characters', + character.toJson(), + where: 'id = ?', + whereArgs: [character.id], + ); + }); + + /// 软删除角色 + Future delete(String id) => _wrap('delete', () async { + final db = await _dbHelper.database; + return await db.update( + 'movie_characters', + {'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + /// 获取影视的角色数量 + Future getCount(String movieId) => _wrap('getCount', () async { + final db = await _dbHelper.database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM movie_characters WHERE movie_id = ? AND is_deleted = 0', + [movieId], + ); + return result.first['count'] as int? ?? 0; + }); + + /// 彻底删除角色 + Future permanentDelete(String id) => _wrap('permanentDelete', () async { + final db = await _dbHelper.database; + await db.delete('movie_characters', where: 'id = ?', whereArgs: [id]); + }); +} diff --git a/lib/data/database_helper.dart b/lib/data/database_helper.dart index d6ac2dd..fd1649a 100644 --- a/lib/data/database_helper.dart +++ b/lib/data/database_helper.dart @@ -81,7 +81,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 39, + version: 40, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -405,6 +405,14 @@ class DatabaseHelper { // 创建人物表和关联表 await _createPeopleTables(db); } + if (oldVersion < 40) { + // 创建角色表 + await _createCharacterTables(db); + // 早期 v40 迭代可能已建表但缺列,补齐缺失列 + await _ensureCharacterColumns(db, 'movie_characters'); + await _ensureCharacterColumns(db, 'book_characters'); + await _ensureCharacterColumns(db, 'game_characters'); + } } Future _upgradeBooksTableV26(Database db) async { final columns = await db.rawQuery('PRAGMA table_info(books)'); @@ -1040,6 +1048,8 @@ class DatabaseHelper { // 人物表 await _createPeopleTables(db); + // 角色表 + await _createCharacterTables(db); } /// 创建人物表和关联表 @@ -1107,6 +1117,85 @@ class DatabaseHelper { await db.execute('CREATE INDEX IF NOT EXISTS idx_game_people_person ON game_people(person_id)'); } + /// 创建角色表(影视/书籍/游戏) + Future _createCharacterTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS movie_characters ( + id TEXT PRIMARY KEY, + movie_id TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT, + aliases TEXT DEFAULT '[]', + tags TEXT DEFAULT '[]', + description TEXT, + image_path TEXT, + sort_order INTEGER DEFAULT 0, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_movie_characters_movie ON movie_characters(movie_id)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS book_characters ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT, + aliases TEXT DEFAULT '[]', + tags TEXT DEFAULT '[]', + description TEXT, + image_path TEXT, + sort_order INTEGER DEFAULT 0, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_book_characters_book ON book_characters(book_id)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS game_characters ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT, + aliases TEXT DEFAULT '[]', + tags TEXT DEFAULT '[]', + description TEXT, + image_path TEXT, + sort_order INTEGER DEFAULT 0, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_game_characters_game ON game_characters(game_id)'); + } + + /// 补齐角色表缺失的列(早期 v40 迭代建表时可能未包含) + Future _ensureCharacterColumns(Database db, String table) async { + final columns = await db.rawQuery('PRAGMA table_info($table)'); + final names = columns.map((c) => c['name'] as String).toSet(); + const additions = { + 'role': 'TEXT', + 'aliases': "TEXT DEFAULT '[]'", + 'tags': "TEXT DEFAULT '[]'", + 'description': 'TEXT', + 'image_path': 'TEXT', + 'sort_order': 'INTEGER DEFAULT 0', + 'is_deleted': 'INTEGER DEFAULT 0', + 'created_at': "TEXT DEFAULT ''", + 'updated_at': "TEXT DEFAULT ''", + }; + for (final entry in additions.entries) { + if (!names.contains(entry.key)) { + await db.execute('ALTER TABLE $table ADD COLUMN ${entry.key} ${entry.value}'); + } + } + } + // 关闭数据库 Future close() async { if (_database != null) { diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 07744a5..9633cb6 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -1365,3 +1365,321 @@ class GamePerson { } } +/// 影视角色模型 +class MovieCharacter { + final String id; + final String movieId; + final String name; + final String? role; + final List aliases; + final List tags; + final String? description; + final String? imagePath; + final int sortOrder; + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + MovieCharacter({ + required this.id, + required this.movieId, + required this.name, + this.role, + this.aliases = const [], + this.tags = const [], + this.description, + this.imagePath, + this.sortOrder = 0, + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory MovieCharacter.fromJson(Map json) { + return MovieCharacter( + id: json['id']?.toString() ?? '', + movieId: json['movie_id']?.toString() ?? '', + name: json['name'] ?? '', + role: json['role'], + aliases: parseStringListGeneric(json['aliases']), + tags: parseStringListGeneric(json['tags']), + description: json['description'], + imagePath: json['image_path'], + sortOrder: json['sort_order'] ?? 0, + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, + ); + } + + Map toJson() { + return { + 'id': id, + 'movie_id': movieId, + 'name': name, + 'role': role, + 'aliases': jsonEncode(aliases), + 'tags': jsonEncode(tags), + 'description': description, + 'image_path': imagePath, + 'sort_order': sortOrder, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + }; + } + + MovieCharacter copyWith({ + String? id, + String? movieId, + String? name, + Object? role = _copyWithNull, + List? aliases, + List? tags, + Object? description = _copyWithNull, + Object? imagePath = _copyWithNull, + int? sortOrder, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MovieCharacter( + id: id ?? this.id, + movieId: movieId ?? this.movieId, + name: name ?? this.name, + role: role is _CopyWithNullSentinel ? this.role : (role as String?), + aliases: aliases ?? this.aliases, + tags: tags ?? this.tags, + description: description is _CopyWithNullSentinel ? this.description : (description as String?), + imagePath: imagePath is _CopyWithNullSentinel ? this.imagePath : (imagePath as String?), + sortOrder: sortOrder ?? this.sortOrder, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + File? get imageFile { + if (imagePath == null || imagePath!.isEmpty) return null; + return File(imagePath!); + } + + String get summary { + if (description == null || description!.isEmpty) return ''; + if (description!.length <= 50) return description!; + return '${description!.substring(0, 50)}...'; + } +} + +/// 书籍角色模型 +class BookCharacter { + final String id; + final String bookId; + final String name; + final String? role; + final List aliases; + final List tags; + final String? description; + final String? imagePath; + final int sortOrder; + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + BookCharacter({ + required this.id, + required this.bookId, + required this.name, + this.role, + this.aliases = const [], + this.tags = const [], + this.description, + this.imagePath, + this.sortOrder = 0, + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory BookCharacter.fromJson(Map json) { + return BookCharacter( + id: json['id']?.toString() ?? '', + bookId: json['book_id']?.toString() ?? '', + name: json['name'] ?? '', + role: json['role'], + aliases: parseStringListGeneric(json['aliases']), + tags: parseStringListGeneric(json['tags']), + description: json['description'], + imagePath: json['image_path'], + sortOrder: json['sort_order'] ?? 0, + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, + ); + } + + Map toJson() { + return { + 'id': id, + 'book_id': bookId, + 'name': name, + 'role': role, + 'aliases': jsonEncode(aliases), + 'tags': jsonEncode(tags), + 'description': description, + 'image_path': imagePath, + 'sort_order': sortOrder, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + }; + } + + BookCharacter copyWith({ + String? id, + String? bookId, + String? name, + Object? role = _copyWithNull, + List? aliases, + List? tags, + Object? description = _copyWithNull, + Object? imagePath = _copyWithNull, + int? sortOrder, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return BookCharacter( + id: id ?? this.id, + bookId: bookId ?? this.bookId, + name: name ?? this.name, + role: role is _CopyWithNullSentinel ? this.role : (role as String?), + aliases: aliases ?? this.aliases, + tags: tags ?? this.tags, + description: description is _CopyWithNullSentinel ? this.description : (description as String?), + imagePath: imagePath is _CopyWithNullSentinel ? this.imagePath : (imagePath as String?), + sortOrder: sortOrder ?? this.sortOrder, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + File? get imageFile { + if (imagePath == null || imagePath!.isEmpty) return null; + return File(imagePath!); + } + + String get summary { + if (description == null || description!.isEmpty) return ''; + if (description!.length <= 50) return description!; + return '${description!.substring(0, 50)}...'; + } +} + +/// 游戏角色模型 +class GameCharacter { + final String id; + final String gameId; + final String name; + final String? role; + final List aliases; + final List tags; + final String? description; + final String? imagePath; + final int sortOrder; + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + GameCharacter({ + required this.id, + required this.gameId, + required this.name, + this.role, + this.aliases = const [], + this.tags = const [], + this.description, + this.imagePath, + this.sortOrder = 0, + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory GameCharacter.fromJson(Map json) { + return GameCharacter( + id: json['id']?.toString() ?? '', + gameId: json['game_id']?.toString() ?? '', + name: json['name'] ?? '', + role: json['role'], + aliases: parseStringListGeneric(json['aliases']), + tags: parseStringListGeneric(json['tags']), + description: json['description'], + imagePath: json['image_path'], + sortOrder: json['sort_order'] ?? 0, + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, + ); + } + + Map toJson() { + return { + 'id': id, + 'game_id': gameId, + 'name': name, + 'role': role, + 'aliases': jsonEncode(aliases), + 'tags': jsonEncode(tags), + 'description': description, + 'image_path': imagePath, + 'sort_order': sortOrder, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + }; + } + + GameCharacter copyWith({ + String? id, + String? gameId, + String? name, + Object? role = _copyWithNull, + List? aliases, + List? tags, + Object? description = _copyWithNull, + Object? imagePath = _copyWithNull, + int? sortOrder, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return GameCharacter( + id: id ?? this.id, + gameId: gameId ?? this.gameId, + name: name ?? this.name, + role: role is _CopyWithNullSentinel ? this.role : (role as String?), + aliases: aliases ?? this.aliases, + tags: tags ?? this.tags, + description: description is _CopyWithNullSentinel ? this.description : (description as String?), + imagePath: imagePath is _CopyWithNullSentinel ? this.imagePath : (imagePath as String?), + sortOrder: sortOrder ?? this.sortOrder, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + File? get imageFile { + if (imagePath == null || imagePath!.isEmpty) return null; + return File(imagePath!); + } + + String get summary { + if (description == null || description!.isEmpty) return ''; + if (description!.length <= 50) return description!; + return '${description!.substring(0, 50)}...'; + } +} + diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index f3a0838..90288b8 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -16,9 +16,12 @@ import '../../utils/image_path_helper.dart'; import '../../utils/responsive.dart'; import '../../widgets/genre_selector_page.dart'; import '../../widgets/work_people_section.dart'; +import '../../widgets/character_preview_section.dart'; +import '../../widgets/character_info_sheet.dart'; import 'book_reviews_page.dart'; import 'book_excerpts_page.dart'; import 'book_share_page.dart'; +import '../character/character_list_page.dart'; import '../../data/epub/reader_dao.dart'; import '../epub_reader/epub_highlights_page.dart'; import '../epub_reader/reader_screen.dart'; @@ -44,6 +47,9 @@ class _BookDetailPageState extends State { final ValueNotifier _showTitle = ValueNotifier(false); ScrollController? _overlayScrollController; + // ─── 角色预览 ─── + List _characters = []; + // ─── 编辑模式 ─── bool _isEditing = false; final _editFormKey = GlobalKey(); @@ -84,6 +90,13 @@ class _BookDetailPageState extends State { _detailStyle = UserPrefs().detailPageStyle; _coverOffset.value = UserPrefs().getCoverOffset(widget.book.id); _initEditControllers(); + _loadCharacters(); + } + + Future _loadCharacters() async { + final list = await context.read().getBookCharacters(widget.book.id); + if (!mounted) return; + setState(() => _characters = list); } void _initEditControllers() { @@ -268,6 +281,10 @@ class _BookDetailPageState extends State { Expanded(child: Text('${book.readCount} 次', style: TextStyle(fontSize: 13, color: colors.onSurface))), ]), ], + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + ), WorkPeopleSection(workId: book.id, workType: 'book'), if (book.summary != null && book.summary!.isNotEmpty) ...[ Divider(height: 32, thickness: 0.5, color: colors.outline), @@ -312,6 +329,15 @@ class _BookDetailPageState extends State { unit: '条句读', onTap: () => _navigateToEpubHighlights(book), ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getBookCharacterCount(book.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(book), + ), ], ), ), @@ -808,6 +834,10 @@ class _BookDetailPageState extends State { if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book), if (book.publishDate != null) _buildPublishDateSection(book), if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book), + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + ), WorkPeopleSection(workId: book.id, workType: 'book'), if (book.summary != null && book.summary!.isNotEmpty) _buildSummarySection(book), Divider(height: 0.5, thickness: 0.5, color: colors.outline), @@ -911,6 +941,11 @@ class _BookDetailPageState extends State { if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book), // 类型标签毛玻璃 if (book.genres.isNotEmpty) _buildGenresSection(book), + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + isOverlay: true, + ), // 关联人物 WorkPeopleSection(workId: book.id, workType: 'book'), // 简介:内部已有毛玻璃卡片 @@ -996,6 +1031,14 @@ class _BookDetailPageState extends State { foregroundColor: colors.onPrimary, ), const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.people_outline, + onPressed: () => _navigateToCharacters(book), + tooltip: '角色', + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + ), + const SizedBox(height: 12), _buildFloatingButton( icon: Icons.delete_outline, onPressed: () => _showDeleteDialog(context), @@ -1866,6 +1909,15 @@ class _BookDetailPageState extends State { unit: '条句读', onTap: () => _navigateToEpubHighlights(book), ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getBookCharacterCount(book.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(book), + ), ], ), ); @@ -1904,6 +1956,15 @@ class _BookDetailPageState extends State { unit: '条句读', onTap: () => _navigateToEpubHighlights(book), ), + const SizedBox(height: 12), + _buildFrostedExtraItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getBookCharacterCount(book.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(book), + ), ], ), ); @@ -2052,6 +2113,25 @@ class _BookDetailPageState extends State { ); } + void _navigateToCharacters(Book book) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookCharactersPage(book: book), + ), + ).then((_) => _loadCharacters()); + } + + Future _openCharacterSheet(dynamic character) async { + final needRefresh = await CharacterInfoSheet.show( + context, + entityType: 'book', + entityId: widget.book.id, + character: character, + ); + if (needRefresh == true) _loadCharacters(); + } + /// 获取关联 EPUB 的句读(高亮)数量 Future _getEpubHighlightCount(String bookId) async { final readerBook = await ReaderDao().getReaderBookByBookId(bookId); diff --git a/lib/pages/character/character_form_page.dart b/lib/pages/character/character_form_page.dart new file mode 100644 index 0000000..a92a0ce --- /dev/null +++ b/lib/pages/character/character_form_page.dart @@ -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 createState() => _CharacterFormPageState(); +} + +class _CharacterFormPageState extends State { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _roleCtrl = TextEditingController(); + final _descCtrl = TextEditingController(); + final ImagePicker _picker = ImagePicker(); + + List _aliases = []; + List _tags = []; + String? _imagePath; + bool _isDownloading = false; + + @override + void initState() { + super.initState(); + if (widget.character != null) { + final c = widget.character; + _nameCtrl.text = c.name; + _roleCtrl.text = c.role ?? ''; + _descCtrl.text = c.description ?? ''; + _aliases = List.from(c.aliases); + _tags = List.from(c.tags); + _imagePath = c.imagePath; + } + } + + @override + void dispose() { + _nameCtrl.dispose(); + _roleCtrl.dispose(); + _descCtrl.dispose(); + super.dispose(); + } + + String get _entityLabel => switch (widget.entityType) { + 'movie' => '影视', + 'book' => '书籍', + 'game' => '游戏', + _ => '作品', + }; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final isEdit = widget.character != null; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmLeave(); + if (shouldPop && context.mounted) Navigator.pop(context); + }, + child: Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: Text(isEdit ? '编辑角色' : '添加角色'), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: FilledButton( + onPressed: _save, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('保存'), + ), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + children: [ + Center(child: _buildImagePicker(colors)), + const SizedBox(height: 24), + + _buildField('名称', _nameCtrl, hint: '角色名称', required: true), + const SizedBox(height: 16), + + _buildField('角色定位', _roleCtrl, hint: '如:男主、女主、反派、配角'), + const SizedBox(height: 16), + + _buildChipField('别名', _aliases, colors, onTap: () async { + final result = await GenreSelectorPage.show( + context: context, + title: '添加别名', + existingTags: [], + initialSelected: _aliases, + hint: '如:曾用名、英文名', + ); + if (result != null) setState(() => _aliases = result); + }), + const SizedBox(height: 16), + + _buildChipField('标签', _tags, colors, onTap: () async { + final result = await GenreSelectorPage.show( + context: context, + title: '添加标签', + existingTags: [], + initialSelected: _tags, + hint: '如:主角、反派', + ); + if (result != null) setState(() => _tags = result); + }), + const SizedBox(height: 16), + + _buildSectionLabel('角色简介', colors), + const SizedBox(height: 6), + Container( + constraints: const BoxConstraints(minHeight: 120), + child: TextFormField( + controller: _descCtrl, + maxLines: null, + style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6), + decoration: InputDecoration( + hintText: '写下角色简介...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, + fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.all(12), + ), + ), + ), + const SizedBox(height: 48), + ], + ), + ), + ), + ); + } + + Widget _buildImagePicker(ColorScheme colors) { + final hasImage = _imagePath != null && _imagePath!.isNotEmpty; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _showImageOptions, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: Stack( + alignment: Alignment.center, + children: [ + if (hasImage) + FadeInLocalImage(path: _imagePath, fit: BoxFit.cover) + else + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_add_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)), + const SizedBox(height: 4), + Text('添加图片', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ], + ), + if (_isDownloading) + Container( + color: Colors.black.withValues(alpha: 0.4), + child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ], + ), + ), + ), + if (hasImage) + Padding( + padding: const EdgeInsets.only(top: 8), + child: GestureDetector( + onTap: () => setState(() => _imagePath = null), + child: Text('移除图片', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), + ), + ), + ], + ); + } + + void _showImageOptions() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align(alignment: Alignment.centerLeft, + child: Text('添加图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface))), + ), + const SizedBox(height: 16), + ListTile( + leading: Icon(Icons.photo_library_outlined, color: colors.onSurface.withValues(alpha: 0.6)), + title: Text('从相册选择', style: TextStyle(color: colors.onSurface)), + onTap: () { Navigator.pop(ctx); _pickImage(); }, + ), + ListTile( + leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)), + title: Text('网络链接', style: TextStyle(color: colors.onSurface)), + onTap: () { Navigator.pop(ctx); _pickImageFromUrl(); }, + ), + ], + ), + ), + ), + ); + } + + Future _characterId() async { + if (widget.character != null) return widget.character.id; + return const Uuid().v4(); + } + + Future _pickImage() async { + try { + final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 600, maxHeight: 600, imageQuality: 85); + if (picked == null) return; + final fileName = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final charId = await _characterId(); + final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(picked.path).copy(targetPath); + if (mounted) setState(() => _imagePath = targetPath); + } catch (e) { + if (mounted) ToastUtil.show(context, '选择图片失败: $e'); + } + } + + Future _pickImageFromUrl() async { + String? url; + final confirmed = await showDialog(context: context, builder: (ctx) { + final urlCtrl = TextEditingController(); + final colors = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 12), + TextField( + controller: urlCtrl, + keyboardType: TextInputType.url, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: 'https://example.com/image.jpg', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () { url = urlCtrl.text.trim(); Navigator.pop(ctx, true); }, + style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('确定'), + ), + ], + ); + }); + if (confirmed != true || url == null || url!.isEmpty) return; + await _downloadImageFromUrl(url!); + } + + Future _downloadImageFromUrl(String url) async { + setState(() => _isDownloading = true); + try { + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Referer': Uri.parse(url).replace(path: '/').toString(), + }, + ); + + if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}'); + + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片'); + if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大'); + + final fileName = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final charId = await _characterId(); + final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(targetPath).writeAsBytes(response.bodyBytes); + + if (!mounted) return; + setState(() => _imagePath = targetPath); + } catch (e) { + debugPrint('角色图片下载失败: $e'); + if (mounted) ToastUtil.show(context, '下载失败: $e'); + } finally { + if (mounted) setState(() => _isDownloading = false); + } + } + + Widget _buildSectionLabel(String label, ColorScheme colors) { + return Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))); + } + + Widget _buildField(String label, TextEditingController ctrl, {String hint = '', bool required = false}) { + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + TextFormField( + controller: ctrl, + style: TextStyle(fontSize: 14, color: colors.onSurface), + validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null, + decoration: InputDecoration( + hintText: hint, hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + isDense: true, + ), + ), + ], + ); + } + + Widget _buildChipField(String label, List chips, ColorScheme colors, {required VoidCallback onTap}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: chips.isEmpty + ? Text('点击添加$label', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25))) + : Wrap( + spacing: 4, runSpacing: 4, + children: chips.map((c) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(4)), + child: Text(c, style: TextStyle(fontSize: 12, color: colors.onSurface)), + )).toList(), + ), + ), + ), + ], + ); + } + + bool _hasContent() { + if (widget.character != null) return true; + if (_nameCtrl.text.trim().isNotEmpty) return true; + if (_roleCtrl.text.trim().isNotEmpty) return true; + if (_descCtrl.text.trim().isNotEmpty) return true; + if (_imagePath != null) return true; + if (_aliases.isNotEmpty || _tags.isNotEmpty) return true; + return false; + } + + Future _confirmLeave() async { + if (!_hasContent()) return true; + final colors = Theme.of(context).colorScheme; + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('未保存', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('当前内容未保存,确定要离开吗?', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('离开'), + ), + ], + ), + ); + return result ?? false; + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + try { + final now = DateTime.now(); + final provider = context.read(); + final desc = _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(); + final role = _roleCtrl.text.trim().isEmpty ? null : _roleCtrl.text.trim(); + + if (widget.character == null) { + // 新建 + final newId = const Uuid().v4(); + switch (widget.entityType) { + case 'movie': + await provider.addMovieCharacter(MovieCharacter( + id: newId, + movieId: widget.entityId, + name: _nameCtrl.text.trim(), + role: role, + aliases: _aliases, + tags: _tags, + description: desc, + imagePath: _imagePath, + createdAt: now, + updatedAt: now, + )); + break; + case 'book': + await provider.addBookCharacter(BookCharacter( + id: newId, + bookId: widget.entityId, + name: _nameCtrl.text.trim(), + role: role, + aliases: _aliases, + tags: _tags, + description: desc, + imagePath: _imagePath, + createdAt: now, + updatedAt: now, + )); + break; + case 'game': + await provider.addGameCharacter(GameCharacter( + id: newId, + gameId: widget.entityId, + name: _nameCtrl.text.trim(), + role: role, + aliases: _aliases, + tags: _tags, + description: desc, + imagePath: _imagePath, + createdAt: now, + updatedAt: now, + )); + break; + } + } else { + // 编辑 + final c = widget.character; + switch (widget.entityType) { + case 'movie': + await provider.updateMovieCharacter((c as MovieCharacter).copyWith( + name: _nameCtrl.text.trim(), + role: role, + aliases: _aliases, + tags: _tags, + description: desc, + imagePath: _imagePath, + updatedAt: now, + )); + break; + case 'book': + await provider.updateBookCharacter((c as BookCharacter).copyWith( + name: _nameCtrl.text.trim(), + role: role, + aliases: _aliases, + tags: _tags, + description: desc, + imagePath: _imagePath, + updatedAt: now, + )); + break; + case 'game': + await provider.updateGameCharacter((c as GameCharacter).copyWith( + name: _nameCtrl.text.trim(), + role: role, + aliases: _aliases, + tags: _tags, + description: desc, + imagePath: _imagePath, + updatedAt: now, + )); + break; + } + } + + if (!mounted) return; + ToastUtil.show(context, widget.character == null ? '添加成功' : '更新成功'); + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + ToastUtil.show(context, '保存失败: $e'); + } + } +} diff --git a/lib/pages/character/character_list_page.dart b/lib/pages/character/character_list_page.dart new file mode 100644 index 0000000..364d696 --- /dev/null +++ b/lib/pages/character/character_list_page.dart @@ -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 createState() => _MovieCharactersPageState(); +} + +class _MovieCharactersPageState extends State { + List _characters = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCharacters(); + } + + Future _loadCharacters() async { + setState(() => _isLoading = true); + final list = await context.read().getMovieCharacters(widget.movie.id); + if (!mounted) return; + setState(() { + _characters = list; + _isLoading = false; + }); + } + + Future _openForm([MovieCharacter? c]) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CharacterFormPage( + entityType: 'movie', + entityId: widget.movie.id, + character: c, + ), + ), + ); + if (result == true) _loadCharacters(); + } + + Future _delete(MovieCharacter c) async { + if (c.imagePath != null && c.imagePath!.isNotEmpty) { + await ImagePathHelper.instance.deleteCharacterImages(c.id); + } + await context.read().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 createState() => _BookCharactersPageState(); +} + +class _BookCharactersPageState extends State { + List _characters = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCharacters(); + } + + Future _loadCharacters() async { + setState(() => _isLoading = true); + final list = await context.read().getBookCharacters(widget.book.id); + if (!mounted) return; + setState(() { + _characters = list; + _isLoading = false; + }); + } + + Future _openForm([BookCharacter? c]) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CharacterFormPage( + entityType: 'book', + entityId: widget.book.id, + character: c, + ), + ), + ); + if (result == true) _loadCharacters(); + } + + Future _delete(BookCharacter c) async { + if (c.imagePath != null && c.imagePath!.isNotEmpty) { + await ImagePathHelper.instance.deleteCharacterImages(c.id); + } + await context.read().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 createState() => _GameCharactersPageState(); +} + +class _GameCharactersPageState extends State { + List _characters = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCharacters(); + } + + Future _loadCharacters() async { + setState(() => _isLoading = true); + final list = await context.read().getGameCharacters(widget.game.id); + if (!mounted) return; + setState(() { + _characters = list; + _isLoading = false; + }); + } + + Future _openForm([GameCharacter? c]) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CharacterFormPage( + entityType: 'game', + entityId: widget.game.id, + character: c, + ), + ), + ); + if (result == true) _loadCharacters(); + } + + Future _delete(GameCharacter c) async { + if (c.imagePath != null && c.imagePath!.isNotEmpty) { + await ImagePathHelper.instance.deleteCharacterImages(c.id); + } + await context.read().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 characters; + final VoidCallback onAdd; + final void Function(dynamic) onTap; + final Future 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, + tags: c.tags as List, + 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 aliases; + final List 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), + ), + ); + } +} diff --git a/lib/pages/game/game_detail_page.dart b/lib/pages/game/game_detail_page.dart index 83caafc..f2746be 100644 --- a/lib/pages/game/game_detail_page.dart +++ b/lib/pages/game/game_detail_page.dart @@ -16,9 +16,12 @@ import '../../utils/image_path_helper.dart'; import '../../utils/responsive.dart'; import '../../widgets/genre_selector_page.dart'; import '../../widgets/work_people_section.dart'; +import '../../widgets/character_preview_section.dart'; +import '../../widgets/character_info_sheet.dart'; import 'game_reviews_page.dart'; import 'game_screenshots_page.dart'; import 'game_share_page.dart'; +import '../character/character_list_page.dart'; /// 游戏详情页 - 极简主义设计 class GameDetailPage extends StatefulWidget { @@ -42,6 +45,9 @@ class _GameDetailPageState extends State { final ValueNotifier _showTitle = ValueNotifier(false); ScrollController? _overlayScrollController; + // ─── 角色预览 ─── + List _characters = []; + // ─── 编辑模式 ─── bool _isEditing = false; final _editFormKey = GlobalKey(); @@ -71,6 +77,13 @@ class _GameDetailPageState extends State { _coverOffset.value = UserPrefs().getCoverOffset(widget.game.id); _initEditControllers(); _detectCoverAspect(); + _loadCharacters(); + } + + Future _loadCharacters() async { + final list = await context.read().getGameCharacters(widget.game.id); + if (!mounted) return; + setState(() => _characters = list); } void _initEditControllers() { @@ -273,6 +286,10 @@ class _GameDetailPageState extends State { _buildDesktopInfoRow('购买时间', _formatDate(game.purchaseDate!), colors), if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) _buildDesktopInfoRow('购买价格', game.purchasePrice!, colors), + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + ), WorkPeopleSection(workId: game.id, workType: 'game'), if (game.summary != null && game.summary!.isNotEmpty) ...[ Divider(height: 32, thickness: 0.5, color: colors.outline), @@ -308,6 +325,15 @@ class _GameDetailPageState extends State { unit: '张截图', onTap: () => _navigateToScreenshots(game), ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getGameCharacterCount(game.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(game), + ), ], ), ), @@ -999,6 +1025,10 @@ class _GameDetailPageState extends State { _buildInfoSection('购买时间', _formatDate(game.purchaseDate!)), if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) _buildInfoSection('购买价格', game.purchasePrice!), + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + ), WorkPeopleSection(workId: game.id, workType: 'game'), if (game.summary != null && game.summary!.isNotEmpty) _buildInfoSection('游戏简介', game.summary!), @@ -1149,6 +1179,11 @@ class _GameDetailPageState extends State { _buildOverlayInfoRow('购买时间', _formatDate(game.purchaseDate!)), if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) _buildOverlayInfoRow('购买价格', game.purchasePrice!), + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + isOverlay: true, + ), // 关联人物 WorkPeopleSection(workId: game.id, workType: 'game'), if (game.summary != null && game.summary!.isNotEmpty) ...[ @@ -1289,6 +1324,15 @@ class _GameDetailPageState extends State { unit: '张截图', onTap: () => _navigateToScreenshots(game), ), + const SizedBox(height: 12), + _buildFrostedExtraItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getGameCharacterCount(game.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(game), + ), ], ), ); @@ -1357,6 +1401,14 @@ class _GameDetailPageState extends State { foregroundColor: colors.onPrimary, ), const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.people_outline, + onPressed: () => _navigateToCharacters(game), + tooltip: '角色', + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + ), + const SizedBox(height: 12), _buildFloatingButton( icon: Icons.delete_outline, onPressed: () => _showDeleteDialog(context), @@ -1721,6 +1773,15 @@ class _GameDetailPageState extends State { unit: '张截图', onTap: () => _navigateToScreenshots(game), ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getGameCharacterCount(game.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(game), + ), ], ), ); @@ -1790,6 +1851,21 @@ class _GameDetailPageState extends State { Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreenshotsPage(game: game))); } + void _navigateToCharacters(Game game) { + Navigator.push(context, MaterialPageRoute(builder: (_) => GameCharactersPage(game: game))) + .then((_) => _loadCharacters()); + } + + Future _openCharacterSheet(dynamic character) async { + final needRefresh = await CharacterInfoSheet.show( + context, + entityType: 'game', + entityId: widget.game.id, + character: character, + ); + if (needRefresh == true) _loadCharacters(); + } + void _showStylePicker() { final colors = Theme.of(context).colorScheme; final currentStyle = UserPrefs().detailPageStyle; diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index b6b2cee..e8268dc 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -16,9 +16,12 @@ import '../../utils/image_path_helper.dart'; import '../../utils/responsive.dart'; import '../../widgets/genre_selector_page.dart'; import '../../widgets/work_people_section.dart'; +import '../../widgets/character_preview_section.dart'; +import '../../widgets/character_info_sheet.dart'; import 'movie_reviews_page.dart'; import 'movie_posters_page.dart'; import 'movie_share_page.dart'; +import '../character/character_list_page.dart'; /// 影视详情页 - 极简主义设计 class MovieDetailPage extends StatefulWidget { @@ -42,6 +45,9 @@ class _MovieDetailPageState extends State { final ValueNotifier _showTitle = ValueNotifier(false); ScrollController? _overlayScrollController; + // ─── 角色预览 ─── + List _characters = []; + // ─── 编辑模式 ─── bool _isEditing = false; final _editFormKey = GlobalKey(); @@ -68,6 +74,13 @@ class _MovieDetailPageState extends State { _detailStyle = UserPrefs().detailPageStyle; _posterOffset.value = UserPrefs().getCoverOffset(widget.movie.id); _initEditControllers(); + _loadCharacters(); + } + + Future _loadCharacters() async { + final list = await context.read().getMovieCharacters(widget.movie.id); + if (!mounted) return; + setState(() => _characters = list); } void _initEditControllers() { @@ -268,6 +281,10 @@ class _MovieDetailPageState extends State { )), ]), ], + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + ), WorkPeopleSection(workId: movie.id, workType: 'movie'), if (movie.summary != null && movie.summary!.isNotEmpty) ...[ Divider(height: 32, thickness: 0.5, color: colors.outline), @@ -304,6 +321,15 @@ class _MovieDetailPageState extends State { unit: '张海报', onTap: () => _navigateToPosters(movie), ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getMovieCharacterCount(movie.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(movie), + ), ], ), ), @@ -934,6 +960,10 @@ class _MovieDetailPageState extends State { _buildActorsSection(movie), if (movie.genres.isNotEmpty) _buildGenresSection(movie), + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + ), WorkPeopleSection(workId: movie.id, workType: 'movie'), if (movie.summary != null && movie.summary!.isNotEmpty) _buildSummarySection(movie), @@ -1066,7 +1096,11 @@ class _MovieDetailPageState extends State { const SizedBox(height: 12), _buildGenresSection(movie), ], - const SizedBox(height: 12), + CharacterPreviewSection( + characters: _characters, + onTap: _openCharacterSheet, + isOverlay: true, + ), // 关联人物 WorkPeopleSection(workId: movie.id, workType: 'movie'), const SizedBox(height: 12), @@ -1202,6 +1236,14 @@ class _MovieDetailPageState extends State { foregroundColor: colors.onPrimary, ), const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.people_outline, + onPressed: () => _navigateToCharacters(movie), + tooltip: '角色', + backgroundColor: colors.secondaryContainer, + foregroundColor: colors.onSecondaryContainer, + ), + const SizedBox(height: 12), _buildFloatingButton( icon: Icons.delete_outline, onPressed: () => _showDeleteDialog(context), @@ -1811,6 +1853,15 @@ class _MovieDetailPageState extends State { unit: '张海报', onTap: () => _navigateToPosters(movie), ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getMovieCharacterCount(movie.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(movie), + ), ], ), ); @@ -1839,6 +1890,15 @@ class _MovieDetailPageState extends State { unit: '张海报', onTap: () => _navigateToPosters(movie), ), + const SizedBox(height: 12), + _buildFrostedExtraItem( + icon: Icons.people_outline, + title: '角色', + subtitleFuture: context.read().getMovieCharacterCount(movie.id), + emptyText: '暂无角色', + unit: '个角色', + onTap: () => _navigateToCharacters(movie), + ), ], ), ); @@ -1987,6 +2047,25 @@ class _MovieDetailPageState extends State { ); } + void _navigateToCharacters(Movie movie) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MovieCharactersPage(movie: movie), + ), + ).then((_) => _loadCharacters()); + } + + Future _openCharacterSheet(dynamic character) async { + final needRefresh = await CharacterInfoSheet.show( + context, + entityType: 'movie', + entityId: widget.movie.id, + character: character, + ); + if (needRefresh == true) _loadCharacters(); + } + String _formatDate(DateTime date) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f85cdcd..8c8e94e 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -19,6 +19,9 @@ import '../data/person/person_dao.dart'; import '../data/person/movie_person_dao.dart'; import '../data/person/book_person_dao.dart'; import '../data/person/game_person_dao.dart'; +import '../data/character/movie_character_dao.dart'; +import '../data/character/book_character_dao.dart'; +import '../data/character/game_character_dao.dart'; import '../data/database_helper.dart'; import '../utils/image_path_helper.dart'; import '../utils/user_prefs.dart'; @@ -44,6 +47,9 @@ class AppProvider extends ChangeNotifier { final MoviePersonDao _moviePersonDao = MoviePersonDao(); final BookPersonDao _bookPersonDao = BookPersonDao(); final GamePersonDao _gamePersonDao = GamePersonDao(); + final MovieCharacterDao _movieCharacterDao = MovieCharacterDao(); + final BookCharacterDao _bookCharacterDao = BookCharacterDao(); + final GameCharacterDao _gameCharacterDao = GameCharacterDao(); // 数据列表 List _movies = []; List _books = []; @@ -1449,4 +1455,78 @@ class AppProvider extends ChangeNotifier { await loadPeople(); return (newPersons: newPersons, newRelations: newRelations, merged: merged); } + + // ========== 角色相关方法 ========== + + // ─── 影视角色 ─── + Future> getMovieCharacters(String movieId) async { + return await _movieCharacterDao.getByMovieId(movieId); + } + + Future getMovieCharacterCount(String movieId) async { + return await _movieCharacterDao.getCount(movieId); + } + + Future addMovieCharacter(MovieCharacter character) async { + await _movieCharacterDao.insert(character); + notifyListeners(); + } + + Future updateMovieCharacter(MovieCharacter character) async { + await _movieCharacterDao.update(character); + notifyListeners(); + } + + Future deleteMovieCharacter(String id) async { + await _movieCharacterDao.delete(id); + notifyListeners(); + } + + // ─── 书籍角色 ─── + Future> getBookCharacters(String bookId) async { + return await _bookCharacterDao.getByBookId(bookId); + } + + Future getBookCharacterCount(String bookId) async { + return await _bookCharacterDao.getCount(bookId); + } + + Future addBookCharacter(BookCharacter character) async { + await _bookCharacterDao.insert(character); + notifyListeners(); + } + + Future updateBookCharacter(BookCharacter character) async { + await _bookCharacterDao.update(character); + notifyListeners(); + } + + Future deleteBookCharacter(String id) async { + await _bookCharacterDao.delete(id); + notifyListeners(); + } + + // ─── 游戏角色 ─── + Future> getGameCharacters(String gameId) async { + return await _gameCharacterDao.getByGameId(gameId); + } + + Future getGameCharacterCount(String gameId) async { + return await _gameCharacterDao.getCount(gameId); + } + + Future addGameCharacter(GameCharacter character) async { + await _gameCharacterDao.insert(character); + notifyListeners(); + } + + Future updateGameCharacter(GameCharacter character) async { + await _gameCharacterDao.update(character); + notifyListeners(); + } + + Future deleteGameCharacter(String id) async { + await _gameCharacterDao.delete(id); + notifyListeners(); + } } diff --git a/lib/services/sync/backup_service.dart b/lib/services/sync/backup_service.dart index 8350232..1fecfd9 100644 --- a/lib/services/sync/backup_service.dart +++ b/lib/services/sync/backup_service.dart @@ -75,6 +75,11 @@ class BackupService { final moviePeople = await db.query('movie_people'); final bookPeople = await db.query('book_people'); final gamePeople = await db.query('game_people'); + final playlists = await db.query('playlists'); + final playlistItems = await db.query('playlist_items'); + final movieCharacters = await db.query('movie_characters'); + final bookCharacters = await db.query('book_characters'); + final gameCharacters = await db.query('game_characters'); // 收集图片路径 final imagePaths = {}; @@ -117,6 +122,10 @@ class BackupService { final pp = p['photo_path'] as String?; if (pp != null && pp.isNotEmpty) imagePaths.add(pp); } + for (final c in [...movieCharacters, ...bookCharacters, ...gameCharacters]) { + final ip = c['image_path'] as String?; + if (ip != null && ip.isNotEmpty) imagePaths.add(ip); + } final userPrefs = UserPrefs(); final userInfo = { @@ -153,6 +162,11 @@ class BackupService { 'movie_people': moviePeople, 'book_people': bookPeople, 'game_people': gamePeople, + 'playlists': playlists, + 'playlist_items': playlistItems, + 'movie_characters': movieCharacters, + 'book_characters': bookCharacters, + 'game_characters': gameCharacters, }, }; @@ -436,6 +450,11 @@ class BackupService { final moviePeopleCols = await _getTableColumns(db, 'movie_people'); final bookPeopleCols = await _getTableColumns(db, 'book_people'); final gamePeopleCols = await _getTableColumns(db, 'game_people'); + final playlistsCols = await _getTableColumns(db, 'playlists'); + final playlistItemsCols = await _getTableColumns(db, 'playlist_items'); + final movieCharactersCols = await _getTableColumns(db, 'movie_characters'); + final bookCharactersCols = await _getTableColumns(db, 'book_characters'); + final gameCharactersCols = await _getTableColumns(db, 'game_characters'); await db.transaction((txn) async { await txn.delete('movie_reviews'); @@ -449,6 +468,11 @@ class BackupService { await txn.delete('book_people'); await txn.delete('game_people'); await txn.delete('people'); + await txn.delete('playlist_items'); + await txn.delete('playlists'); + await txn.delete('movie_characters'); + await txn.delete('book_characters'); + await txn.delete('game_characters'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); @@ -527,6 +551,51 @@ class BackupService { ); } } + if (data.containsKey('people')) { + for (final p in data['people'] as List) { + await txn.insert('people', _updateImagePath(_convertToDbMapSafe(p, peopleCols), 'photo_path', imagePathMap)); + } + } + if (data.containsKey('movie_people')) { + for (final mp in data['movie_people'] as List) { + await txn.insert('movie_people', _convertToDbMapSafe(mp, moviePeopleCols)); + } + } + if (data.containsKey('book_people')) { + for (final bp in data['book_people'] as List) { + await txn.insert('book_people', _convertToDbMapSafe(bp, bookPeopleCols)); + } + } + if (data.containsKey('game_people')) { + for (final gp in data['game_people'] as List) { + await txn.insert('game_people', _convertToDbMapSafe(gp, gamePeopleCols)); + } + } + if (data.containsKey('playlists')) { + for (final pl in data['playlists'] as List) { + await txn.insert('playlists', _updateImagePath(_convertToDbMapSafe(pl, playlistsCols), 'cover_path', imagePathMap)); + } + } + if (data.containsKey('playlist_items')) { + for (final pi in data['playlist_items'] as List) { + await txn.insert('playlist_items', _convertToDbMapSafe(pi, playlistItemsCols)); + } + } + if (data.containsKey('movie_characters')) { + for (final c in data['movie_characters'] as List) { + await txn.insert('movie_characters', _updateImagePath(_convertToDbMapSafe(c, movieCharactersCols), 'image_path', imagePathMap)); + } + } + if (data.containsKey('book_characters')) { + for (final c in data['book_characters'] as List) { + await txn.insert('book_characters', _updateImagePath(_convertToDbMapSafe(c, bookCharactersCols), 'image_path', imagePathMap)); + } + } + if (data.containsKey('game_characters')) { + for (final c in data['game_characters'] as List) { + await txn.insert('game_characters', _updateImagePath(_convertToDbMapSafe(c, gameCharactersCols), 'image_path', imagePathMap)); + } + } }); // 恢复用户信息 @@ -600,6 +669,11 @@ class BackupService { final moviePeopleCols = await _getTableColumns(db, 'movie_people'); final bookPeopleCols = await _getTableColumns(db, 'book_people'); final gamePeopleCols = await _getTableColumns(db, 'game_people'); + final playlistsCols = await _getTableColumns(db, 'playlists'); + final playlistItemsCols = await _getTableColumns(db, 'playlist_items'); + final movieCharactersCols = await _getTableColumns(db, 'movie_characters'); + final bookCharactersCols = await _getTableColumns(db, 'book_characters'); + final gameCharactersCols = await _getTableColumns(db, 'game_characters'); await db.transaction((txn) async { await txn.delete('movie_reviews'); @@ -613,6 +687,11 @@ class BackupService { await txn.delete('book_people'); await txn.delete('game_people'); await txn.delete('people'); + await txn.delete('playlist_items'); + await txn.delete('playlists'); + await txn.delete('movie_characters'); + await txn.delete('book_characters'); + await txn.delete('game_characters'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); @@ -691,6 +770,51 @@ class BackupService { ); } } + if (data.containsKey('people')) { + for (final p in data['people'] as List) { + await txn.insert('people', _updateImagePath(_convertToDbMapSafe(p, peopleCols), 'photo_path', imagePathMap)); + } + } + if (data.containsKey('movie_people')) { + for (final mp in data['movie_people'] as List) { + await txn.insert('movie_people', _convertToDbMapSafe(mp, moviePeopleCols)); + } + } + if (data.containsKey('book_people')) { + for (final bp in data['book_people'] as List) { + await txn.insert('book_people', _convertToDbMapSafe(bp, bookPeopleCols)); + } + } + if (data.containsKey('game_people')) { + for (final gp in data['game_people'] as List) { + await txn.insert('game_people', _convertToDbMapSafe(gp, gamePeopleCols)); + } + } + if (data.containsKey('playlists')) { + for (final pl in data['playlists'] as List) { + await txn.insert('playlists', _updateImagePath(_convertToDbMapSafe(pl, playlistsCols), 'cover_path', imagePathMap)); + } + } + if (data.containsKey('playlist_items')) { + for (final pi in data['playlist_items'] as List) { + await txn.insert('playlist_items', _convertToDbMapSafe(pi, playlistItemsCols)); + } + } + if (data.containsKey('movie_characters')) { + for (final c in data['movie_characters'] as List) { + await txn.insert('movie_characters', _updateImagePath(_convertToDbMapSafe(c, movieCharactersCols), 'image_path', imagePathMap)); + } + } + if (data.containsKey('book_characters')) { + for (final c in data['book_characters'] as List) { + await txn.insert('book_characters', _updateImagePath(_convertToDbMapSafe(c, bookCharactersCols), 'image_path', imagePathMap)); + } + } + if (data.containsKey('game_characters')) { + for (final c in data['game_characters'] as List) { + await txn.insert('game_characters', _updateImagePath(_convertToDbMapSafe(c, gameCharactersCols), 'image_path', imagePathMap)); + } + } }); await _restoreUserInfo(backupData, imagePathMap); @@ -778,6 +902,13 @@ class BackupService { if (data.containsKey('games')) stats['游戏'] = (data['games'] as List).length; if (data.containsKey('game_reviews')) stats['游戏评价'] = (data['game_reviews'] as List).length; if (data.containsKey('game_screenshots')) stats['游戏截图'] = (data['game_screenshots'] as List).length; + if (data.containsKey('people')) stats['人物'] = (data['people'] as List).length; + if (data.containsKey('playlists')) stats['片单'] = (data['playlists'] as List).length; + int charCount = 0; + for (final key in ['movie_characters', 'book_characters', 'game_characters']) { + if (data.containsKey(key)) charCount += (data[key] as List).length; + } + if (charCount > 0) stats['角色'] = charCount; if (imageCount > 0) stats['图片'] = imageCount; return stats; } diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index f0f1da6..32b4d0b 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -13,6 +13,7 @@ import '../pages/note/note_detail_page.dart'; import '../pages/game/game_detail_page.dart'; import '../pages/movies/douban_webview_page.dart'; import '../pages/people/person_form_page.dart'; +import '../pages/character/character_form_page.dart'; /// 路由生成器 class AppRouter { @@ -95,6 +96,16 @@ class AppRouter { final Person? person = args is Person ? args : null; return SlideUpPageRoute(page: PersonFormPage(person: person)); + case '/character-form': + final args = settings.arguments as Map; + return SlideUpPageRoute( + page: CharacterFormPage( + entityType: args['entityType'] as String, + entityId: args['entityId'] as String, + character: args['character'], + ), + ); + default: return _buildUnknownRoute(settings.name); } diff --git a/lib/utils/image_path_helper.dart b/lib/utils/image_path_helper.dart index 2859ff9..33c7828 100644 --- a/lib/utils/image_path_helper.dart +++ b/lib/utils/image_path_helper.dart @@ -147,6 +147,29 @@ class ImagePathHelper { return p.join(dir, fileName); } + // ==================== 角色相关路径 ==================== + + /// 获取角色图片目录 + /// 路径: images/characters/{characterId}/ + Future getCharacterImagesDir(String characterId) async { + final root = await imagesRoot; + return p.join(root, 'characters', characterId); + } + + /// 获取角色图片路径 + /// 路径: images/characters/{characterId}/{fileName} + Future getCharacterImagePath(String characterId, String fileName) async { + final dir = await getCharacterImagesDir(characterId); + return p.join(dir, fileName); + } + + /// 删除角色图片目录 + /// 删除路径: images/characters/{characterId}/ + Future deleteCharacterImages(String characterId) async { + final dirPath = await getCharacterImagesDir(characterId); + await _deleteDirectory(dirPath); + } + // ==================== 目录操作 ==================== /// 确保目录存在 diff --git a/lib/widgets/character_info_sheet.dart b/lib/widgets/character_info_sheet.dart new file mode 100644 index 0000000..c23f60b --- /dev/null +++ b/lib/widgets/character_info_sheet.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import '../pages/character/character_form_page.dart'; +import 'fade_in_local_image.dart'; + +/// 角色信息底部弹窗 +/// +/// 展示角色详情,提供编辑入口。 +/// [entityType] = 'movie' / 'book' / 'game' +/// [entityId] = 所属作品 ID +/// [character] = MovieCharacter / BookCharacter / GameCharacter +class CharacterInfoSheet extends StatefulWidget { + final String entityType; + final String entityId; + final dynamic character; + + const CharacterInfoSheet({ + super.key, + required this.entityType, + required this.entityId, + required this.character, + }); + + static Future show( + BuildContext context, { + required String entityType, + required String entityId, + required dynamic character, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => CharacterInfoSheet( + entityType: entityType, + entityId: entityId, + character: character, + ), + ); + } + + @override + State createState() => _CharacterInfoSheetState(); +} + +class _CharacterInfoSheetState extends State { + bool _summaryExpanded = false; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final c = widget.character; + final name = c.name as String; + final role = c.role as String?; + final aliases = c.aliases as List; + final tags = c.tags as List; + final description = c.description as String?; + final imagePath = c.imagePath as String?; + + final maxHeight = MediaQuery.of(context).size.height * 0.7; + + 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(name, role, imagePath, colors), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (aliases.isNotEmpty) + _buildInfoRow('别名', aliases.join('、'), colors), + if (tags.isNotEmpty) + _buildInfoRow('标签', tags.join(' | '), colors), + if (description != null && description.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildSectionTitle('简介', colors), + const SizedBox(height: 8), + _buildSummary(description, colors), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(String name, String? role, String? imagePath, ColorScheme colors) { + final hasImage = imagePath != null && imagePath.isNotEmpty; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 56, + height: 56, + 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: 22, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.3), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (role != null && role.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + role, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + TextButton.icon( + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CharacterFormPage( + entityType: widget.entityType, + entityId: widget.entityId, + character: widget.character, + ), + ), + ); + if (result == true && mounted) { + Navigator.pop(context, true); + } + }, + icon: const Icon(Icons.edit_outlined, 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: 56, + 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), + ), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/character_preview_section.dart b/lib/widgets/character_preview_section.dart new file mode 100644 index 0000000..95a3bab --- /dev/null +++ b/lib/widgets/character_preview_section.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'fade_in_local_image.dart'; + +/// 角色卡片横向预览组件 +/// +/// 在影视/书籍/游戏详情页的角色入口上方展示。 +/// 空列表返回 SizedBox.shrink(),不占空间。 +class CharacterPreviewSection extends StatelessWidget { + final List characters; + final void Function(dynamic character) onTap; + final bool isOverlay; + + const CharacterPreviewSection({ + super.key, + required this.characters, + required this.onTap, + this.isOverlay = false, + }); + + @override + Widget build(BuildContext context) { + if (characters.isEmpty) return const SizedBox.shrink(); + + final colors = Theme.of(context).colorScheme; + final titleColor = isOverlay ? Colors.white : colors.onSurface; + + 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: titleColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + '角色', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + ], + ), + const SizedBox(height: 20), + ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0x00FFFFFF), + Color(0xFFFFFFFF), + Color(0xFFFFFFFF), + Color(0x00FFFFFF), + ], + stops: [0.0, 0.04, 0.96, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: SizedBox( + height: 132, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: characters.length, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (context, index) { + return _CharacterCard( + character: characters[index], + onTap: () => onTap(characters[index]), + isOverlay: isOverlay, + ); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _CharacterCard extends StatelessWidget { + final dynamic character; + final VoidCallback onTap; + final bool isOverlay; + + const _CharacterCard({ + required this.character, + required this.onTap, + required this.isOverlay, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final name = character.name as String; + final role = character.role as String?; + final aliases = character.aliases as List; + final tags = character.tags as List; + final description = character.description as String?; + final imagePath = character.imagePath as String?; + + final cardColor = isOverlay + ? Colors.white.withValues(alpha: 0.08) + : colors.surfaceContainerHigh; + final borderColor = isOverlay + ? Colors.white.withValues(alpha: 0.12) + : colors.outlineVariant; + final primaryText = isOverlay ? Colors.white : colors.onSurface; + final secondaryText = isOverlay + ? Colors.white.withValues(alpha: 0.5) + : colors.onSurface.withValues(alpha: 0.4); + final tagText = isOverlay + ? Colors.white.withValues(alpha: 0.7) + : colors.onSurface.withValues(alpha: 0.6); + final avatarBg = isOverlay + ? Colors.white.withValues(alpha: 0.1) + : colors.surfaceContainerHighest; + + return GestureDetector( + onTap: onTap, + child: Container( + width: 220, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 第一行:头像 + 名称 + 角色定位 + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: avatarBg, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: imagePath != null && imagePath.isNotEmpty + ? FadeInLocalImage(path: imagePath, fit: BoxFit.cover) + : Center( + child: Text( + name.isNotEmpty ? name.characters.first : '?', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: secondaryText, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: primaryText, + ), + ), + if (role != null && role.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 1), + child: Text( + role, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: secondaryText), + ), + ), + ], + ), + ), + ], + ), + // 第二行:标签用 | 分割 + if (tags.isNotEmpty || aliases.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + [...tags, ...aliases].join(' | '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: tagText, height: 1.3), + ), + ), + // 第三行:简介,最多两行 + if (description != null && description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: secondaryText, height: 1.35), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/work_people_section.dart b/lib/widgets/work_people_section.dart index ba2308d..4c8ba3c 100644 --- a/lib/widgets/work_people_section.dart +++ b/lib/widgets/work_people_section.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/data_models.dart'; @@ -88,8 +89,14 @@ class _WorkPeopleSectionState extends State { break; } - // 按 sortOrder 保留首次出现的顺序 - final items = byPerson.values.toList(); + // 按角色权重排序:导演/编剧/作者等优先,纯演员最后 + // 每个人取其所有角色中的最小权重(最高优先级)作为排序依据 + final items = byPerson.values.toList() + ..sort((a, b) { + final aWeight = a.roleTypes.map(_roleWeight).reduce(min); + final bWeight = b.roleTypes.map(_roleWeight).reduce(min); + return aWeight.compareTo(bWeight); + }); if (!mounted) return; setState(() { _items = items; @@ -182,16 +189,32 @@ class _WorkPeopleSectionState extends State { ], ), 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(), + ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0x00FFFFFF), + Color(0xFFFFFFFF), + Color(0xFFFFFFFF), + Color(0x00FFFFFF), + ], + stops: [0.0, 0.04, 0.96, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: 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(), + ), ), ), ],