Files
MaliangAINovalWriter/AINoval/lib/widgets/common/scene_selector.dart
2025-09-10 00:07:52 +08:00

541 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:ainoval/models/novel_structure.dart';
/// 场景选择器组件
/// 提供场景信息显示和下拉选择功能,支持大量场景的性能优化
class SceneSelector extends StatefulWidget {
const SceneSelector({
Key? key,
required this.novel,
required this.activeSceneId,
required this.onSceneSelected,
this.onSummaryLoaded,
this.compact = false,
}) : super(key: key);
final Novel novel;
final String? activeSceneId;
final Function(String sceneId, String actId, String chapterId) onSceneSelected;
final Function(String summary)? onSummaryLoaded;
final bool compact;
@override
State<SceneSelector> createState() => _SceneSelectorState();
}
class _SceneSelectorState extends State<SceneSelector> {
final GlobalKey _buttonKey = GlobalKey();
List<_SceneItem> _cachedSceneItems = [];
bool _isDropdownOpen = false;
OverlayEntry? _overlayEntry;
@override
void initState() {
super.initState();
_buildSceneItemsCache();
}
@override
void didUpdateWidget(SceneSelector oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.novel != widget.novel) {
_buildSceneItemsCache();
}
}
@override
void dispose() {
_closeDropdown();
super.dispose();
}
/// 构建场景项缓存,提高性能
void _buildSceneItemsCache() {
_cachedSceneItems = [];
for (final act in widget.novel.acts) {
// 添加Act分组标题
_cachedSceneItems.add(_SceneItem(
type: _SceneItemType.actHeader,
title: act.title,
actId: act.id,
));
// 添加Act下的Chapter和Scene
for (final chapter in act.chapters) {
// 添加Chapter分组标题
_cachedSceneItems.add(_SceneItem(
type: _SceneItemType.chapterHeader,
title: chapter.title,
actId: act.id,
chapterId: chapter.id,
));
// 添加Scene
for (int i = 0; i < chapter.scenes.length; i++) {
final scene = chapter.scenes[i];
_cachedSceneItems.add(_SceneItem(
type: _SceneItemType.scene,
title: scene.title,
actId: act.id,
chapterId: chapter.id,
sceneId: scene.id,
sceneIndex: i,
sceneSummary: scene.summary?.content,
));
}
}
}
}
/// 打开下拉菜单
void _openDropdown() {
if (_isDropdownOpen) return;
final RenderBox buttonRenderBox = _buttonKey.currentContext!.findRenderObject() as RenderBox;
final buttonPosition = buttonRenderBox.localToGlobal(Offset.zero);
final buttonSize = buttonRenderBox.size;
_overlayEntry = OverlayEntry(
builder: (context) => _DropdownOverlay(
buttonPosition: buttonPosition,
buttonSize: buttonSize,
items: _cachedSceneItems,
activeSceneId: widget.activeSceneId,
onItemSelected: (sceneId, actId, chapterId) {
_closeDropdown();
widget.onSceneSelected(sceneId, actId, chapterId);
},
onClose: _closeDropdown,
),
);
Overlay.of(context).insert(_overlayEntry!);
setState(() {
_isDropdownOpen = true;
});
}
/// 关闭下拉菜单
void _closeDropdown() {
if (!_isDropdownOpen) return;
_overlayEntry?.remove();
_overlayEntry = null;
setState(() {
_isDropdownOpen = false;
});
}
/// 获取当前场景信息
String _getCurrentSceneInfo() {
final activeScene = _getActiveScene();
if (activeScene == null) return '未选择场景';
final scenePosition = _getScenePosition(activeScene);
if (widget.compact) {
return scenePosition;
}
return '$scenePosition · ${activeScene.title}';
}
/// 获取当前激活的场景
Scene? _getActiveScene() {
if (widget.activeSceneId == null) return null;
for (final act in widget.novel.acts) {
for (final chapter in act.chapters) {
for (final scene in chapter.scenes) {
if (scene.id == widget.activeSceneId) {
return scene;
}
}
}
}
return null;
}
/// 获取场景位置信息
String _getScenePosition(Scene scene) {
int actIndex = 0;
int chapterIndex = 0;
int sceneIndex = 0;
for (int i = 0; i < widget.novel.acts.length; i++) {
final act = widget.novel.acts[i];
for (int j = 0; j < act.chapters.length; j++) {
final chapter = act.chapters[j];
for (int k = 0; k < chapter.scenes.length; k++) {
final sceneItem = chapter.scenes[k];
if (sceneItem.id == scene.id) {
actIndex = i + 1;
chapterIndex = j + 1;
sceneIndex = k + 1;
break;
}
}
}
}
return '${actIndex}卷 · 第${chapterIndex}章 · 第${sceneIndex}';
}
@override
Widget build(BuildContext context) {
return Container(
key: _buttonKey,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _isDropdownOpen ? Colors.blue : Colors.grey[300]!,
width: 1,
),
boxShadow: _isDropdownOpen ? [
BoxShadow(
color: Colors.blue.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
] : null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _isDropdownOpen ? _closeDropdown : _openDropdown,
borderRadius: BorderRadius.circular(8),
hoverColor: Colors.grey[50],
child: Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
alignment: Alignment.centerLeft,
child: Row(
children: [
Expanded(
child: Text(
_getCurrentSceneInfo(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(
_isDropdownOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 18,
color: _isDropdownOpen ? Colors.blue : Colors.grey[600],
),
],
),
),
),
),
);
}
}
/// 自定义下拉菜单覆盖层
class _DropdownOverlay extends StatefulWidget {
const _DropdownOverlay({
required this.buttonPosition,
required this.buttonSize,
required this.items,
required this.activeSceneId,
required this.onItemSelected,
required this.onClose,
});
final Offset buttonPosition;
final Size buttonSize;
final List<_SceneItem> items;
final String? activeSceneId;
final Function(String sceneId, String actId, String chapterId) onItemSelected;
final VoidCallback onClose;
@override
State<_DropdownOverlay> createState() => _DropdownOverlayState();
}
class _DropdownOverlayState extends State<_DropdownOverlay> {
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 计算下拉菜单的位置和大小
final screenSize = MediaQuery.of(context).size;
final maxHeight = 300.0; // 增加最大高度
// 计算菜单位置,确保上边缘紧贴按钮下边缘
final menuTop = widget.buttonPosition.dy + widget.buttonSize.height;
final menuLeft = widget.buttonPosition.dx;
final menuWidth = widget.buttonSize.width;
// 确保菜单不会超出屏幕
final availableHeight = screenSize.height - menuTop - 20;
final menuHeight = maxHeight < availableHeight ? maxHeight : availableHeight;
return Stack(
children: [
// 背景遮罩,点击关闭下拉菜单
Positioned.fill(
child: GestureDetector(
onTap: widget.onClose,
child: Container(
color: Colors.transparent,
),
),
),
// 下拉菜单
Positioned(
left: menuLeft,
top: menuTop,
width: menuWidth,
height: menuHeight,
child: Material(
elevation: 12, // 增加阴影
borderRadius: BorderRadius.circular(8),
shadowColor: Colors.black.withOpacity(0.15),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[200]!, width: 1),
),
child: Column(
children: [
// 显示场景数量限制提示
if (widget.items.where((item) => item.type == _SceneItemType.scene).length > 200)
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange[50],
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 14,
color: Colors.orange[600],
),
const SizedBox(width: 6),
const Text(
'场景数量过多仅显示前200个场景',
style: TextStyle(
fontSize: 11,
color: Colors.orange,
),
),
],
),
),
// 场景列表
Expanded(
child: Theme(
data: Theme.of(context).copyWith(
scrollbarTheme: ScrollbarThemeData(
thickness: MaterialStateProperty.all(6),
radius: const Radius.circular(3),
thumbColor: MaterialStateProperty.all(Colors.grey[400]),
trackColor: MaterialStateProperty.all(Colors.grey[200]),
),
),
child: Scrollbar(
controller: _scrollController,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
return _buildDropdownItem(item);
},
),
),
),
),
],
),
),
),
),
],
);
}
Widget _buildDropdownItem(_SceneItem item) {
switch (item.type) {
case _SceneItemType.actHeader:
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.centerLeft,
child: Text(
item.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
color: Colors.black87,
),
),
);
case _SceneItemType.chapterHeader:
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
alignment: Alignment.centerLeft,
child: Text(
item.title,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 10,
color: Colors.black54,
),
),
);
case _SceneItemType.scene:
final isSelected = item.sceneId == widget.activeSceneId;
final hasSummary = item.sceneSummary != null && item.sceneSummary!.isNotEmpty;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
),
child: Material(
color: isSelected ? Colors.blue[50] : Colors.transparent,
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: () {
widget.onItemSelected(item.sceneId!, item.actId, item.chapterId!);
},
borderRadius: BorderRadius.circular(6),
hoverColor: isSelected ? Colors.blue[100] : Colors.grey[100],
splashColor: isSelected ? Colors.blue[200] : Colors.grey[200],
child: Container(
// 动态高度:有摘要时使用更大的高度
height: hasSummary ? 44 : 30,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 12),
// 场景序号容器,固定在顶部对齐
Container(
margin: const EdgeInsets.only(top: 1),
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.grey[700],
borderRadius: BorderRadius.circular(6),
boxShadow: isSelected ? [
BoxShadow(
color: Colors.blue.withOpacity(0.3),
blurRadius: 2,
offset: const Offset(0, 1),
),
] : null,
),
child: Center(
child: Text(
'${item.sceneIndex! + 1}',
style: const TextStyle(
fontSize: 7,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.title,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.blue[700] : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
if (hasSummary) ...[
const SizedBox(height: 2),
Text(
item.sceneSummary!,
style: TextStyle(
fontSize: 9,
color: isSelected ? Colors.blue[600] : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
],
),
),
],
),
),
),
),
);
}
}
}
/// 场景项类型枚举
enum _SceneItemType {
actHeader,
chapterHeader,
scene,
}
/// 场景项数据类
class _SceneItem {
final _SceneItemType type;
final String title;
final String actId;
final String? chapterId;
final String? sceneId;
final int? sceneIndex;
final String? sceneSummary;
_SceneItem({
required this.type,
required this.title,
required this.actId,
this.chapterId,
this.sceneId,
this.sceneIndex,
this.sceneSummary,
});
}