- EditorPage 新增查看模式: 打开已保存日记默认只读,编辑按钮切换 - EditorBloc 新增 ElementLayerChanged 事件,支持置顶/置底图层排序 - DraggableElement 添加图层控制按钮 (置顶/置底/删除) - TagPanel 标签建议改为从日记历史动态生成 (Top 10 频率) - StickerPickerSheet 重构,预留 API 扩展点
1182 lines
38 KiB
Dart
1182 lines
38 KiB
Dart
// 手账编辑器页面 — 三层 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 树获取 JournalRepository(IsarJournalRepository)
|
||
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> {
|
||
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
|
||
bool _isViewMode = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 当 journalId 非空时,进入查看模式
|
||
_isViewMode = widget.journalId != null;
|
||
if (widget.journalId != null) {
|
||
_loadExistingJournal(widget.journalId!);
|
||
}
|
||
}
|
||
|
||
/// 从查看模式切换到编辑模式
|
||
void _enterEditMode() {
|
||
setState(() => _isViewMode = false);
|
||
// 切换到画笔工具,进入编辑状态
|
||
context.read<EditorBloc>().add(ToolChanged(EditorTool.brush));
|
||
}
|
||
|
||
/// 从 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,
|
||
));
|
||
|
||
// 查看模式下使用 select 工具,避免自动弹出画笔面板
|
||
if (_isViewMode) {
|
||
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
|
||
}
|
||
} 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,
|
||
isViewMode: _isViewMode,
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
// 底部工具栏 — 仅编辑模式显示
|
||
if (!_isViewMode)
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (_isViewMode) ...[
|
||
// 查看模式:评语按钮 + 编辑按钮
|
||
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: _enterEditMode,
|
||
style: FilledButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
minimumSize: const Size(0, 32),
|
||
),
|
||
child: const Text('编辑', style: TextStyle(fontSize: 14)),
|
||
),
|
||
),
|
||
] else ...[
|
||
// 编辑模式:撤销/重做/标签/评语/完成
|
||
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) — 仅编辑模式显示
|
||
if (!_isViewMode) _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;
|
||
final bool isViewMode;
|
||
|
||
const _EditorStack({
|
||
required this.state,
|
||
this.journalId,
|
||
this.isViewMode = false,
|
||
});
|
||
|
||
@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,
|
||
enabled: !widget.isViewMode,
|
||
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 (!widget.isViewMode && 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 (!widget.isViewMode && 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 (!widget.isViewMode &&
|
||
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));
|
||
},
|
||
onLayerChanged: (id, change) {
|
||
context.read<EditorBloc>().add(
|
||
ElementLayerChanged(elementId: id, change: change),
|
||
);
|
||
},
|
||
);
|
||
}).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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|