generated from dellevin/template
添加角色信息编辑
This commit is contained in:
85
lib/data/character/book_character_dao.dart
Normal file
85
lib/data/character/book_character_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[BookCharacterDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取书籍的所有角色
|
||||
Future<List<BookCharacter>> 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<BookCharacter?> 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<int> insert(BookCharacter character) => _wrap('insert', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.insert('book_characters', character.toJson());
|
||||
});
|
||||
|
||||
/// 更新角色
|
||||
Future<int> 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<int> 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<int> 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<void> permanentDelete(String id) => _wrap('permanentDelete', () async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('book_characters', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
85
lib/data/character/game_character_dao.dart
Normal file
85
lib/data/character/game_character_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[GameCharacterDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取游戏的所有角色
|
||||
Future<List<GameCharacter>> 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<GameCharacter?> 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<int> insert(GameCharacter character) => _wrap('insert', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.insert('game_characters', character.toJson());
|
||||
});
|
||||
|
||||
/// 更新角色
|
||||
Future<int> 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<int> 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<int> 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<void> permanentDelete(String id) => _wrap('permanentDelete', () async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('game_characters', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
85
lib/data/character/movie_character_dao.dart
Normal file
85
lib/data/character/movie_character_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[MovieCharacterDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取影视的所有角色
|
||||
Future<List<MovieCharacter>> 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<MovieCharacter?> 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<int> insert(MovieCharacter character) => _wrap('insert', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.insert('movie_characters', character.toJson());
|
||||
});
|
||||
|
||||
/// 更新角色
|
||||
Future<int> 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<int> 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<int> 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<void> permanentDelete(String id) => _wrap('permanentDelete', () async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('movie_characters', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
@@ -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<void> _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<void> _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<void> _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 = <String, String>{
|
||||
'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) {
|
||||
|
||||
@@ -1365,3 +1365,321 @@ class GamePerson {
|
||||
}
|
||||
}
|
||||
|
||||
/// 影视角色模型
|
||||
class MovieCharacter {
|
||||
final String id;
|
||||
final String movieId;
|
||||
final String name;
|
||||
final String? role;
|
||||
final List<String> aliases;
|
||||
final List<String> 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<String, dynamic> 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<String, dynamic> 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<String>? aliases,
|
||||
List<String>? 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<String> aliases;
|
||||
final List<String> 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<String, dynamic> 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<String, dynamic> 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<String>? aliases,
|
||||
List<String>? 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<String> aliases;
|
||||
final List<String> 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<String, dynamic> 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<String, dynamic> 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<String>? aliases,
|
||||
List<String>? 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)}...';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BookDetailPage> {
|
||||
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||
ScrollController? _overlayScrollController;
|
||||
|
||||
// ─── 角色预览 ───
|
||||
List<dynamic> _characters = [];
|
||||
|
||||
// ─── 编辑模式 ───
|
||||
bool _isEditing = false;
|
||||
final _editFormKey = GlobalKey<FormState>();
|
||||
@@ -84,6 +90,13 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
_detailStyle = UserPrefs().detailPageStyle;
|
||||
_coverOffset.value = UserPrefs().getCoverOffset(widget.book.id);
|
||||
_initEditControllers();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
final list = await context.read<AppProvider>().getBookCharacters(widget.book.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _characters = list);
|
||||
}
|
||||
|
||||
void _initEditControllers() {
|
||||
@@ -268,6 +281,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
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<BookDetailPage> {
|
||||
unit: '条句读',
|
||||
onTap: () => _navigateToEpubHighlights(book),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(book),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -808,6 +834,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
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<BookDetailPage> {
|
||||
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<BookDetailPage> {
|
||||
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<BookDetailPage> {
|
||||
unit: '条句读',
|
||||
onTap: () => _navigateToEpubHighlights(book),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(book),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1904,6 +1956,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
unit: '条句读',
|
||||
onTap: () => _navigateToEpubHighlights(book),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFrostedExtraItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(book),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -2052,6 +2113,25 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToCharacters(Book book) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BookCharactersPage(book: book),
|
||||
),
|
||||
).then((_) => _loadCharacters());
|
||||
}
|
||||
|
||||
Future<void> _openCharacterSheet(dynamic character) async {
|
||||
final needRefresh = await CharacterInfoSheet.show(
|
||||
context,
|
||||
entityType: 'book',
|
||||
entityId: widget.book.id,
|
||||
character: character,
|
||||
);
|
||||
if (needRefresh == true) _loadCharacters();
|
||||
}
|
||||
|
||||
/// 获取关联 EPUB 的句读(高亮)数量
|
||||
Future<int> _getEpubHighlightCount(String bookId) async {
|
||||
final readerBook = await ReaderDao().getReaderBookByBookId(bookId);
|
||||
|
||||
553
lib/pages/character/character_form_page.dart
Normal file
553
lib/pages/character/character_form_page.dart
Normal file
@@ -0,0 +1,553 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../widgets/fade_in_local_image.dart';
|
||||
import '../../widgets/genre_selector_page.dart';
|
||||
|
||||
/// 角色编辑/添加页面
|
||||
///
|
||||
/// entityType: 'movie' / 'book' / 'game'
|
||||
/// entityId: 所属作品 ID
|
||||
/// character: 可空,空=新建
|
||||
class CharacterFormPage extends StatefulWidget {
|
||||
final String entityType;
|
||||
final String entityId;
|
||||
final dynamic character; // MovieCharacter / BookCharacter / GameCharacter
|
||||
|
||||
const CharacterFormPage({
|
||||
super.key,
|
||||
required this.entityType,
|
||||
required this.entityId,
|
||||
this.character,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CharacterFormPage> createState() => _CharacterFormPageState();
|
||||
}
|
||||
|
||||
class _CharacterFormPageState extends State<CharacterFormPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _roleCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
List<String> _aliases = [];
|
||||
List<String> _tags = [];
|
||||
String? _imagePath;
|
||||
bool _isDownloading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.character != null) {
|
||||
final c = widget.character;
|
||||
_nameCtrl.text = c.name;
|
||||
_roleCtrl.text = c.role ?? '';
|
||||
_descCtrl.text = c.description ?? '';
|
||||
_aliases = List<String>.from(c.aliases);
|
||||
_tags = List<String>.from(c.tags);
|
||||
_imagePath = c.imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_roleCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _entityLabel => switch (widget.entityType) {
|
||||
'movie' => '影视',
|
||||
'book' => '书籍',
|
||||
'game' => '游戏',
|
||||
_ => '作品',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final isEdit = widget.character != null;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmLeave();
|
||||
if (shouldPop && context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
title: Text(isEdit ? '编辑角色' : '添加角色'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: FilledButton(
|
||||
onPressed: _save,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
children: [
|
||||
Center(child: _buildImagePicker(colors)),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildField('名称', _nameCtrl, hint: '角色名称', required: true),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildField('角色定位', _roleCtrl, hint: '如:男主、女主、反派、配角'),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildChipField('别名', _aliases, colors, onTap: () async {
|
||||
final result = await GenreSelectorPage.show(
|
||||
context: context,
|
||||
title: '添加别名',
|
||||
existingTags: [],
|
||||
initialSelected: _aliases,
|
||||
hint: '如:曾用名、英文名',
|
||||
);
|
||||
if (result != null) setState(() => _aliases = result);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildChipField('标签', _tags, colors, onTap: () async {
|
||||
final result = await GenreSelectorPage.show(
|
||||
context: context,
|
||||
title: '添加标签',
|
||||
existingTags: [],
|
||||
initialSelected: _tags,
|
||||
hint: '如:主角、反派',
|
||||
);
|
||||
if (result != null) setState(() => _tags = result);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSectionLabel('角色简介', colors),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 120),
|
||||
child: TextFormField(
|
||||
controller: _descCtrl,
|
||||
maxLines: null,
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
|
||||
decoration: InputDecoration(
|
||||
hintText: '写下角色简介...',
|
||||
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePicker(ColorScheme colors) {
|
||||
final hasImage = _imagePath != null && _imagePath!.isNotEmpty;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _showImageOptions,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (hasImage)
|
||||
FadeInLocalImage(path: _imagePath, fit: BoxFit.cover)
|
||||
else
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_add_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
const SizedBox(height: 4),
|
||||
Text('添加图片', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
],
|
||||
),
|
||||
if (_isDownloading)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasImage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _imagePath = null),
|
||||
child: Text('移除图片', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showImageOptions() {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colors.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Align(alignment: Alignment.centerLeft,
|
||||
child: Text('添加图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface))),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: Icon(Icons.photo_library_outlined, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
title: Text('从相册选择', style: TextStyle(color: colors.onSurface)),
|
||||
onTap: () { Navigator.pop(ctx); _pickImage(); },
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
title: Text('网络链接', style: TextStyle(color: colors.onSurface)),
|
||||
onTap: () { Navigator.pop(ctx); _pickImageFromUrl(); },
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _characterId() async {
|
||||
if (widget.character != null) return widget.character.id;
|
||||
return const Uuid().v4();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 600, maxHeight: 600, imageQuality: 85);
|
||||
if (picked == null) return;
|
||||
final fileName = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final charId = await _characterId();
|
||||
final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(picked.path).copy(targetPath);
|
||||
if (mounted) setState(() => _imagePath = targetPath);
|
||||
} catch (e) {
|
||||
if (mounted) ToastUtil.show(context, '选择图片失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImageFromUrl() async {
|
||||
String? url;
|
||||
final confirmed = await showDialog<bool>(context: context, builder: (ctx) {
|
||||
final urlCtrl = TextEditingController();
|
||||
final colors = Theme.of(ctx).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colors.surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: urlCtrl,
|
||||
keyboardType: TextInputType.url,
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'https://example.com/image.jpg',
|
||||
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
filled: true, fillColor: colors.surfaceContainerHigh,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(
|
||||
onPressed: () { url = urlCtrl.text.trim(); Navigator.pop(ctx, true); },
|
||||
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
if (confirmed != true || url == null || url!.isEmpty) return;
|
||||
await _downloadImageFromUrl(url!);
|
||||
}
|
||||
|
||||
Future<void> _downloadImageFromUrl(String url) async {
|
||||
setState(() => _isDownloading = true);
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Referer': Uri.parse(url).replace(path: '/').toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}');
|
||||
|
||||
final contentType = response.headers['content-type'];
|
||||
if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片');
|
||||
if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大');
|
||||
|
||||
final fileName = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final charId = await _characterId();
|
||||
final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(targetPath).writeAsBytes(response.bodyBytes);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _imagePath = targetPath);
|
||||
} catch (e) {
|
||||
debugPrint('角色图片下载失败: $e');
|
||||
if (mounted) ToastUtil.show(context, '下载失败: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isDownloading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSectionLabel(String label, ColorScheme colors) {
|
||||
return Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)));
|
||||
}
|
||||
|
||||
Widget _buildField(String label, TextEditingController ctrl, {String hint = '', bool required = false}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
TextFormField(
|
||||
controller: ctrl,
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint, hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChipField(String label, List<String> chips, ColorScheme colors, {required VoidCallback onTap}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: chips.isEmpty
|
||||
? Text('点击添加$label', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)))
|
||||
: Wrap(
|
||||
spacing: 4, runSpacing: 4,
|
||||
children: chips.map((c) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(c, style: TextStyle(fontSize: 12, color: colors.onSurface)),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasContent() {
|
||||
if (widget.character != null) return true;
|
||||
if (_nameCtrl.text.trim().isNotEmpty) return true;
|
||||
if (_roleCtrl.text.trim().isNotEmpty) return true;
|
||||
if (_descCtrl.text.trim().isNotEmpty) return true;
|
||||
if (_imagePath != null) return true;
|
||||
if (_aliases.isNotEmpty || _tags.isNotEmpty) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _confirmLeave() async {
|
||||
if (!_hasContent()) return true;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: colors.surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('未保存', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: Text('当前内容未保存,确定要离开吗?',
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
|
||||
child: const Text('离开'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final provider = context.read<AppProvider>();
|
||||
final desc = _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim();
|
||||
final role = _roleCtrl.text.trim().isEmpty ? null : _roleCtrl.text.trim();
|
||||
|
||||
if (widget.character == null) {
|
||||
// 新建
|
||||
final newId = const Uuid().v4();
|
||||
switch (widget.entityType) {
|
||||
case 'movie':
|
||||
await provider.addMovieCharacter(MovieCharacter(
|
||||
id: newId,
|
||||
movieId: widget.entityId,
|
||||
name: _nameCtrl.text.trim(),
|
||||
role: role,
|
||||
aliases: _aliases,
|
||||
tags: _tags,
|
||||
description: desc,
|
||||
imagePath: _imagePath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
break;
|
||||
case 'book':
|
||||
await provider.addBookCharacter(BookCharacter(
|
||||
id: newId,
|
||||
bookId: widget.entityId,
|
||||
name: _nameCtrl.text.trim(),
|
||||
role: role,
|
||||
aliases: _aliases,
|
||||
tags: _tags,
|
||||
description: desc,
|
||||
imagePath: _imagePath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
break;
|
||||
case 'game':
|
||||
await provider.addGameCharacter(GameCharacter(
|
||||
id: newId,
|
||||
gameId: widget.entityId,
|
||||
name: _nameCtrl.text.trim(),
|
||||
role: role,
|
||||
aliases: _aliases,
|
||||
tags: _tags,
|
||||
description: desc,
|
||||
imagePath: _imagePath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 编辑
|
||||
final c = widget.character;
|
||||
switch (widget.entityType) {
|
||||
case 'movie':
|
||||
await provider.updateMovieCharacter((c as MovieCharacter).copyWith(
|
||||
name: _nameCtrl.text.trim(),
|
||||
role: role,
|
||||
aliases: _aliases,
|
||||
tags: _tags,
|
||||
description: desc,
|
||||
imagePath: _imagePath,
|
||||
updatedAt: now,
|
||||
));
|
||||
break;
|
||||
case 'book':
|
||||
await provider.updateBookCharacter((c as BookCharacter).copyWith(
|
||||
name: _nameCtrl.text.trim(),
|
||||
role: role,
|
||||
aliases: _aliases,
|
||||
tags: _tags,
|
||||
description: desc,
|
||||
imagePath: _imagePath,
|
||||
updatedAt: now,
|
||||
));
|
||||
break;
|
||||
case 'game':
|
||||
await provider.updateGameCharacter((c as GameCharacter).copyWith(
|
||||
name: _nameCtrl.text.trim(),
|
||||
role: role,
|
||||
aliases: _aliases,
|
||||
tags: _tags,
|
||||
description: desc,
|
||||
imagePath: _imagePath,
|
||||
updatedAt: now,
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ToastUtil.show(context, widget.character == null ? '添加成功' : '更新成功');
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ToastUtil.show(context, '保存失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
451
lib/pages/character/character_list_page.dart
Normal file
451
lib/pages/character/character_list_page.dart
Normal file
@@ -0,0 +1,451 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../widgets/fade_in_local_image.dart';
|
||||
import 'character_form_page.dart';
|
||||
|
||||
/// 影视角色列表页
|
||||
class MovieCharactersPage extends StatefulWidget {
|
||||
final Movie movie;
|
||||
const MovieCharactersPage({super.key, required this.movie});
|
||||
|
||||
@override
|
||||
State<MovieCharactersPage> createState() => _MovieCharactersPageState();
|
||||
}
|
||||
|
||||
class _MovieCharactersPageState extends State<MovieCharactersPage> {
|
||||
List<MovieCharacter> _characters = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
setState(() => _isLoading = true);
|
||||
final list = await context.read<AppProvider>().getMovieCharacters(widget.movie.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_characters = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm([MovieCharacter? c]) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CharacterFormPage(
|
||||
entityType: 'movie',
|
||||
entityId: widget.movie.id,
|
||||
character: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == true) _loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _delete(MovieCharacter c) async {
|
||||
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
|
||||
await ImagePathHelper.instance.deleteCharacterImages(c.id);
|
||||
}
|
||||
await context.read<AppProvider>().deleteMovieCharacter(c.id);
|
||||
_loadCharacters();
|
||||
if (mounted) ToastUtil.show(context, '已删除');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CharacterListScaffold(
|
||||
title: '角色',
|
||||
isLoading: _isLoading,
|
||||
characters: _characters,
|
||||
onAdd: () => _openForm(),
|
||||
onTap: (c) => _openForm(c),
|
||||
onDelete: (c) => _delete(c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 书籍角色列表页
|
||||
class BookCharactersPage extends StatefulWidget {
|
||||
final Book book;
|
||||
const BookCharactersPage({super.key, required this.book});
|
||||
|
||||
@override
|
||||
State<BookCharactersPage> createState() => _BookCharactersPageState();
|
||||
}
|
||||
|
||||
class _BookCharactersPageState extends State<BookCharactersPage> {
|
||||
List<BookCharacter> _characters = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
setState(() => _isLoading = true);
|
||||
final list = await context.read<AppProvider>().getBookCharacters(widget.book.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_characters = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm([BookCharacter? c]) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CharacterFormPage(
|
||||
entityType: 'book',
|
||||
entityId: widget.book.id,
|
||||
character: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == true) _loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _delete(BookCharacter c) async {
|
||||
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
|
||||
await ImagePathHelper.instance.deleteCharacterImages(c.id);
|
||||
}
|
||||
await context.read<AppProvider>().deleteBookCharacter(c.id);
|
||||
_loadCharacters();
|
||||
if (mounted) ToastUtil.show(context, '已删除');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CharacterListScaffold(
|
||||
title: '角色',
|
||||
isLoading: _isLoading,
|
||||
characters: _characters,
|
||||
onAdd: () => _openForm(),
|
||||
onTap: (c) => _openForm(c),
|
||||
onDelete: (c) => _delete(c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 游戏角色列表页
|
||||
class GameCharactersPage extends StatefulWidget {
|
||||
final Game game;
|
||||
const GameCharactersPage({super.key, required this.game});
|
||||
|
||||
@override
|
||||
State<GameCharactersPage> createState() => _GameCharactersPageState();
|
||||
}
|
||||
|
||||
class _GameCharactersPageState extends State<GameCharactersPage> {
|
||||
List<GameCharacter> _characters = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
setState(() => _isLoading = true);
|
||||
final list = await context.read<AppProvider>().getGameCharacters(widget.game.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_characters = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openForm([GameCharacter? c]) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CharacterFormPage(
|
||||
entityType: 'game',
|
||||
entityId: widget.game.id,
|
||||
character: c,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == true) _loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _delete(GameCharacter c) async {
|
||||
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
|
||||
await ImagePathHelper.instance.deleteCharacterImages(c.id);
|
||||
}
|
||||
await context.read<AppProvider>().deleteGameCharacter(c.id);
|
||||
_loadCharacters();
|
||||
if (mounted) ToastUtil.show(context, '已删除');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CharacterListScaffold(
|
||||
title: '角色',
|
||||
isLoading: _isLoading,
|
||||
characters: _characters,
|
||||
onAdd: () => _openForm(),
|
||||
onTap: (c) => _openForm(c),
|
||||
onDelete: (c) => _delete(c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用角色列表 UI(接收 dynamic 角色列表,访问 .name/.aliases/.tags/.imagePath/.description)
|
||||
class _CharacterListScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final bool isLoading;
|
||||
final List<dynamic> characters;
|
||||
final VoidCallback onAdd;
|
||||
final void Function(dynamic) onTap;
|
||||
final Future<void> Function(dynamic) onDelete;
|
||||
|
||||
const _CharacterListScaffold({
|
||||
required this.title,
|
||||
required this.isLoading,
|
||||
required this.characters,
|
||||
required this.onAdd,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(title: Text(title)),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: onAdd,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: characters.isEmpty
|
||||
? _buildEmpty(colors)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: characters.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, thickness: 0.5, color: colors.outlineVariant),
|
||||
itemBuilder: (context, index) {
|
||||
final c = characters[index];
|
||||
return _CharacterTile(
|
||||
name: c.name as String,
|
||||
aliases: c.aliases as List<String>,
|
||||
tags: c.tags as List<String>,
|
||||
description: c.description as String?,
|
||||
imagePath: c.imagePath as String?,
|
||||
onTap: () => onTap(c),
|
||||
onDelete: () => _confirmDelete(context, c),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty(ColorScheme colors) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(Icons.people_outline, size: 40, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text('暂无角色', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 8),
|
||||
Text('点击右下角 + 添加角色', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, dynamic c) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: colors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: Text('确定要删除该角色吗?',
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
onDelete(c);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CharacterTile extends StatelessWidget {
|
||||
final String name;
|
||||
final List<String> aliases;
|
||||
final List<String> tags;
|
||||
final String? description;
|
||||
final String? imagePath;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _CharacterTile({
|
||||
required this.name,
|
||||
required this.aliases,
|
||||
required this.tags,
|
||||
required this.description,
|
||||
required this.imagePath,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Dismissible(
|
||||
key: ValueKey(name + imagePath.toString()),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: colors.error,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: Icon(Icons.delete_outline, color: colors.onError),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
_showDeleteDialog(context);
|
||||
return false;
|
||||
},
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: _buildAvatar(colors),
|
||||
title: Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (aliases.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(aliases.join('、'),
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
),
|
||||
if (tags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: tags.map((t) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(t, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
if (description != null && description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(description!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(ColorScheme colors) {
|
||||
final hasImage = imagePath != null && imagePath!.isNotEmpty;
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasImage
|
||||
? FadeInLocalImage(path: imagePath, fit: BoxFit.cover)
|
||||
: Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name.characters.first : '?',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteDialog(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: colors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: Text('确定要删除"$name"吗?',
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
onDelete();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<GameDetailPage> {
|
||||
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||
ScrollController? _overlayScrollController;
|
||||
|
||||
// ─── 角色预览 ───
|
||||
List<dynamic> _characters = [];
|
||||
|
||||
// ─── 编辑模式 ───
|
||||
bool _isEditing = false;
|
||||
final _editFormKey = GlobalKey<FormState>();
|
||||
@@ -71,6 +77,13 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
_coverOffset.value = UserPrefs().getCoverOffset(widget.game.id);
|
||||
_initEditControllers();
|
||||
_detectCoverAspect();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
final list = await context.read<AppProvider>().getGameCharacters(widget.game.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _characters = list);
|
||||
}
|
||||
|
||||
void _initEditControllers() {
|
||||
@@ -273,6 +286,10 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
_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<GameDetailPage> {
|
||||
unit: '张截图',
|
||||
onTap: () => _navigateToScreenshots(game),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getGameCharacterCount(game.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(game),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -999,6 +1025,10 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
_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<GameDetailPage> {
|
||||
_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<GameDetailPage> {
|
||||
unit: '张截图',
|
||||
onTap: () => _navigateToScreenshots(game),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFrostedExtraItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getGameCharacterCount(game.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(game),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1357,6 +1401,14 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
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<GameDetailPage> {
|
||||
unit: '张截图',
|
||||
onTap: () => _navigateToScreenshots(game),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getGameCharacterCount(game.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(game),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1790,6 +1851,21 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreenshotsPage(game: game)));
|
||||
}
|
||||
|
||||
void _navigateToCharacters(Game game) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => GameCharactersPage(game: game)))
|
||||
.then((_) => _loadCharacters());
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
|
||||
@@ -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<MovieDetailPage> {
|
||||
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||
ScrollController? _overlayScrollController;
|
||||
|
||||
// ─── 角色预览 ───
|
||||
List<dynamic> _characters = [];
|
||||
|
||||
// ─── 编辑模式 ───
|
||||
bool _isEditing = false;
|
||||
final _editFormKey = GlobalKey<FormState>();
|
||||
@@ -68,6 +74,13 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
_detailStyle = UserPrefs().detailPageStyle;
|
||||
_posterOffset.value = UserPrefs().getCoverOffset(widget.movie.id);
|
||||
_initEditControllers();
|
||||
_loadCharacters();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacters() async {
|
||||
final list = await context.read<AppProvider>().getMovieCharacters(widget.movie.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _characters = list);
|
||||
}
|
||||
|
||||
void _initEditControllers() {
|
||||
@@ -268,6 +281,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
)),
|
||||
]),
|
||||
],
|
||||
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<MovieDetailPage> {
|
||||
unit: '张海报',
|
||||
onTap: () => _navigateToPosters(movie),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getMovieCharacterCount(movie.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(movie),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -934,6 +960,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
_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<MovieDetailPage> {
|
||||
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<MovieDetailPage> {
|
||||
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<MovieDetailPage> {
|
||||
unit: '张海报',
|
||||
onTap: () => _navigateToPosters(movie),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getMovieCharacterCount(movie.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(movie),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1839,6 +1890,15 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
unit: '张海报',
|
||||
onTap: () => _navigateToPosters(movie),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFrostedExtraItem(
|
||||
icon: Icons.people_outline,
|
||||
title: '角色',
|
||||
subtitleFuture: context.read<AppProvider>().getMovieCharacterCount(movie.id),
|
||||
emptyText: '暂无角色',
|
||||
unit: '个角色',
|
||||
onTap: () => _navigateToCharacters(movie),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1987,6 +2047,25 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToCharacters(Movie movie) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MovieCharactersPage(movie: movie),
|
||||
),
|
||||
).then((_) => _loadCharacters());
|
||||
}
|
||||
|
||||
Future<void> _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')}';
|
||||
}
|
||||
|
||||
@@ -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<Movie> _movies = [];
|
||||
List<Book> _books = [];
|
||||
@@ -1449,4 +1455,78 @@ class AppProvider extends ChangeNotifier {
|
||||
await loadPeople();
|
||||
return (newPersons: newPersons, newRelations: newRelations, merged: merged);
|
||||
}
|
||||
|
||||
// ========== 角色相关方法 ==========
|
||||
|
||||
// ─── 影视角色 ───
|
||||
Future<List<MovieCharacter>> getMovieCharacters(String movieId) async {
|
||||
return await _movieCharacterDao.getByMovieId(movieId);
|
||||
}
|
||||
|
||||
Future<int> getMovieCharacterCount(String movieId) async {
|
||||
return await _movieCharacterDao.getCount(movieId);
|
||||
}
|
||||
|
||||
Future<void> addMovieCharacter(MovieCharacter character) async {
|
||||
await _movieCharacterDao.insert(character);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateMovieCharacter(MovieCharacter character) async {
|
||||
await _movieCharacterDao.update(character);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> deleteMovieCharacter(String id) async {
|
||||
await _movieCharacterDao.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ─── 书籍角色 ───
|
||||
Future<List<BookCharacter>> getBookCharacters(String bookId) async {
|
||||
return await _bookCharacterDao.getByBookId(bookId);
|
||||
}
|
||||
|
||||
Future<int> getBookCharacterCount(String bookId) async {
|
||||
return await _bookCharacterDao.getCount(bookId);
|
||||
}
|
||||
|
||||
Future<void> addBookCharacter(BookCharacter character) async {
|
||||
await _bookCharacterDao.insert(character);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateBookCharacter(BookCharacter character) async {
|
||||
await _bookCharacterDao.update(character);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> deleteBookCharacter(String id) async {
|
||||
await _bookCharacterDao.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ─── 游戏角色 ───
|
||||
Future<List<GameCharacter>> getGameCharacters(String gameId) async {
|
||||
return await _gameCharacterDao.getByGameId(gameId);
|
||||
}
|
||||
|
||||
Future<int> getGameCharacterCount(String gameId) async {
|
||||
return await _gameCharacterDao.getCount(gameId);
|
||||
}
|
||||
|
||||
Future<void> addGameCharacter(GameCharacter character) async {
|
||||
await _gameCharacterDao.insert(character);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateGameCharacter(GameCharacter character) async {
|
||||
await _gameCharacterDao.update(character);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> deleteGameCharacter(String id) async {
|
||||
await _gameCharacterDao.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <String>{};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>;
|
||||
return SlideUpPageRoute(
|
||||
page: CharacterFormPage(
|
||||
entityType: args['entityType'] as String,
|
||||
entityId: args['entityId'] as String,
|
||||
character: args['character'],
|
||||
),
|
||||
);
|
||||
|
||||
default:
|
||||
return _buildUnknownRoute(settings.name);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,29 @@ class ImagePathHelper {
|
||||
return p.join(dir, fileName);
|
||||
}
|
||||
|
||||
// ==================== 角色相关路径 ====================
|
||||
|
||||
/// 获取角色图片目录
|
||||
/// 路径: images/characters/{characterId}/
|
||||
Future<String> getCharacterImagesDir(String characterId) async {
|
||||
final root = await imagesRoot;
|
||||
return p.join(root, 'characters', characterId);
|
||||
}
|
||||
|
||||
/// 获取角色图片路径
|
||||
/// 路径: images/characters/{characterId}/{fileName}
|
||||
Future<String> getCharacterImagePath(String characterId, String fileName) async {
|
||||
final dir = await getCharacterImagesDir(characterId);
|
||||
return p.join(dir, fileName);
|
||||
}
|
||||
|
||||
/// 删除角色图片目录
|
||||
/// 删除路径: images/characters/{characterId}/
|
||||
Future<void> deleteCharacterImages(String characterId) async {
|
||||
final dirPath = await getCharacterImagesDir(characterId);
|
||||
await _deleteDirectory(dirPath);
|
||||
}
|
||||
|
||||
// ==================== 目录操作 ====================
|
||||
|
||||
/// 确保目录存在
|
||||
|
||||
248
lib/widgets/character_info_sheet.dart
Normal file
248
lib/widgets/character_info_sheet.dart
Normal file
@@ -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<bool?> show(
|
||||
BuildContext context, {
|
||||
required String entityType,
|
||||
required String entityId,
|
||||
required dynamic character,
|
||||
}) {
|
||||
return showModalBottomSheet<bool>(
|
||||
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<CharacterInfoSheet> createState() => _CharacterInfoSheetState();
|
||||
}
|
||||
|
||||
class _CharacterInfoSheetState extends State<CharacterInfoSheet> {
|
||||
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<String>;
|
||||
final tags = c.tags as List<String>;
|
||||
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<bool>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
226
lib/widgets/character_preview_section.dart
Normal file
226
lib/widgets/character_preview_section.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'fade_in_local_image.dart';
|
||||
|
||||
/// 角色卡片横向预览组件
|
||||
///
|
||||
/// 在影视/书籍/游戏详情页的角色入口上方展示。
|
||||
/// 空列表返回 SizedBox.shrink(),不占空间。
|
||||
class CharacterPreviewSection extends StatelessWidget {
|
||||
final List<dynamic> 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<String>;
|
||||
final tags = character.tags as List<String>;
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<WorkPeopleSection> {
|
||||
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<WorkPeopleSection> {
|
||||
],
|
||||
),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user