Files
nj/app/lib/features/editor/views/editor_page.dart
iven bb388ed8ff
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 日记可见性修复 — 私密日记仅本地 + Web 端 ID 修复 + 分享按钮
问题修复:
1. Web端保存的日记看不到:createJournal 返回值未捕获,server ID 丢失导致
   后续元素保存用错 ID。现在使用 saved.id 贯穿全部操作。
2. 管理端看不到新建日记:后端 list_journals 添加 is_private 过滤,admin/teacher
   查看他人日记时排除私密日记。
3. RemoteJournalRepository 添加 onJournalChanged 变更通知流,HomeBloc 可自动刷新。
4. SyncEngine(native + web)enqueue 添加 is_private 防御性检查,私密日记不入队。
5. 编辑器 _persistState 条件入队:仅非私密日记同步到后端。
6. 分享流程改造:首次从私密变为公开时入队 create 操作上传。
7. 日记卡片添加可见性标签(仅自己可见/班级可见/公开),私密日记可点击分享。
8. 首页 _sharePrivateJournal 弹出 ShareBottomSheet 主动分享。
2026-06-04 12:03:24 +08:00

1131 lines
36 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 StatefulWidget {
final String? journalId;
final String? templateId;
const EditorPage({super.key, this.journalId, this.templateId});
@override
State<EditorPage> createState() => _EditorPageState();
}
class _EditorPageState extends State<EditorPage> {
/// 跟踪已保存的日记 ID — 新建日记首次保存后赋值
String? _savedJournalId;
@override
void initState() {
super.initState();
_savedJournalId = widget.journalId;
}
@override
Widget build(BuildContext context) {
// 从 Provider 树获取 JournalRepositoryIsarJournalRepository
final repo = context.read<JournalRepository>();
// 从 Provider 树获取 SyncEngine同步到后端
final syncEngine = context.read<SyncEngine>();
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: widget.journalId,
templateId: widget.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,
);
// 保存到仓库Web=远程API原生=Isar本地
// 远程仓库返回服务端生成的 ID必须使用返回值
final saved = await repo.createJournal(entry);
final journalId = saved.id;
setId(journalId);
// 保存笔画 — 使用 saved.id与仓库一致
if (state.strokes.isNotEmpty) {
await _saveStrokesAsElement(repo, journalId, state.strokes);
}
// 保存其他元素
for (final element in state.elements) {
await repo.addElement(element.copyWith(journalId: journalId));
}
// 仅非私密日记入队 SyncEngine 等待同步到后端
// 私密日记is_private=true仅保存在本地不上传
if (!saved.isPrivate) {
syncEngine.enqueue(PendingOperation(
id: journalId,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: saved.toJson(),
version: saved.version,
createdAt: now,
));
}
} else {
// --- 更新已有日记 ---
final existing = await repo.getJournal(savedJournalId);
if (existing != null) {
// 将编辑器当前状态合并到已有日记中
final updated = existing.copyWith(
title: state.title.isNotEmpty ? state.title : existing.title,
mood: state.selectedMood,
tags: state.tags.isNotEmpty ? state.tags : existing.tags,
updatedAt: now,
);
await repo.updateJournal(updated);
// 仅非私密日记入队 SyncEngine
if (!updated.isPrivate) {
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.update,
endpoint: '/diary/journals/${updated.id}',
data: updated.toJson(),
version: updated.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);
}
}
/// 显示分享面板并在用户选择后导航
///
/// 分享行为:
/// - 分享到班级/所有人 → is_private=false + shared_to_class=对应值
/// - 仅自己可见 → is_private=true不上传到后端
/// - 首次将私密日记变为公开时,入队 SyncEngine create 操作上传
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 {
if (savedJournalId != null) {
try {
final entry = await repo.getJournal(savedJournalId);
if (entry != null) {
final wasPrivate = entry.isPrivate;
// 分享到班级/所有人 → 取消私密标记
final updated = entry.copyWith(
isPrivate: false,
sharedToClass: shareToClass,
);
await repo.updateJournal(updated);
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
if (wasPrivate && !updated.isPrivate) {
final syncEngine = context.read<SyncEngine>();
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: updated.toJson(),
version: updated.version,
createdAt: DateTime.now(),
));
} else if (!updated.isPrivate) {
// 已公开日记的分享状态更新
final syncEngine = context.read<SyncEngine>();
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.update,
endpoint: '/diary/journals/${updated.id}',
data: updated.toJson(),
version: updated.version,
createdAt: DateTime.now(),
));
}
}
} 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),
],
),
),
);
}
}