Files
nj/app/lib/features/editor/views/editor_page.dart

576 lines
17 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 '../../../data/models/journal_element.dart';
import '../../../data/models/journal_entry.dart';
import '../../../data/repositories/journal_repository.dart';
import '../../../data/repositories/class_repository.dart';
import '../bloc/editor_bloc.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';
/// 手账编辑器页面
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>();
// 可变闭包变量:跟踪已保存的日记 ID
// 新建日记首次保存后赋值,后续自动更新使用此 ID
String? savedJournalId = journalId;
return BlocProvider(
create: (_) => EditorBloc(
onSave: (state) async {
try {
await _persistState(repo, state, (id) => savedJournalId = id, savedJournalId);
} 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 元素
Future<void> _persistState(
JournalRepository repo,
EditorState state,
void Function(String) setId,
String? savedJournalId,
) async {
final now = DateTime.now();
if (savedJournalId == null) {
// --- 新建日记 ---
final entry = JournalEntry.create(
authorId: 'local', // TODO: 从 AuthBloc 获取真实用户 ID
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));
}
} else {
// --- 更新已有日记 ---
final existing = await repo.getJournal(savedJournalId);
if (existing != null) {
await repo.updateJournal(existing);
}
// 更新笔画
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 void _showShareSheetAndNavigate(
BuildContext context,
JournalRepository repo,
String? savedJournalId,
) {
// 尝试获取用户的班级信息
String? userClassId;
String userClassName = '我的班级';
try {
context.read<ClassRepository>();
// Phase 1 简化:不等待异步调用,使用默认值
userClassId = null; // TODO: 从 AuthBloc/ClassBloc 获取真实班级 ID
} catch (_) {
// ClassRepository 不可用(未注入)
}
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 StatelessWidget {
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
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
// 顶栏
_buildTopBar(context),
// 编辑区域(三层 Stack
Expanded(
child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return _EditorStack(state: state, journalId: journalId);
},
),
),
// 底部工具栏
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
},
),
],
),
),
);
}
/// 顶部操作栏 — 返回/日记标题/完成
Widget _buildTopBar(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
bottom: BorderSide(color: colorScheme.outline.withValues(alpha: 0.1)),
),
),
child: Row(
children: [
// 返回按钮
IconButton(
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/home');
}
},
icon: const Icon(Icons.arrow_back_rounded),
tooltip: '返回',
),
const SizedBox(width: DesignTokens.spacing8),
// 日记标题
Expanded(
child: Text(
journalId != null
? '编辑日记'
: templateId != null
? '从模板新建'
: '新建日记',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
// 完成按钮
FilledButton.tonal(
onPressed: onSaveComplete,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('完成'),
),
],
),
);
}
}
// ============================================================
// 编辑器三层 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;
@override
void didUpdateWidget(covariant _EditorStack oldWidget) {
super.didUpdateWidget(oldWidget);
final currentTool = widget.state.activeTool;
// 贴纸工具刚被激活时弹出底部面板(防止重复弹窗)
if (currentTool == EditorTool.sticker && _lastTool != EditorTool.sticker) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showStickerPicker();
});
}
_lastTool = currentTool;
}
/// 显示贴纸选择底部面板
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));
},
),
);
}
@override
Widget build(BuildContext context) {
final state = widget.state;
return Stack(
fit: StackFit.expand,
children: [
// Layer 1: 手写画布(底层)
IgnorePointer(
ignoring: !state.isDrawingMode,
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.image)
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,
));
},
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),
],
),
),
);
}
}