// 手账编辑器页面 — 三层 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/services/sync_engine.dart'; import '../../auth/bloc/auth_bloc.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'; 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 树获取 JournalRepository(IsarJournalRepository) final repo = context.read(); // 从 Provider 树获取 SyncEngine(同步到后端) final syncEngine = context.read(); // 可变闭包变量:跟踪已保存的日记 ID // 新建日记首次保存后赋值,后续自动更新使用此 ID String? savedJournalId = journalId; return BlocProvider( create: (_) => EditorBloc( onSave: (state) async { try { // 从 AuthBloc 获取真实用户 ID String authorId = 'local'; final authState = context.read().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 _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 _saveStrokesAsElement( JournalRepository repo, String journalId, List 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 _showShareSheetAndNavigate( BuildContext context, JournalRepository repo, String? savedJournalId, ) async { // 尝试获取用户的班级信息 String? userClassId; String userClassName = '我的班级'; try { // 从 AuthBloc 获取用户关联的班级 final authState = context.read().state; if (authState is Authenticated) { final classRepo = context.read(); 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 _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 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)) .toList(); } } // 过滤掉 handwriting_ref 元素(笔画单独管理) final otherElements = elements .where((e) => e.elementType != ElementType.handwritingRef) .toList(); // 原子加载 — 一次 dispatch 还原所有状态 context.read().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( builder: (context, state) { return _buildTopBar(context, state); }, ), // 编辑区域(三层 Stack) Expanded( child: BlocBuilder( builder: (context, state) { return _EditorStack(state: state, journalId: widget.journalId); }, ), ), // 底部工具栏(自带底部安全区) BlocBuilder( builder: (context, state) { return EditorToolbar( state: state, onEvent: (event) => context.read().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().add(Undo()), constraints: const BoxConstraints(minWidth: 36, minHeight: 36), ), // 重做 IconButton( icon: const Icon(Icons.redo_rounded, size: 18), onPressed: () => context.read().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), ), // 完成/保存按钮 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(); } /// 格式化日期显示 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().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(), child: BlocBuilder( builder: (ctx, state) => TagPanel( selectedTags: state.tags, onTagAdded: (tag) { context.read().add(TagAdded(tag)); }, onTagRemoved: (tag) { context.read().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; 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; } /// 显示贴纸选择底部面板 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().add(ElementAdded( JournalElement.createSticker( journalId: widget.journalId ?? '', emoji: emoji, position: center, ), )); context.read().add(ToolChanged(EditorTool.select)); }, ), ); } /// 显示画笔设置底部面板 void _showBrushPanel() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => BlocProvider.value( value: context.read(), child: BlocBuilder( builder: (ctx, state) => BrushPanel( activeBrushType: state.brushType, activeColor: state.brushColor, activeWidth: state.brushWidth, activeOpacity: state.brushOpacity, onBrushTypeChanged: (type) => context.read().add( BrushChanged( type: type, color: state.brushColor, width: state.brushWidth, ), ), onColorChanged: (color) => context.read().add( BrushChanged( type: state.brushType, color: color, width: state.brushWidth, ), ), onWidthChanged: (width) => context.read().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().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().add(TitleChanged(value)); }, ), ), // 画布区域 Expanded( child: HandwritingCanvas( brushType: state.brushType, brushColor: state.brushColor, brushWidth: state.brushWidth, strokes: state.strokes, onStrokeCompleted: (stroke) { context.read().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().add(ElementAdded( JournalElement.createText( journalId: widget.journalId ?? '', text: text, position: center, fontSize: fontSize, fontColor: fontColor, ), )); context.read().add(ToolChanged(EditorTool.select)); }, onCancelled: () { context.read().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 _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().add(ElementAdded( JournalElement.createImage( journalId: widget.journalId ?? '', filePath: filePath, position: center, thumbnailPath: thumbnailPath, ), )); context.read().add(ToolChanged(EditorTool.select)); } /// 元素层 — 所有日记元素叠加显示 Widget _buildElementLayer(BuildContext context, EditorState state) { // 按 zIndex 排序 final sorted = List.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().add(ElementSelected(id)); }, onMoved: (id, x, y) { context.read().add(ElementMoved( elementId: id, positionX: x, positionY: y, )); }, onDeleted: (id) { context.read().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), ], ), ), ); } }