Files
MaliangAINovalWriter/AINoval/lib/screens/editor/components/editor_sidebar.dart
2025-09-10 00:07:52 +08:00

665 lines
24 KiB
Dart
Raw 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:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/models/novel_summary.dart';
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_sidebar.dart';
import 'package:ainoval/screens/editor/widgets/snippet_list_tab.dart';
import 'package:ainoval/screens/editor/widgets/snippet_edit_form.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
import 'package:ainoval/blocs/setting/setting_bloc.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/user_avatar_menu.dart';
import 'package:ainoval/screens/subscription/subscription_screen.dart';
import 'chapter_directory_tab.dart';
/// 保持存活状态的包装器组件
class _KeepAliveWrapper extends StatefulWidget {
final Widget child;
const _KeepAliveWrapper({required this.child});
@override
State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<_KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
class EditorSidebar extends StatefulWidget {
const EditorSidebar({
super.key,
required this.novel,
required this.tabController,
this.onOpenAIChat,
this.onOpenSettings,
this.onToggleSidebar,
this.onAdjustWidth,
});
final NovelSummary novel;
final TabController tabController;
final VoidCallback? onOpenAIChat;
final VoidCallback? onOpenSettings;
final VoidCallback? onToggleSidebar;
final VoidCallback? onAdjustWidth;
@override
State<EditorSidebar> createState() => _EditorSidebarState();
}
class _EditorSidebarState extends State<EditorSidebar> {
final TextEditingController _searchController = TextEditingController();
// String _selectedMode = 'codex';
// 片段列表操作回调
VoidCallback? _refreshSnippetList; // used via callbacks wiring
Function(NovelSnippet)? _addSnippetToList; // used via callbacks wiring
Function(NovelSnippet)? _updateSnippetInList; // used via callbacks wiring
Function(String)? _removeSnippetFromList; // used via callbacks wiring
String _selectedBottomBarItem = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 🚀 添加重建监控日志 - 现在应该不会频繁触发了
AppLogger.d('EditorSidebar', '🔄 EditorSidebar.build() 被调用 - 监控重建');
final theme = Theme.of(context);
// 🚀 优化直接使用父级提供的SettingBloc实例避免重复创建
final settingSidebarWidget = BlocProvider.value(
value: context.read<SettingBloc>(),
child: NovelSettingSidebar(novelId: widget.novel.id),
);
return Material(
color: WebTheme.getBackgroundColor(context),
child: Container(
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context),
border: Border(
right: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1.0,
),
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.shadow.withOpacity(0.03),
blurRadius: 5,
offset: const Offset(0, 0),
),
],
),
child: Column(
children: [
// 顶部应用栏
_buildAppBar(theme),
// 标签页导航
_buildTabBar(theme),
// 标签页内容
Expanded(
child: TabBarView(
controller: widget.tabController,
children: [
// 设定库标签页替换原来的Codex标签页
settingSidebarWidget,
// 片段标签页
Builder(
builder: (context) {
return SnippetListTab(
key: ValueKey('snippet_list_${widget.novel.id}'),
novel: widget.novel,
onRefreshCallbackChanged: (callback) {
_refreshSnippetList = callback;
},
onAddSnippetCallbackChanged: (callback) {
_addSnippetToList = callback;
},
onUpdateSnippetCallbackChanged: (callback) {
_updateSnippetInList = callback;
},
onRemoveSnippetCallbackChanged: (callback) {
_removeSnippetFromList = callback;
},
onSnippetTap: (snippet) {
FloatingSnippetEditor.show(
context: context,
snippet: snippet,
onSaved: (updatedSnippet) {
// 判断是创建还是更新
if (snippet.id.isEmpty) {
// 创建新片段:直接添加到列表
_addSnippetToList?.call(updatedSnippet);
} else {
// 更新现有片段:更新列表中的片段
_updateSnippetInList?.call(updatedSnippet);
}
},
onDeleted: (snippetId) {
// 删除片段:从列表中移除
_removeSnippetFromList?.call(snippetId);
},
);
},
);
},
),
// 章节目录标签页
Builder(
builder: (context) {
// 确保在有Provider访问权限的新BuildContext中构建ChapterDirectoryTab
return Consumer<EditorScreenController>(
builder: (context, controller, child) {
return ChapterDirectoryTab(novel: widget.novel);
},
);
},
),
// 添加AI生成选项
_buildPlaceholderTab(
icon: Icons.auto_awesome,
text: 'AI生成功能开发中'),
],
),
),
// 底部导航栏
_buildBottomBar(theme),
],
),
),
);
}
PreferredSizeWidget _buildAppBar(ThemeData theme) {
return AppBar(
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: WebTheme.getBackgroundColor(context),
automaticallyImplyLeading: false,
titleSpacing: 0,
toolbarHeight: 60, // 增加高度以适应新设计
title: Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
// 返回按钮
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.pop(context);
},
child: Icon(
Icons.arrow_back,
size: 18,
color: WebTheme.getTextColor(context),
),
),
),
),
const SizedBox(width: 12),
// 可点击的设置和小说信息区域
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onOpenSettings,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// 设置图标
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.settings,
size: 16,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 12),
// 小说标题和作者信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.novel.title,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
color: WebTheme.getTextColor(context),
height: 1.1,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
Text(
widget.novel.author ?? 'Erminia Osteen',
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.0,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
),
],
),
),
),
),
),
const SizedBox(width: 8),
// 右侧操作按钮
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 侧边栏折叠按钮
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onToggleSidebar,
child: Icon(
Icons.menu_open,
size: 18,
color: WebTheme.getTextColor(context),
),
),
),
),
const SizedBox(width: 8),
// 调整宽度按钮
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onAdjustWidth,
child: Icon(
Icons.more_horiz,
size: 18,
color: WebTheme.getTextColor(context),
),
),
),
),
],
),
],
),
),
);
}
Widget _buildTabBar(ThemeData theme) {
return Container(
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context),
border: Border(
bottom: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1.0,
),
),
),
child: TabBar(
controller: widget.tabController,
labelColor: WebTheme.getTextColor(context),
unselectedLabelColor: WebTheme.getSecondaryTextColor(context),
indicatorColor: WebTheme.getTextColor(context),
indicatorWeight: 2.0, // 减小指示器粗细
indicatorSize: TabBarIndicatorSize.label,
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13, // 减小字体大小
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 13, // 减小字体大小
),
dividerColor: Colors.transparent,
isScrollable: false, // 确保不可滚动,平均分配空间
labelPadding: const EdgeInsets.symmetric(horizontal: 2.0), // 减小标签内边距
padding: const EdgeInsets.symmetric(horizontal: 2.0), // 减小整体内边距
tabs: const [
Tab(
icon: Icon(Icons.inventory_2_outlined, size: 18), // 修改图标来反映设定功能
text: '设定库', // 改为"设定库"
height: 60, // 与顶部 AppBar 高度一致
),
Tab(
icon: Icon(Icons.bookmark_border_outlined, size: 18), // 减小图标大小
text: '片段',
height: 60, // 与顶部 AppBar 高度一致
),
Tab(
icon: Icon(Icons.menu_outlined, size: 18), // 目录图标
text: '章节目录', // "章节目录"
height: 60, // 与顶部 AppBar 高度一致
),
Tab(
icon: Icon(Icons.auto_awesome, size: 18), // AI生成图标
text: 'AI生成',
height: 60, // 与顶部 AppBar 高度一致
),
],
),
);
}
Widget _buildPlaceholderTab({required IconData icon, required String text}) {
return _KeepAliveWrapper(
child: Container(
color: WebTheme.getSurfaceColor(context),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 48, color: WebTheme.getSecondaryTextColor(context)),
const SizedBox(height: 16),
Text(
text,
style: TextStyle(fontSize: 16, color: WebTheme.getSecondaryTextColor(context)),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
Widget _buildBottomBar(ThemeData theme) {
return LayoutBuilder(
builder: (context, constraints) {
// 当侧边栏宽度较小时,仅显示图标;宽度充足时显示图标+文字
final bool isCompact = constraints.maxWidth < 240;
return Container(
height: 60,
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context),
border: Border(
top: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1.0,
),
),
),
child: Row(
children: [
// 用户头像菜单
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: UserAvatarMenu(
size: 16,
showName: false,
onMySubscription: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SubscriptionScreen()),
);
},
onOpenSettings: widget.onOpenSettings,
onProfile: widget.onOpenSettings, // 个人资料也使用设置面板
onAccountSettings: widget.onOpenSettings, // 账户设置使用设置面板
),
),
// 使用Expanded包裹SingleChildScrollView来确保按钮能够根据宽度滚动/自适应
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 帮助按钮
_buildBottomBarItem(
icon: Icons.help_outline,
label: 'Help',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Help',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Help';
});
// TODO: 实现帮助功能
},
),
// 提示按钮
_buildBottomBarItem(
icon: Icons.lightbulb_outline,
label: 'Prompts',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Prompts',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Prompts';
});
final controller = Provider.of<EditorScreenController>(context, listen: false);
controller.togglePromptView();
},
),
// 导出按钮
_buildBottomBarItem(
icon: Icons.download_outlined,
label: 'Export',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Export',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Export';
});
// TODO: 实现导出功能
},
),
// 保存按钮
_buildBottomBarItem(
icon: Icons.save_outlined,
label: 'Save',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Save',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Save';
});
// 手动保存触发与自动保存一致的SaveContent事件
try {
final controller = Provider.of<EditorScreenController>(context, listen: false);
controller.editorBloc.add(const SaveContent());
} catch (e) {
AppLogger.w('EditorSidebar', '手动保存触发失败', e);
}
},
),
],
),
),
),
],
),
);
},
);
}
/// 构建底部栏单个按钮
Widget _buildBottomBarItem({
required IconData icon,
required String label,
bool showLabel = true,
bool selected = false,
required VoidCallback onTap,
}) {
final isDark = WebTheme.isDarkMode(context);
// 修复选中状态的颜色配置,确保在暗黑模式下文字可见
final Color foregroundColor;
final Color backgroundColor;
if (selected) {
if (isDark) {
// 暗黑模式下:选中时使用深灰背景+白字
backgroundColor = WebTheme.darkGrey700;
foregroundColor = WebTheme.white;
} else {
// 亮色模式下:选中时使用深色背景+白字
backgroundColor = WebTheme.grey800;
foregroundColor = WebTheme.white;
}
} else {
// 未选中时:透明背景+半透明文字
backgroundColor = Colors.transparent;
foregroundColor = WebTheme.getTextColor(context).withOpacity(0.7);
}
return Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(6),
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: foregroundColor,
),
if (showLabel) ...[
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: foregroundColor,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
);
}
}
class _CodexEmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // 左对齐
children: [
Text(
'YOUR CODEX IS EMPTY',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 8),
Text(
'The Codex stores information about the world your story takes place in, its inhabitants and more.',
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
fontSize: 14,
height: 1.5,
),
),
const SizedBox(height: 12),
InkWell(
onTap: () {
// 该点击应执行与"+ New Entry"按钮相同的操作
},
child: Text(
'→ Create a new entry by clicking the button above.',
style: TextStyle(
color: WebTheme.getTextColor(context),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}