Files
nj/app/lib/features/editor/views/editor_page.dart
iven 9fce34f4ef
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 修复 4 个 Flutter 交互问题
1. 首页数据不刷新 — JournalRepository 添加 onJournalChanged
   Stream 变更通知,HomeBloc 订阅后自动刷新
2. 画笔再次点击不弹出面板 — 添加 ToolReactivated 事件,
   工具栏检测已激活工具时发出重新激活信号
3. 钢笔铅笔效果一样 — 调整 perfect_freehand 参数
   (pen: size 10/smooth 0.65, pencil: size 3/smooth 0.35)
4. 橡皮擦不生效 — ActiveStrokePainter 橡皮擦模式绘制
   半透明灰色反馈,笔画完成后 setState 触发 Layer 1 重绘
5. 贴纸文字无法缩放 — DraggableElement 用 Scale 手势
   替换 Pan 手势,支持双指缩放和旋转
2026-06-04 00:05:22 +08:00

1073 lines
33 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.
// 手账编辑器页面 — 三层 Stack 架构
//
// Layer 1 (底层): HandwritingCanvas — 手写画布
// Layer 2 (中层): DraggableElements — 贴纸/照片/文字元素
// Layer 3 (顶层): EditorToolbar — 底部工具栏 + 顶栏操作
//
// 交互逻辑:
// - 画笔模式 → Layer 1 接收手势Layer 2 透传
// - 选择模式 → Layer 2 接收手势Layer 1 透传
// - 工具栏 → 始终在最顶层
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.dart';
import '../../../core/theme/app_colors.dart';
import '../../../data/models/journal_element.dart';
import '../../../data/models/journal_entry.dart' show JournalEntry, Mood;
import '../../../data/repositories/journal_repository.dart';
import '../../../data/repositories/class_repository.dart';
import '../../../data/remote/api_client.dart';
import '../../../data/services/sync_engine.dart';
import '../../auth/bloc/auth_bloc.dart';
import '../bloc/editor_bloc.dart';
import '../widgets/comment_list_sheet.dart';
import '../widgets/handwriting_canvas.dart';
import '../widgets/stroke_model.dart';
import '../widgets/draggable_element.dart';
import '../widgets/editor_toolbar.dart';
import '../widgets/text_input_overlay.dart';
import '../widgets/image_picker_handler.dart';
import '../widgets/sticker_picker_sheet.dart';
import '../widgets/share_bottom_sheet.dart';
import '../widgets/tag_panel.dart';
import '../widgets/brush_panel.dart';
import '../widgets/dot_grid_painter.dart';
/// 手账编辑器页面
class EditorPage extends StatelessWidget {
final String? journalId;
final String? templateId;
const EditorPage({super.key, this.journalId, this.templateId});
@override
Widget build(BuildContext context) {
// 从 Provider 树获取 JournalRepositoryIsarJournalRepository
final repo = context.read<JournalRepository>();
// 从 Provider 树获取 SyncEngine同步到后端
final syncEngine = context.read<SyncEngine>();
// 可变闭包变量:跟踪已保存的日记 ID
// 新建日记首次保存后赋值,后续自动更新使用此 ID
String? savedJournalId = journalId;
return BlocProvider(
create: (_) => EditorBloc(
onSave: (state) async {
try {
// 从 AuthBloc 获取真实用户 ID
String authorId = 'local';
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
authorId = authState.user.id;
}
await _persistState(
repo, state, (id) => savedJournalId = id, savedJournalId,
syncEngine: syncEngine,
authorId: authorId,
);
} catch (e) {
debugPrint('自动保存失败: $e');
}
},
),
child: _EditorView(
journalId: journalId,
templateId: templateId,
savedJournalId: savedJournalId,
repo: repo,
onSaveComplete: () {
_showShareSheetAndNavigate(context, repo, savedJournalId);
},
),
);
}
/// 持久化编辑器状态到 Isar并同步到后端
///
/// 策略:
/// - 首次保存savedJournalId == null→ createJournal + addElement
/// - 后续保存 → updateJournal + upsert 元素
/// - 笔画序列化为 handwriting_ref 元素
/// - 保存成功后入队 SyncEngine 等待网络同步
Future<void> _persistState(
JournalRepository repo,
EditorState state,
void Function(String) setId,
String? savedJournalId, {
required SyncEngine syncEngine,
String authorId = 'local',
}) async {
final now = DateTime.now();
if (savedJournalId == null) {
// --- 新建日记 ---
final entry = JournalEntry.create(
authorId: authorId,
title: '${now.month}${now.day}日的日记',
date: now,
);
await repo.createJournal(entry);
setId(entry.id);
// 保存笔画
if (state.strokes.isNotEmpty) {
await _saveStrokesAsElement(repo, entry.id, state.strokes);
}
// 保存其他元素
for (final element in state.elements) {
await repo.addElement(element.copyWith(journalId: entry.id));
}
// 入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation(
id: entry.id,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: entry.toJson(),
version: entry.version,
createdAt: now,
));
} else {
// --- 更新已有日记 ---
final existing = await repo.getJournal(savedJournalId);
if (existing != null) {
await repo.updateJournal(existing);
// 入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation(
id: existing.id,
type: SyncOperationType.update,
endpoint: '/diary/journals/${existing.id}',
data: existing.toJson(),
version: existing.version,
createdAt: now,
));
}
// 更新笔画
if (state.strokes.isNotEmpty) {
await _saveStrokesAsElement(repo, savedJournalId, state.strokes);
}
// upsert 元素(先尝试更新,失败则新建)
for (final element in state.elements) {
try {
await repo.updateElement(element);
} catch (_) {
await repo.addElement(element);
}
}
}
}
/// 将笔画列表序列化为 handwriting_ref 元素并保存
///
/// 每次保存都替换整个笔画集Phase 1 简化策略)。
Future<void> _saveStrokesAsElement(
JournalRepository repo,
String journalId,
List<Stroke> strokes,
) async {
final now = DateTime.now();
final strokesJson = strokes.map((s) => s.toJson()).toList();
final element = JournalElement(
id: '${journalId}_strokes', // 固定 ID保证每次覆盖
journalId: journalId,
elementType: ElementType.handwritingRef,
content: {
'strokes': strokesJson,
'strokeCount': strokes.length,
},
version: 1,
createdAt: now,
updatedAt: now,
);
try {
await repo.updateElement(element);
} catch (_) {
await repo.addElement(element);
}
}
/// 显示分享面板并在用户选择后导航
static Future<void> _showShareSheetAndNavigate(
BuildContext context,
JournalRepository repo,
String? savedJournalId,
) async {
// 尝试获取用户的班级信息
String? userClassId;
String userClassName = '我的班级';
try {
// 从 AuthBloc 获取用户关联的班级
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
final classRepo = context.read<ClassRepository>();
final classes = await classRepo.getMyClasses();
if (classes.isNotEmpty) {
userClassId = classes.first.id;
userClassName = classes.first.name;
}
}
} catch (e) {
debugPrint('获取班级信息失败: $e');
}
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) => ShareBottomSheet(
classId: userClassId,
className: userClassName,
onDecision: (shareToClass) async {
// 更新日记的 sharedToClass 状态
if (savedJournalId != null) {
try {
final entry = await repo.getJournal(savedJournalId);
if (entry != null) {
await repo.updateJournal(
entry.copyWith(sharedToClass: shareToClass),
);
}
} catch (e) {
debugPrint('更新分享状态失败: $e');
}
}
// 导航返回
if (!context.mounted) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/home');
}
},
),
);
}
}
class _EditorView extends StatefulWidget {
final String? journalId;
final String? templateId;
final String? savedJournalId;
final JournalRepository repo;
final VoidCallback onSaveComplete;
const _EditorView({
this.journalId,
this.templateId,
this.savedJournalId,
required this.repo,
required this.onSaveComplete,
});
@override
State<_EditorView> createState() => _EditorViewState();
}
class _EditorViewState extends State<_EditorView> {
@override
void initState() {
super.initState();
// 当 journalId 非空时,从 Isar 加载已有日记数据
if (widget.journalId != null) {
_loadExistingJournal(widget.journalId!);
}
}
/// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原
Future<void> _loadExistingJournal(String id) async {
try {
final entry = await widget.repo.getJournal(id);
if (entry == null || !mounted) return;
// 加载元素(含笔画)
final elements = await widget.repo.getElements(id);
if (!mounted) return;
// 从 handwriting_ref 元素中反序列化笔画
List<Stroke> strokes = [];
final strokesElement = elements
.where((e) => e.elementType == ElementType.handwritingRef)
.firstOrNull;
if (strokesElement != null) {
final strokesData = strokesElement.content['strokes'];
if (strokesData is List) {
strokes = strokesData
.map((s) => Stroke.fromJson(s as Map<String, dynamic>))
.toList();
}
}
// 过滤掉 handwriting_ref 元素(笔画单独管理)
final otherElements = elements
.where((e) => e.elementType != ElementType.handwritingRef)
.toList();
// 原子加载 — 一次 dispatch 还原所有状态
context.read<EditorBloc>().add(LoadJournal(
title: entry.title,
mood: entry.mood,
tags: entry.tags,
strokes: strokes,
elements: otherElements,
lastSavedAt: entry.updatedAt,
));
} catch (e) {
debugPrint('加载日记数据失败: $e');
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: Column(
children: [
// 顶栏(自带状态栏安全区)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return _buildTopBar(context, state);
},
),
// 编辑区域(三层 Stack
Expanded(
child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return _EditorStack(state: state, journalId: widget.journalId);
},
),
),
// 底部工具栏(自带底部安全区)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
},
),
],
),
);
}
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成
Widget _buildTopBar(BuildContext context, EditorState state) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
),
),
child: Column(
children: [
// 主顶栏行 (44px)
SizedBox(
height: 44,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
// 返回按钮
IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => _handleBack(context),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
iconSize: 22,
),
// 日期显示
Expanded(
child: Center(
child: Text(
_formatDate(state),
style: TextStyle(
fontFamily: 'Quicksand',
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
),
),
// 撤销
IconButton(
icon: const Icon(Icons.undo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Undo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 重做
IconButton(
icon: const Icon(Icons.redo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Redo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 自动保存状态
_buildAutosaveIndicator(state),
// 标签按钮
IconButton(
icon: const Icon(Icons.sell_rounded, size: 18),
onPressed: () => _showTagPanel(context, state),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 评语按钮(仅已有日记显示)
if (widget.journalId != null)
IconButton(
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
onPressed: () => _showComments(context),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 完成/保存按钮
Padding(
padding: const EdgeInsets.only(left: 4),
child: FilledButton.tonal(
onPressed: () => _handleSave(context, state),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('完成', style: TextStyle(fontSize: 14)),
),
),
],
),
),
),
// 日期 + 心情条 (40px)
_buildDateMoodStrip(context, state),
],
),
);
}
/// 返回处理
void _handleBack(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
context.go('/home');
}
}
/// 保存处理
void _handleSave(BuildContext context, EditorState state) {
widget.onSaveComplete();
}
/// 显示评论列表
void _showComments(BuildContext context) {
final journalId = widget.journalId;
if (journalId == null) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => CommentListSheet(
journalId: journalId,
apiClient: context.read<ApiClient>(),
),
);
}
/// 格式化日期显示
String _formatDate(EditorState state) {
final now = DateTime.now();
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return '${now.month}${now.day}日 · ${weekdays[now.weekday - 1]}';
}
/// 自动保存状态指示器
Widget _buildAutosaveIndicator(EditorState state) {
if (state.lastSavedAt == null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'未保存',
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
'已保存',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
],
),
);
}
/// 日期时间 + 心情选择条
Widget _buildDateMoodStrip(BuildContext context, EditorState state) {
final now = DateTime.now();
final timeStr =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
final moods = [
(Mood.happy, '😊'),
(Mood.calm, '😐'),
(Mood.sad, '😢'),
(Mood.angry, '😡'),
(Mood.thinking, '🤔'),
];
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: Theme.of(context).colorScheme.surface,
child: Row(
children: [
Text(
timeStr,
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
// 心情快捷按钮
const Spacer(),
...moods.map((m) {
final isSelected = state.selectedMood == m.$1;
return GestureDetector(
onTap: () =>
context.read<EditorBloc>().add(MoodChanged(m.$1)),
child: Container(
width: 24,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: AppColors.accent, width: 1.5)
: null,
color: isSelected ? const Color(0xFFFFF3E6) : null,
),
alignment: Alignment.center,
child: Text(m.$2, style: const TextStyle(fontSize: 12)),
),
);
}),
],
),
);
}
/// 显示标签面板
void _showTagPanel(BuildContext context, EditorState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => BlocProvider.value(
value: context.read<EditorBloc>(),
child: BlocBuilder<EditorBloc, EditorState>(
builder: (ctx, state) => TagPanel(
selectedTags: state.tags,
onTagAdded: (tag) {
context.read<EditorBloc>().add(TagAdded(tag));
},
onTagRemoved: (tag) {
context.read<EditorBloc>().add(TagRemoved(tag));
},
),
),
),
);
}
}
// ============================================================
// 编辑器三层 Stack
// ============================================================
/// 编辑器 Stack — 三层叠加结构
///
/// Layer 1 (底层): HandwritingCanvas
/// Layer 2 (中层): 可拖拽元素(贴纸/照片/文字)
/// Layer 3 (顶层): 由 _EditorView 中的工具栏处理
class _EditorStack extends StatefulWidget {
final EditorState state;
final String? journalId;
const _EditorStack({required this.state, this.journalId});
@override
State<_EditorStack> createState() => _EditorStackState();
}
class _EditorStackState extends State<_EditorStack> {
EditorTool? _lastTool;
int _lastReactivatedAt = 0;
late final TextEditingController _titleController;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.state.title);
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant _EditorStack oldWidget) {
super.didUpdateWidget(oldWidget);
// 同步标题输入框LoadJournal 更新 state.title 时 controller 需要跟随)
if (widget.state.title != oldWidget.state.title &&
widget.state.title != _titleController.text) {
_titleController.text = widget.state.title;
}
final currentTool = widget.state.activeTool;
// 防止重复弹窗:只在工具切换时触发
if (currentTool != _lastTool) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
switch (currentTool) {
// 贴纸工具 → 弹出贴纸选择面板
case EditorTool.sticker:
_showStickerPicker();
// 画笔工具 → 弹出画笔设置面板
case EditorTool.brush:
_showBrushPanel();
// 模板工具 → 导航到模板页
case EditorTool.template:
context.go('/templates');
// 更多工具 → 弹出分享/导出选项
case EditorTool.more:
_showMoreSheet();
default:
break;
}
});
}
_lastTool = currentTool;
// 工具重新激活(再次点击已选中的工具)→ 重新弹出面板
final reactivatedAt = widget.state.toolReactivatedAt;
if (reactivatedAt != _lastReactivatedAt) {
_lastReactivatedAt = reactivatedAt;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
switch (currentTool) {
case EditorTool.brush:
_showBrushPanel();
case EditorTool.sticker:
_showStickerPicker();
case EditorTool.more:
_showMoreSheet();
default:
break;
}
});
}
}
/// 显示贴纸选择底部面板
void _showStickerPicker() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => StickerPickerSheet(
onStickerSelected: (emoji) {
final center = Offset(
MediaQuery.of(context).size.width / 2 - 24,
MediaQuery.of(context).size.height / 3,
);
context.read<EditorBloc>().add(ElementAdded(
JournalElement.createSticker(
journalId: widget.journalId ?? '',
emoji: emoji,
position: center,
),
));
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
},
),
);
}
/// 显示画笔设置底部面板
void _showBrushPanel() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => BlocProvider.value(
value: context.read<EditorBloc>(),
child: BlocBuilder<EditorBloc, EditorState>(
builder: (ctx, state) => BrushPanel(
activeBrushType: state.brushType,
activeColor: state.brushColor,
activeWidth: state.brushWidth,
activeOpacity: state.brushOpacity,
onBrushTypeChanged: (type) => context.read<EditorBloc>().add(
BrushChanged(
type: type,
color: state.brushColor,
width: state.brushWidth,
),
),
onColorChanged: (color) => context.read<EditorBloc>().add(
BrushChanged(
type: state.brushType,
color: color,
width: state.brushWidth,
),
),
onWidthChanged: (width) => context.read<EditorBloc>().add(
BrushChanged(
type: state.brushType,
color: state.brushColor,
width: width,
),
),
onOpacityChanged: (opacity) {
// Phase 1 简化opacity 仅在 marker 模式下生效
// 暂无 opacity 事件,后续扩展
},
),
),
),
);
}
/// 显示更多选项底部面板(分享/导出/清除)
void _showMoreSheet() {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 拖拽指示条
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
),
// 清除画布
ListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: const Text('清除画布'),
onTap: () {
Navigator.pop(context);
context.read<EditorBloc>().add(ClearCanvas());
},
),
// 分享
ListTile(
leading: const Icon(Icons.share_rounded),
title: const Text('分享日记'),
onTap: () {
Navigator.pop(context);
// 委托给外层的分享逻辑
},
),
const SizedBox(height: 16),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final state = widget.state;
final colorScheme = Theme.of(context).colorScheme;
return Stack(
fit: StackFit.expand,
children: [
// Layer 0: 点阵背景(最底层)
CustomPaint(
painter: const DotGridPainter(),
size: Size.infinite,
),
// Layer 1: 手写画布 + 内嵌标题
IgnorePointer(
ignoring: !state.isDrawingMode,
child: Column(
children: [
// 内嵌标题输入框
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField(
controller: _titleController,
style: TextStyle(
fontFamily: 'Quicksand',
fontSize: 18,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: '给日记起个标题吧...',
hintStyle: TextStyle(
fontFamily: 'Quicksand',
fontSize: 18,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withValues(alpha: 0.25),
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
onChanged: (value) {
context.read<EditorBloc>().add(TitleChanged(value));
},
),
),
// 画布区域
Expanded(
child: HandwritingCanvas(
brushType: state.brushType,
brushColor: state.brushColor,
brushWidth: state.brushWidth,
strokes: state.strokes,
onStrokeCompleted: (stroke) {
context.read<EditorBloc>().add(StrokeCompleted(stroke));
},
),
),
],
),
),
// Layer 2: 可拖拽元素(中层)
if (state.elements.isNotEmpty)
_buildElementLayer(context, state),
// 文字输入覆盖层(文字工具激活时显示)
if (state.activeTool == EditorTool.text)
TextInputOverlay(
onConfirmed: (text, fontSize, fontColor) {
final center = Offset(
MediaQuery.of(context).size.width / 2 - 80,
MediaQuery.of(context).size.height / 3,
);
context.read<EditorBloc>().add(ElementAdded(
JournalElement.createText(
journalId: widget.journalId ?? '',
text: text,
position: center,
fontSize: fontSize,
fontColor: fontColor,
),
));
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
},
onCancelled: () {
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
},
),
// 图片选择覆盖层(图片工具激活时显示)
if (state.activeTool == EditorTool.photo)
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ImageSourceButton(
icon: Icons.camera_alt_outlined,
label: '拍照',
onTap: () => _pickImage(fromCamera: true),
),
const SizedBox(width: 24),
_ImageSourceButton(
icon: Icons.photo_library_outlined,
label: '从相册',
onTap: () => _pickImage(fromCamera: false),
),
],
),
),
// 空状态提示
if (state.strokes.isEmpty && state.elements.isEmpty && state.activeTool == EditorTool.select)
_buildEmptyHint(context),
],
);
}
/// 图片选择逻辑
Future<void> _pickImage({required bool fromCamera}) async {
final filePath = await ImagePickerHandler.pickImage(fromCamera: fromCamera);
if (filePath == null || !mounted) return;
final thumbnailPath = await ImagePickerHandler.generateThumbnail(filePath);
if (!mounted) return;
final center = Offset(
MediaQuery.of(context).size.width / 2 - 80,
MediaQuery.of(context).size.height / 3,
);
context.read<EditorBloc>().add(ElementAdded(
JournalElement.createImage(
journalId: widget.journalId ?? '',
filePath: filePath,
position: center,
thumbnailPath: thumbnailPath,
),
));
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
}
/// 元素层 — 所有日记元素叠加显示
Widget _buildElementLayer(BuildContext context, EditorState state) {
// 按 zIndex 排序
final sorted = List<JournalElement>.from(state.elements)
..sort((a, b) => a.zIndex.compareTo(b.zIndex));
return Stack(
children: sorted.map((element) {
return DraggableElement(
key: ValueKey(element.id),
element: element,
isSelected: state.selectedElementId == element.id,
onTap: (id) {
context.read<EditorBloc>().add(ElementSelected(id));
},
onMoved: (id, x, y) {
context.read<EditorBloc>().add(ElementMoved(
elementId: id,
positionX: x,
positionY: y,
));
},
onResized: (id, w, h) {
context.read<EditorBloc>().add(ElementResized(
elementId: id,
width: w,
height: h,
));
},
onRotated: (id, r) {
context.read<EditorBloc>().add(ElementRotated(
elementId: id,
rotation: r,
));
},
onDeleted: (id) {
context.read<EditorBloc>().add(ElementRemoved(id));
},
);
}).toList(),
);
}
/// 空状态提示
Widget _buildEmptyHint(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.draw_rounded,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15),
),
const SizedBox(height: DesignTokens.spacing12),
Text(
'在这里开始书写吧 ✏️',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
),
],
),
);
}
}
/// 图片来源按钮 — 拍照 / 从相册
class _ImageSourceButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ImageSourceButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 32, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 8),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
}
}