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(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 39,
|
version: 40,
|
||||||
onCreate: _createDB,
|
onCreate: _createDB,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -405,6 +405,14 @@ class DatabaseHelper {
|
|||||||
// 创建人物表和关联表
|
// 创建人物表和关联表
|
||||||
await _createPeopleTables(db);
|
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 {
|
Future<void> _upgradeBooksTableV26(Database db) async {
|
||||||
final columns = await db.rawQuery('PRAGMA table_info(books)');
|
final columns = await db.rawQuery('PRAGMA table_info(books)');
|
||||||
@@ -1040,6 +1048,8 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
// 人物表
|
// 人物表
|
||||||
await _createPeopleTables(db);
|
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)');
|
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 {
|
Future close() async {
|
||||||
if (_database != null) {
|
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 '../../utils/responsive.dart';
|
||||||
import '../../widgets/genre_selector_page.dart';
|
import '../../widgets/genre_selector_page.dart';
|
||||||
import '../../widgets/work_people_section.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_reviews_page.dart';
|
||||||
import 'book_excerpts_page.dart';
|
import 'book_excerpts_page.dart';
|
||||||
import 'book_share_page.dart';
|
import 'book_share_page.dart';
|
||||||
|
import '../character/character_list_page.dart';
|
||||||
import '../../data/epub/reader_dao.dart';
|
import '../../data/epub/reader_dao.dart';
|
||||||
import '../epub_reader/epub_highlights_page.dart';
|
import '../epub_reader/epub_highlights_page.dart';
|
||||||
import '../epub_reader/reader_screen.dart';
|
import '../epub_reader/reader_screen.dart';
|
||||||
@@ -44,6 +47,9 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||||
ScrollController? _overlayScrollController;
|
ScrollController? _overlayScrollController;
|
||||||
|
|
||||||
|
// ─── 角色预览 ───
|
||||||
|
List<dynamic> _characters = [];
|
||||||
|
|
||||||
// ─── 编辑模式 ───
|
// ─── 编辑模式 ───
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
final _editFormKey = GlobalKey<FormState>();
|
final _editFormKey = GlobalKey<FormState>();
|
||||||
@@ -84,6 +90,13 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
_detailStyle = UserPrefs().detailPageStyle;
|
_detailStyle = UserPrefs().detailPageStyle;
|
||||||
_coverOffset.value = UserPrefs().getCoverOffset(widget.book.id);
|
_coverOffset.value = UserPrefs().getCoverOffset(widget.book.id);
|
||||||
_initEditControllers();
|
_initEditControllers();
|
||||||
|
_loadCharacters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCharacters() async {
|
||||||
|
final list = await context.read<AppProvider>().getBookCharacters(widget.book.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _characters = list);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initEditControllers() {
|
void _initEditControllers() {
|
||||||
@@ -268,6 +281,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
Expanded(child: Text('${book.readCount} 次', style: TextStyle(fontSize: 13, color: colors.onSurface))),
|
Expanded(child: Text('${book.readCount} 次', style: TextStyle(fontSize: 13, color: colors.onSurface))),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
),
|
||||||
WorkPeopleSection(workId: book.id, workType: 'book'),
|
WorkPeopleSection(workId: book.id, workType: 'book'),
|
||||||
if (book.summary != null && book.summary!.isNotEmpty) ...[
|
if (book.summary != null && book.summary!.isNotEmpty) ...[
|
||||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||||
@@ -312,6 +329,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
unit: '条句读',
|
unit: '条句读',
|
||||||
onTap: () => _navigateToEpubHighlights(book),
|
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.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book),
|
||||||
if (book.publishDate != null) _buildPublishDateSection(book),
|
if (book.publishDate != null) _buildPublishDateSection(book),
|
||||||
if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book),
|
if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book),
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
),
|
||||||
WorkPeopleSection(workId: book.id, workType: 'book'),
|
WorkPeopleSection(workId: book.id, workType: 'book'),
|
||||||
if (book.summary != null && book.summary!.isNotEmpty) _buildSummarySection(book),
|
if (book.summary != null && book.summary!.isNotEmpty) _buildSummarySection(book),
|
||||||
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
|
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.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book),
|
||||||
// 类型标签毛玻璃
|
// 类型标签毛玻璃
|
||||||
if (book.genres.isNotEmpty) _buildGenresSection(book),
|
if (book.genres.isNotEmpty) _buildGenresSection(book),
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
isOverlay: true,
|
||||||
|
),
|
||||||
// 关联人物
|
// 关联人物
|
||||||
WorkPeopleSection(workId: book.id, workType: 'book'),
|
WorkPeopleSection(workId: book.id, workType: 'book'),
|
||||||
// 简介:内部已有毛玻璃卡片
|
// 简介:内部已有毛玻璃卡片
|
||||||
@@ -996,6 +1031,14 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
foregroundColor: colors.onPrimary,
|
foregroundColor: colors.onPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_buildFloatingButton(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
onPressed: () => _navigateToCharacters(book),
|
||||||
|
tooltip: '角色',
|
||||||
|
backgroundColor: colors.secondaryContainer,
|
||||||
|
foregroundColor: colors.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_buildFloatingButton(
|
_buildFloatingButton(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
onPressed: () => _showDeleteDialog(context),
|
onPressed: () => _showDeleteDialog(context),
|
||||||
@@ -1866,6 +1909,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
unit: '条句读',
|
unit: '条句读',
|
||||||
onTap: () => _navigateToEpubHighlights(book),
|
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: '条句读',
|
unit: '条句读',
|
||||||
onTap: () => _navigateToEpubHighlights(book),
|
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 的句读(高亮)数量
|
/// 获取关联 EPUB 的句读(高亮)数量
|
||||||
Future<int> _getEpubHighlightCount(String bookId) async {
|
Future<int> _getEpubHighlightCount(String bookId) async {
|
||||||
final readerBook = await ReaderDao().getReaderBookByBookId(bookId);
|
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 '../../utils/responsive.dart';
|
||||||
import '../../widgets/genre_selector_page.dart';
|
import '../../widgets/genre_selector_page.dart';
|
||||||
import '../../widgets/work_people_section.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_reviews_page.dart';
|
||||||
import 'game_screenshots_page.dart';
|
import 'game_screenshots_page.dart';
|
||||||
import 'game_share_page.dart';
|
import 'game_share_page.dart';
|
||||||
|
import '../character/character_list_page.dart';
|
||||||
|
|
||||||
/// 游戏详情页 - 极简主义设计
|
/// 游戏详情页 - 极简主义设计
|
||||||
class GameDetailPage extends StatefulWidget {
|
class GameDetailPage extends StatefulWidget {
|
||||||
@@ -42,6 +45,9 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
|||||||
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||||
ScrollController? _overlayScrollController;
|
ScrollController? _overlayScrollController;
|
||||||
|
|
||||||
|
// ─── 角色预览 ───
|
||||||
|
List<dynamic> _characters = [];
|
||||||
|
|
||||||
// ─── 编辑模式 ───
|
// ─── 编辑模式 ───
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
final _editFormKey = GlobalKey<FormState>();
|
final _editFormKey = GlobalKey<FormState>();
|
||||||
@@ -71,6 +77,13 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
|||||||
_coverOffset.value = UserPrefs().getCoverOffset(widget.game.id);
|
_coverOffset.value = UserPrefs().getCoverOffset(widget.game.id);
|
||||||
_initEditControllers();
|
_initEditControllers();
|
||||||
_detectCoverAspect();
|
_detectCoverAspect();
|
||||||
|
_loadCharacters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCharacters() async {
|
||||||
|
final list = await context.read<AppProvider>().getGameCharacters(widget.game.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _characters = list);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initEditControllers() {
|
void _initEditControllers() {
|
||||||
@@ -273,6 +286,10 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
|||||||
_buildDesktopInfoRow('购买时间', _formatDate(game.purchaseDate!), colors),
|
_buildDesktopInfoRow('购买时间', _formatDate(game.purchaseDate!), colors),
|
||||||
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
|
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
|
||||||
_buildDesktopInfoRow('购买价格', game.purchasePrice!, colors),
|
_buildDesktopInfoRow('购买价格', game.purchasePrice!, colors),
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
),
|
||||||
WorkPeopleSection(workId: game.id, workType: 'game'),
|
WorkPeopleSection(workId: game.id, workType: 'game'),
|
||||||
if (game.summary != null && game.summary!.isNotEmpty) ...[
|
if (game.summary != null && game.summary!.isNotEmpty) ...[
|
||||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||||
@@ -308,6 +325,15 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
|||||||
unit: '张截图',
|
unit: '张截图',
|
||||||
onTap: () => _navigateToScreenshots(game),
|
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!)),
|
_buildInfoSection('购买时间', _formatDate(game.purchaseDate!)),
|
||||||
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
|
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
|
||||||
_buildInfoSection('购买价格', game.purchasePrice!),
|
_buildInfoSection('购买价格', game.purchasePrice!),
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
),
|
||||||
WorkPeopleSection(workId: game.id, workType: 'game'),
|
WorkPeopleSection(workId: game.id, workType: 'game'),
|
||||||
if (game.summary != null && game.summary!.isNotEmpty)
|
if (game.summary != null && game.summary!.isNotEmpty)
|
||||||
_buildInfoSection('游戏简介', game.summary!),
|
_buildInfoSection('游戏简介', game.summary!),
|
||||||
@@ -1149,6 +1179,11 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
|||||||
_buildOverlayInfoRow('购买时间', _formatDate(game.purchaseDate!)),
|
_buildOverlayInfoRow('购买时间', _formatDate(game.purchaseDate!)),
|
||||||
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
|
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
|
||||||
_buildOverlayInfoRow('购买价格', game.purchasePrice!),
|
_buildOverlayInfoRow('购买价格', game.purchasePrice!),
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
isOverlay: true,
|
||||||
|
),
|
||||||
// 关联人物
|
// 关联人物
|
||||||
WorkPeopleSection(workId: game.id, workType: 'game'),
|
WorkPeopleSection(workId: game.id, workType: 'game'),
|
||||||
if (game.summary != null && game.summary!.isNotEmpty) ...[
|
if (game.summary != null && game.summary!.isNotEmpty) ...[
|
||||||
@@ -1289,6 +1324,15 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
|||||||
unit: '张截图',
|
unit: '张截图',
|
||||||
onTap: () => _navigateToScreenshots(game),
|
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,
|
foregroundColor: colors.onPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_buildFloatingButton(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
onPressed: () => _navigateToCharacters(game),
|
||||||
|
tooltip: '角色',
|
||||||
|
backgroundColor: colors.secondaryContainer,
|
||||||
|
foregroundColor: colors.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_buildFloatingButton(
|
_buildFloatingButton(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
onPressed: () => _showDeleteDialog(context),
|
onPressed: () => _showDeleteDialog(context),
|
||||||
@@ -1721,6 +1773,15 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
|||||||
unit: '张截图',
|
unit: '张截图',
|
||||||
onTap: () => _navigateToScreenshots(game),
|
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)));
|
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() {
|
void _showStylePicker() {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
final currentStyle = UserPrefs().detailPageStyle;
|
final currentStyle = UserPrefs().detailPageStyle;
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import '../../utils/image_path_helper.dart';
|
|||||||
import '../../utils/responsive.dart';
|
import '../../utils/responsive.dart';
|
||||||
import '../../widgets/genre_selector_page.dart';
|
import '../../widgets/genre_selector_page.dart';
|
||||||
import '../../widgets/work_people_section.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_reviews_page.dart';
|
||||||
import 'movie_posters_page.dart';
|
import 'movie_posters_page.dart';
|
||||||
import 'movie_share_page.dart';
|
import 'movie_share_page.dart';
|
||||||
|
import '../character/character_list_page.dart';
|
||||||
|
|
||||||
/// 影视详情页 - 极简主义设计
|
/// 影视详情页 - 极简主义设计
|
||||||
class MovieDetailPage extends StatefulWidget {
|
class MovieDetailPage extends StatefulWidget {
|
||||||
@@ -42,6 +45,9 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||||
ScrollController? _overlayScrollController;
|
ScrollController? _overlayScrollController;
|
||||||
|
|
||||||
|
// ─── 角色预览 ───
|
||||||
|
List<dynamic> _characters = [];
|
||||||
|
|
||||||
// ─── 编辑模式 ───
|
// ─── 编辑模式 ───
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
final _editFormKey = GlobalKey<FormState>();
|
final _editFormKey = GlobalKey<FormState>();
|
||||||
@@ -68,6 +74,13 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
_detailStyle = UserPrefs().detailPageStyle;
|
_detailStyle = UserPrefs().detailPageStyle;
|
||||||
_posterOffset.value = UserPrefs().getCoverOffset(widget.movie.id);
|
_posterOffset.value = UserPrefs().getCoverOffset(widget.movie.id);
|
||||||
_initEditControllers();
|
_initEditControllers();
|
||||||
|
_loadCharacters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCharacters() async {
|
||||||
|
final list = await context.read<AppProvider>().getMovieCharacters(widget.movie.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _characters = list);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initEditControllers() {
|
void _initEditControllers() {
|
||||||
@@ -268,6 +281,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
)),
|
)),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
),
|
||||||
WorkPeopleSection(workId: movie.id, workType: 'movie'),
|
WorkPeopleSection(workId: movie.id, workType: 'movie'),
|
||||||
if (movie.summary != null && movie.summary!.isNotEmpty) ...[
|
if (movie.summary != null && movie.summary!.isNotEmpty) ...[
|
||||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||||
@@ -304,6 +321,15 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
unit: '张海报',
|
unit: '张海报',
|
||||||
onTap: () => _navigateToPosters(movie),
|
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),
|
_buildActorsSection(movie),
|
||||||
if (movie.genres.isNotEmpty)
|
if (movie.genres.isNotEmpty)
|
||||||
_buildGenresSection(movie),
|
_buildGenresSection(movie),
|
||||||
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
),
|
||||||
WorkPeopleSection(workId: movie.id, workType: 'movie'),
|
WorkPeopleSection(workId: movie.id, workType: 'movie'),
|
||||||
if (movie.summary != null && movie.summary!.isNotEmpty)
|
if (movie.summary != null && movie.summary!.isNotEmpty)
|
||||||
_buildSummarySection(movie),
|
_buildSummarySection(movie),
|
||||||
@@ -1066,7 +1096,11 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildGenresSection(movie),
|
_buildGenresSection(movie),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
CharacterPreviewSection(
|
||||||
|
characters: _characters,
|
||||||
|
onTap: _openCharacterSheet,
|
||||||
|
isOverlay: true,
|
||||||
|
),
|
||||||
// 关联人物
|
// 关联人物
|
||||||
WorkPeopleSection(workId: movie.id, workType: 'movie'),
|
WorkPeopleSection(workId: movie.id, workType: 'movie'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1202,6 +1236,14 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
foregroundColor: colors.onPrimary,
|
foregroundColor: colors.onPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_buildFloatingButton(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
onPressed: () => _navigateToCharacters(movie),
|
||||||
|
tooltip: '角色',
|
||||||
|
backgroundColor: colors.secondaryContainer,
|
||||||
|
foregroundColor: colors.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_buildFloatingButton(
|
_buildFloatingButton(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
onPressed: () => _showDeleteDialog(context),
|
onPressed: () => _showDeleteDialog(context),
|
||||||
@@ -1811,6 +1853,15 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
unit: '张海报',
|
unit: '张海报',
|
||||||
onTap: () => _navigateToPosters(movie),
|
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: '张海报',
|
unit: '张海报',
|
||||||
onTap: () => _navigateToPosters(movie),
|
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) {
|
String _formatDate(DateTime date) {
|
||||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
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/movie_person_dao.dart';
|
||||||
import '../data/person/book_person_dao.dart';
|
import '../data/person/book_person_dao.dart';
|
||||||
import '../data/person/game_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 '../data/database_helper.dart';
|
||||||
import '../utils/image_path_helper.dart';
|
import '../utils/image_path_helper.dart';
|
||||||
import '../utils/user_prefs.dart';
|
import '../utils/user_prefs.dart';
|
||||||
@@ -44,6 +47,9 @@ class AppProvider extends ChangeNotifier {
|
|||||||
final MoviePersonDao _moviePersonDao = MoviePersonDao();
|
final MoviePersonDao _moviePersonDao = MoviePersonDao();
|
||||||
final BookPersonDao _bookPersonDao = BookPersonDao();
|
final BookPersonDao _bookPersonDao = BookPersonDao();
|
||||||
final GamePersonDao _gamePersonDao = GamePersonDao();
|
final GamePersonDao _gamePersonDao = GamePersonDao();
|
||||||
|
final MovieCharacterDao _movieCharacterDao = MovieCharacterDao();
|
||||||
|
final BookCharacterDao _bookCharacterDao = BookCharacterDao();
|
||||||
|
final GameCharacterDao _gameCharacterDao = GameCharacterDao();
|
||||||
// 数据列表
|
// 数据列表
|
||||||
List<Movie> _movies = [];
|
List<Movie> _movies = [];
|
||||||
List<Book> _books = [];
|
List<Book> _books = [];
|
||||||
@@ -1449,4 +1455,78 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await loadPeople();
|
await loadPeople();
|
||||||
return (newPersons: newPersons, newRelations: newRelations, merged: merged);
|
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 moviePeople = await db.query('movie_people');
|
||||||
final bookPeople = await db.query('book_people');
|
final bookPeople = await db.query('book_people');
|
||||||
final gamePeople = await db.query('game_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>{};
|
final imagePaths = <String>{};
|
||||||
@@ -117,6 +122,10 @@ class BackupService {
|
|||||||
final pp = p['photo_path'] as String?;
|
final pp = p['photo_path'] as String?;
|
||||||
if (pp != null && pp.isNotEmpty) imagePaths.add(pp);
|
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 userPrefs = UserPrefs();
|
||||||
final userInfo = {
|
final userInfo = {
|
||||||
@@ -153,6 +162,11 @@ class BackupService {
|
|||||||
'movie_people': moviePeople,
|
'movie_people': moviePeople,
|
||||||
'book_people': bookPeople,
|
'book_people': bookPeople,
|
||||||
'game_people': gamePeople,
|
'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 moviePeopleCols = await _getTableColumns(db, 'movie_people');
|
||||||
final bookPeopleCols = await _getTableColumns(db, 'book_people');
|
final bookPeopleCols = await _getTableColumns(db, 'book_people');
|
||||||
final gamePeopleCols = await _getTableColumns(db, 'game_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 db.transaction((txn) async {
|
||||||
await txn.delete('movie_reviews');
|
await txn.delete('movie_reviews');
|
||||||
@@ -449,6 +468,11 @@ class BackupService {
|
|||||||
await txn.delete('book_people');
|
await txn.delete('book_people');
|
||||||
await txn.delete('game_people');
|
await txn.delete('game_people');
|
||||||
await txn.delete('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('movies');
|
||||||
await txn.delete('books');
|
await txn.delete('books');
|
||||||
await txn.delete('notes');
|
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 moviePeopleCols = await _getTableColumns(db, 'movie_people');
|
||||||
final bookPeopleCols = await _getTableColumns(db, 'book_people');
|
final bookPeopleCols = await _getTableColumns(db, 'book_people');
|
||||||
final gamePeopleCols = await _getTableColumns(db, 'game_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 db.transaction((txn) async {
|
||||||
await txn.delete('movie_reviews');
|
await txn.delete('movie_reviews');
|
||||||
@@ -613,6 +687,11 @@ class BackupService {
|
|||||||
await txn.delete('book_people');
|
await txn.delete('book_people');
|
||||||
await txn.delete('game_people');
|
await txn.delete('game_people');
|
||||||
await txn.delete('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('movies');
|
||||||
await txn.delete('books');
|
await txn.delete('books');
|
||||||
await txn.delete('notes');
|
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);
|
await _restoreUserInfo(backupData, imagePathMap);
|
||||||
@@ -778,6 +902,13 @@ class BackupService {
|
|||||||
if (data.containsKey('games')) stats['游戏'] = (data['games'] as List).length;
|
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_reviews')) stats['游戏评价'] = (data['game_reviews'] as List).length;
|
||||||
if (data.containsKey('game_screenshots')) stats['游戏截图'] = (data['game_screenshots'] 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;
|
if (imageCount > 0) stats['图片'] = imageCount;
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../pages/note/note_detail_page.dart';
|
|||||||
import '../pages/game/game_detail_page.dart';
|
import '../pages/game/game_detail_page.dart';
|
||||||
import '../pages/movies/douban_webview_page.dart';
|
import '../pages/movies/douban_webview_page.dart';
|
||||||
import '../pages/people/person_form_page.dart';
|
import '../pages/people/person_form_page.dart';
|
||||||
|
import '../pages/character/character_form_page.dart';
|
||||||
|
|
||||||
/// 路由生成器
|
/// 路由生成器
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
@@ -95,6 +96,16 @@ class AppRouter {
|
|||||||
final Person? person = args is Person ? args : null;
|
final Person? person = args is Person ? args : null;
|
||||||
return SlideUpPageRoute(page: PersonFormPage(person: person));
|
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:
|
default:
|
||||||
return _buildUnknownRoute(settings.name);
|
return _buildUnknownRoute(settings.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,29 @@ class ImagePathHelper {
|
|||||||
return p.join(dir, fileName);
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/data_models.dart';
|
import '../models/data_models.dart';
|
||||||
@@ -88,8 +89,14 @@ class _WorkPeopleSectionState extends State<WorkPeopleSection> {
|
|||||||
break;
|
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;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_items = items;
|
_items = items;
|
||||||
@@ -182,7 +189,22 @@ class _WorkPeopleSectionState extends State<WorkPeopleSection> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SingleChildScrollView(
|
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,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: _items.map((item) {
|
children: _items.map((item) {
|
||||||
@@ -194,6 +216,7 @@ class _WorkPeopleSectionState extends State<WorkPeopleSection> {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user