# Week 1: 编辑器体验完善 — 实施计划 > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 让小学生可以写出包含手写 + 文字 + 贴纸 + 照片的完整日记 **Architecture:** 在现有三层 Stack 编辑器中添加工具特定覆盖层。EditorBloc 新增元素创建事件;_EditorStack 按活跃工具渲染文字输入/贴纸面板/图片选择器;DraggableElement 增强元素渲染。 **Tech Stack:** Flutter 3.x / flutter_bloc / image_picker / flutter_image_compress / perfect_freehand **Spec:** `docs/superpowers/specs/2026-06-01-30-day-action-plan.md` Week 1 部分 --- ## File Structure | 文件 | 操作 | 职责 | |------|------|------| | `app/lib/features/editor/bloc/editor_bloc.dart` | 修改 | 新增 ElementCreated 事件 + 便利方法 | | `app/lib/features/editor/widgets/editor_toolbar.dart` | 修改 | 文字/贴纸/图片工具的选项面板 | | `app/lib/features/editor/views/editor_page.dart` | 修改 | _EditorStack 增加工具覆盖层 | | `app/lib/features/editor/widgets/draggable_element.dart` | 修改 | 图片真实渲染 + 文字多行 | | `app/lib/features/editor/widgets/text_input_overlay.dart` | **新建** | 叠加式文字输入框 | | `app/lib/features/editor/widgets/sticker_picker_sheet.dart` | **新建** | 贴纸选择底部面板 | | `app/lib/features/editor/widgets/image_picker_handler.dart` | **新建** | 图片选择+压缩+缩略图 | | `app/lib/data/models/journal_element.dart` | 修改 | 添加类型便利工厂 | | `app/pubspec.yaml` | 修改 | 添加 image_picker 依赖 | --- ## Chunk 1: 文字输入 ### Task 1.1: JournalElement 便利工厂方法 **Files:** - Modify: `app/lib/data/models/journal_element.dart:128-155` - [ ] **Step 1: 在 JournalElement 类底部添加便利工厂方法** 在 `JournalElement.create()` 工厂方法之后添加: ```dart /// 文字元素便利工厂 factory JournalElement.createText({ required String journalId, required String text, required Offset position, double fontSize = 18.0, String fontColor = '#2D2420', }) { return JournalElement.create( journalId: journalId, elementType: ElementType.text, position: position, content: { 'text': text, 'fontSize': fontSize, 'fontColor': fontColor, }, ); } /// 图片元素便利工厂 factory JournalElement.createImage({ required String journalId, required String filePath, required Offset position, String? thumbnailPath, }) { return JournalElement.create( journalId: journalId, elementType: ElementType.image, position: position, content: { 'filePath': filePath, if (thumbnailPath != null) 'thumbnailPath': thumbnailPath, }, ); } /// 贴纸元素便利工厂 factory JournalElement.createSticker({ required String journalId, required String emoji, required Offset position, String? stickerPackId, String? stickerId, }) { return JournalElement.create( journalId: journalId, elementType: ElementType.sticker, position: position, content: { 'emoji': emoji, if (stickerPackId != null) 'stickerPackId': stickerPackId, if (stickerId != null) 'stickerId': stickerId, }, ); } ``` - [ ] **Step 2: 验证编译通过** Run: `cd app && flutter analyze lib/data/models/journal_element.dart` Expected: No issues found - [ ] **Step 3: Commit** ```bash git add app/lib/data/models/journal_element.dart git commit -m "feat(app): 添加 JournalElement 类型便利工厂方法 — text/image/sticker" ``` --- ### Task 1.2: 创建文字输入覆盖层组件 **Files:** - Create: `app/lib/features/editor/widgets/text_input_overlay.dart` - [ ] **Step 1: 创建 TextInputOverlay 组件** ```dart import 'package:flutter/material.dart'; /// 编辑器文字输入覆盖层 /// 当用户选择文字工具时,在画布上叠加一个 TextField class TextInputOverlay extends StatefulWidget { final void Function(String text, double fontSize, String fontColor) onConfirmed; final VoidCallback onCancelled; const TextInputOverlay({ super.key, required this.onConfirmed, required this.onCancelled, }); @override State createState() => _TextInputOverlayState(); } class _TextInputOverlayState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); double _fontSize = 18.0; String _fontColor = '#2D2420'; // 字号选项:小(14)/中(18)/大(24) static const _fontSizes = [14.0, 18.0, 24.0]; static const _fontSizeLabels = ['小', '中', '大']; // 颜色选项 static const _colors = [ '#2D2420', // 主文字色 '#E07A5F', // 珊瑚色 '#81B29A', // 鼠尾草绿 '#2C7DA0', // 蓝色 '#D4A5A5', // 玫瑰粉 '#F2CC8F', // 暖金 '#9B5DE5', // 紫色 '#F15BB5', // 粉色 ]; @override void initState() { super.initState(); // 自动弹出键盘 Future.microtask(() => _focusNode.requestFocus()); } @override void dispose() { _controller.dispose(); _focusNode.dispose(); super.dispose(); } void _confirm() { final text = _controller.text.trim(); if (text.isEmpty) { widget.onCancelled(); return; } widget.onConfirmed(text, _fontSize, _fontColor); } @override Widget build(BuildContext context) { return Container( color: Colors.black26, child: Center( child: Container( width: MediaQuery.of(context).size.width * 0.85, constraints: const BoxConstraints(maxHeight: 280), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.15), blurRadius: 20, offset: const Offset(0, 4), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 标题行 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '添加文字', style: Theme.of(context).textTheme.titleSmall, ), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: widget.onCancelled, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), const SizedBox(height: 12), // 文字输入框 TextField( controller: _controller, focusNode: _focusNode, maxLines: 4, minLines: 1, textInputAction: TextInputAction.done, onSubmitted: (_) => _confirm(), style: TextStyle(fontSize: _fontSize), decoration: InputDecoration( hintText: '在这里输入文字...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.grey.shade300), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.grey.shade300), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, width: 2, ), ), ), ), const SizedBox(height: 12), // 字号选择 Row( children: [ Text('字号', style: Theme.of(context).textTheme.bodySmall), const SizedBox(width: 8), ...List.generate(_fontSizes.length, (i) { final selected = _fontSize == _fontSizes[i]; return Padding( padding: const EdgeInsets.only(right: 8), child: ChoiceChip( label: Text(_fontSizeLabels[i]), selected: selected, onSelected: (_) { setState(() => _fontSize = _fontSizes[i]); }, visualDensity: VisualDensity.compact, ), ); }), ], ), const SizedBox(height: 8), // 颜色选择 Row( children: [ Text('颜色', style: Theme.of(context).textTheme.bodySmall), const SizedBox(width: 8), ..._colors.map((hex) { final selected = _fontColor == hex; final color = _parseHexColor(hex); return GestureDetector( onTap: () => setState(() => _fontColor = hex), child: Container( width: 28, height: 28, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: selected ? Border.all(color: Colors.black87, width: 2.5) : Border.all(color: Colors.grey.shade300, width: 1), ), ), ); }), ], ), const SizedBox(height: 12), // 确认按钮 SizedBox( width: double.infinity, height: 44, child: FilledButton( onPressed: _confirm, child: const Text('添加到日记'), ), ), ], ), ), ), ); } Color _parseHexColor(String hex) { final code = hex.replaceAll('#', ''); return Color(int.parse('FF$code', radix: 16)); } } ``` - [ ] **Step 2: 验证编译通过** Run: `cd app && flutter analyze lib/features/editor/widgets/text_input_overlay.dart` Expected: No issues found - [ ] **Step 3: Commit** ```bash git add app/lib/features/editor/widgets/text_input_overlay.dart git commit -m "feat(app): 创建文字输入覆盖层组件 — TextInputOverlay" ``` --- ### Task 1.3: 集成文字输入到编辑器 **Files:** - Modify: `app/lib/features/editor/views/editor_page.dart:265-293` (_EditorStack) - Modify: `app/lib/features/editor/widgets/editor_toolbar.dart:131-134` (_buildOptionsRow) - [ ] **Step 1: 在 _EditorStack.build() 中添加文字覆盖层** 在 `editor_page.dart` 的 `_EditorStack.build()` 方法中,在 Stack children 里添加: ```dart // 在 HandwritingCanvas 和元素层之间插入 if (state.activeTool == EditorTool.text && state.elements.isEmpty) 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( element: JournalElement.createText( journalId: widget.journalId, text: text, position: center, fontSize: fontSize, fontColor: fontColor, ), )); // 切回选择工具 context.read().add(const ToolChanged(EditorTool.select)); }, onCancelled: () { context.read().add(const ToolChanged(EditorTool.select)); }, ), ``` 需要添加导入: ```dart import 'text_input_overlay.dart'; ``` - [ ] **Step 2: 更新工具栏选项行** 在 `editor_toolbar.dart` 的 `_buildOptionsRow()` 方法中,将静态占位替换为: ```dart Widget _buildOptionsRow(EditorState state) { if (state.isDrawingMode) { // 画笔模式:颜色 + 粗细(已有逻辑) return _buildBrushOptions(state); } // 文字工具:字号 + 颜色提示 if (state.activeTool == EditorTool.text) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Icon(Icons.text_fields, size: 16), SizedBox(width: 8), Text('点击画布输入文字', style: TextStyle(fontSize: 13)), ], ), ); } // 贴纸工具:提示(贴纸面板在 Task 2 中实现) if (state.activeTool == EditorTool.sticker) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Icon(Icons.emoji_emotions_outlined, size: 16), SizedBox(width: 8), Text('选择一个贴纸放到日记上', style: TextStyle(fontSize: 13)), ], ), ); } // 图片工具:提示 if (state.activeTool == EditorTool.image) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Icon(Icons.add_photo_alternate_outlined, size: 16), SizedBox(width: 8), Text('选择照片添加到日记', style: TextStyle(fontSize: 13)), ], ), ); } // 选择工具 return const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Text('选择元素或添加内容', style: TextStyle(fontSize: 13)), ); } ``` - [ ] **Step 3: 验证编译通过** Run: `cd app && flutter analyze` Expected: No issues found - [ ] **Step 4: 手动测试文字输入流程** Run: `cd app && flutter run -d chrome --web-port=5173` 操作: 1. 打开编辑器 2. 点击文字工具(T 图标) 3. 应弹出文字输入面板 4. 输入文字 → 选字号 → 选颜色 → 点击"添加到日记" 5. 文字应出现在画布上,可拖拽 - [ ] **Step 5: Commit** ```bash git add app/lib/features/editor/ git commit -m "feat(app): 集成文字输入到编辑器 — TextInputOverlay + 工具栏选项行" ``` --- ### Task 1.4: 增强 DraggableElement 文字渲染 **Files:** - Modify: `app/lib/features/editor/widgets/draggable_element.dart:148-160` - [ ] **Step 1: 替换文字元素渲染** 将 `_buildElementContent()` 中 `ElementType.text` 的分支替换为: ```dart case ElementType.text: final text = element.content['text'] as String? ?? ''; final fontSize = (element.content['fontSize'] as num?)?.toDouble() ?? 18.0; final fontColor = element.content['fontColor'] as String? ?? '#2D2420'; final color = _parseColor(fontColor); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(4), ), child: Text( text, style: TextStyle( fontSize: fontSize, color: color, fontFamily: 'NotoSansSC', ), maxLines: null, // 支持多行 softWrap: true, ), ); ``` - [ ] **Step 2: 验证文字元素正确渲染** Run: `cd app && flutter analyze` Expected: No issues found - [ ] **Step 3: Commit** ```bash git add app/lib/features/editor/widgets/draggable_element.dart git commit -m "feat(app): 增强文字元素渲染 — 多行+字号+颜色" ``` --- ## Chunk 2: 图片上传 ### Task 2.1: 添加图片相关依赖 **Files:** - Modify: `app/pubspec.yaml` - [ ] **Step 1: 添加 image_picker 依赖** 在 `pubspec.yaml` 的 dependencies 中添加: ```yaml image_picker: ^1.0.0 ``` 注意:`flutter_image_compress` 已在 pubspec.yaml 中声明。 - [ ] **Step 2: 安装依赖** Run: `cd app && flutter pub get` - [ ] **Step 3: Commit** ```bash git add app/pubspec.yaml app/pubspec.lock git commit -m "feat(app): 添加 image_picker 依赖" ``` --- ### Task 2.2: 创建图片选择处理器 **Files:** - Create: `app/lib/features/editor/widgets/image_picker_handler.dart` - [ ] **Step 1: 创建 ImagePickerHandler 工具类** ```dart import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; /// 图片选择 + 压缩 + 缩略图生成 class ImagePickerHandler { static const _uuid = Uuid(); /// 从相机或相册选择图片 /// 返回压缩后的本地文件路径,取消返回 null static Future pickImage({required bool fromCamera}) async { final picker = ImagePicker(); final xFile = await picker.pickImage( source: fromCamera ? ImageSource.camera : ImageSource.gallery, maxWidth: 1024, maxHeight: 1024, imageQuality: 85, ); if (xFile == null) return null; // 压缩图片 final compressedPath = await _compressImage(xFile.path); return compressedPath; } /// 生成缩略图路径 static Future generateThumbnail(String imagePath) async { final dir = await getApplicationDocumentsDirectory(); final thumbName = 'thumb_${_uuid.v4()}.jpg'; final thumbPath = '${dir.path}/$thumbName'; final result = await FlutterImageCompress.compressAndGetFile( imagePath, thumbPath, minHeight: 200, minWidth: 200, quality: 70, ); return result?.path ?? imagePath; } /// 压缩原图 static Future _compressImage(String path) async { final dir = await getApplicationDocumentsDirectory(); final fileName = 'img_${_uuid.v4()}.jpg'; final targetPath = '${dir.path}/$fileName'; final result = await FlutterImageCompress.compressAndGetFile( path, targetPath, quality: 85, minWidth: 1024, minHeight: 1024, ); return result?.path ?? path; } } ``` - [ ] **Step 2: 验证编译通过** Run: `cd app && flutter analyze lib/features/editor/widgets/image_picker_handler.dart` Expected: No issues found - [ ] **Step 3: Commit** ```bash git add app/lib/features/editor/widgets/image_picker_handler.dart git commit -m "feat(app): 创建图片选择+压缩处理器 — ImagePickerHandler" ``` --- ### Task 2.3: 集成图片到编辑器 **Files:** - Modify: `app/lib/features/editor/views/editor_page.dart` (_EditorStack) - Modify: `app/lib/features/editor/widgets/editor_toolbar.dart` (_buildOptionsRow image 部分) - Modify: `app/lib/features/editor/widgets/draggable_element.dart:169-184` - [ ] **Step 1: 在 _EditorStack 中添加图片选择逻辑** 在 `_EditorStack.build()` 中,当 `activeTool == EditorTool.image` 时显示选择按钮: ```dart if (state.activeTool == EditorTool.image) Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _ImageSourceButton( icon: Icons.camera_alt_outlined, label: '拍照', onTap: () => _pickImage(context, fromCamera: true), ), const SizedBox(width: 24), _ImageSourceButton( icon: Icons.photo_library_outlined, label: '从相册', onTap: () => _pickImage(context, fromCamera: false), ), ], ), ], ), ), ``` 在 `_EditorStack` 类中添加方法: ```dart Future _pickImage(BuildContext context, {required bool fromCamera}) async { final filePath = await ImagePickerHandler.pickImage(fromCamera: fromCamera); if (filePath == null || !context.mounted) return; final thumbnailPath = await ImagePickerHandler.generateThumbnail(filePath); if (!context.mounted) return; final center = Offset( MediaQuery.of(context).size.width / 2 - 80, MediaQuery.of(context).size.height / 3, ); context.read().add(ElementAdded( element: JournalElement.createImage( journalId: widget.journalId, filePath: filePath, position: center, thumbnailPath: thumbnailPath, ), )); context.read().add(const ToolChanged(EditorTool.select)); } ``` 添加辅助 Widget `_ImageSourceButton`: ```dart 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), ], ), ), ); } } ``` 需要添加导入: ```dart import 'dart:io'; import 'image_picker_handler.dart'; ``` - [ ] **Step 2: 更新 DraggableElement 图片渲染** 将 `_buildElementContent()` 中 `ElementType.image` 分支替换为: ```dart case ElementType.image: final filePath = element.content['filePath'] as String?; if ( filePath != null && filePath.isNotEmpty) { return ClipRRect( borderRadius: BorderRadius.circular(4), child: Image.file( File(filePath), fit: BoxFit.contain, errorBuilder: (_, __, ___) => _buildImagePlaceholder(), ), ); } return _buildImagePlaceholder(); ``` 添加辅助方法: ```dart Widget _buildImagePlaceholder() { return Container( width: 160, height: 120, decoration: BoxDecoration( color: Colors.grey.shade200, borderRadius: BorderRadius.circular(4), ), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.broken_image_outlined, color: Colors.grey), SizedBox(height: 4), Text('图片加载失败', style: TextStyle(color: Colors.grey, fontSize: 11)), ], ), ); } ``` - [ ] **Step 3: 验证编译通过** Run: `cd app && flutter analyze` Expected: No issues found - [ ] **Step 4: 手动测试图片上传** Run: `cd app && flutter run -d windows` (桌面端更方便测试图片选择) 操作: 1. 点击图片工具 2. 选择"从相册" 3. 选择图片 4. 图片应出现在画布上,可拖拽 - [ ] **Step 5: Commit** ```bash git add app/lib/features/editor/ git commit -m "feat(app): 集成图片上传到编辑器 — 拍照/相册 + 压缩 + 拖拽定位" ``` --- ## Chunk 3: 贴纸接入 ### Task 3.1: 创建贴纸选择面板 **Files:** - Create: `app/lib/features/editor/widgets/sticker_picker_sheet.dart` - [ ] **Step 1: 创建 StickerPickerSheet 底部面板** ```dart import 'package:flutter/material.dart'; /// 贴纸选择底部面板 /// Phase 1 使用内置 emoji 贴纸,后续替换为贴纸包 class StickerPickerSheet extends StatelessWidget { final void Function(String emoji) onStickerSelected; const StickerPickerSheet({ super.key, required this.onStickerSelected, }); // Phase 1 内置贴纸集 static const _stickerCategories = >{ '心情': ['😊', '😢', '😡', '🤔', '😐', '🥰', '😋', '🤗', '😴', '🎉'], '动物': ['🐱', '🐶', '🐰', '🐻', '🦊', '🐼', '🐨', '🦄', '🐸', '🦋'], '自然': ['🌸', '🌺', '🌻', '🍀', '🌈', '⭐', '🌙', '☀️', '❄️', '🍃'], '食物': ['🍰', '🍩', '🍦', '🍓', '🍎', '🧁', '🍪', '🍕', '🍔', '🥤'], '学校': ['📚', '✏️', '🎨', '📝', '📐', '🎒', '🏆', '📖', '💡', '🔔'], '装饰': ['💕', '✨', '🎀', '🎵', '🎶', '💫', '🦋', '🌸', '🍀', '💎'], }; @override Widget build(BuildContext context) { return Container( height: 320, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), ), child: Column( children: [ // 拖拽条 Center( child: Container( margin: const EdgeInsets.symmetric(vertical: 8), width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), ), // 分类标签 SizedBox( height: 40, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), children: _stickerCategories.keys.map((category) { return Padding( padding: const EdgeInsets.only(right: 8), child: Chip( label: Text(category, style: const TextStyle(fontSize: 13)), visualDensity: VisualDensity.compact, ), ); }).toList(), ), ), const Divider(height: 1), // 贴纸网格 Expanded( child: GridView.count( crossAxisCount: 5, padding: const EdgeInsets.all(16), mainAxisSpacing: 8, crossAxisSpacing: 8, children: _stickerCategories.values .expand((list) => list) .map((emoji) { return InkWell( onTap: () { onStickerSelected(emoji); Navigator.pop(context); }, borderRadius: BorderRadius.circular(8), child: Center( child: Text( emoji, style: const TextStyle(fontSize: 32), ), ), ); }).toList(), ), ), ], ), ); } } ``` - [ ] **Step 2: 验证编译通过** Run: `cd app && flutter analyze lib/features/editor/widgets/sticker_picker_sheet.dart` Expected: No issues found - [ ] **Step 3: Commit** ```bash git add app/lib/features/editor/widgets/sticker_picker_sheet.dart git commit -m "feat(app): 创建贴纸选择底部面板 — 6 类 60 个 emoji 贴纸" ``` --- ### Task 3.2: 集成贴纸到编辑器 **Files:** - Modify: `app/lib/features/editor/views/editor_page.dart` (_EditorStack) - Modify: `app/lib/features/editor/widgets/editor_toolbar.dart` (贴纸工具选项) - [ ] **Step 1: 在 _EditorStack 中添加贴纸选择逻辑** 当 `activeTool == EditorTool.sticker` 时,自动弹出贴纸面板。在 `_EditorStack.build()` 中添加: ```dart if (state.activeTool == EditorTool.sticker) // 贴纸面板通过工具栏按钮触发 const SizedBox.shrink(), ``` 在 `_EditorStack.initState()` 或 `didChangeDependencies` 中监听工具变化: ```dart // 在 _EditorStack 的 build 方法开始处,监听 sticker 工具切换 // 使用 addPostFrameCallback 避免在 build 中弹窗 if (state.activeTool == EditorTool.sticker) { WidgetsBinding.instance.addPostFrameCallback((_) { _showStickerPicker(context); }); } ``` 添加方法: ```dart void _showStickerPicker(BuildContext context) { 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( element: JournalElement.createSticker( journalId: widget.journalId, emoji: emoji, position: center, ), )); context.read().add(const ToolChanged(EditorTool.select)); }, ), ); } ``` 需要添加导入: ```dart import 'sticker_picker_sheet.dart'; ``` - [ ] **Step 2: 验证编译通过** Run: `cd app && flutter analyze` Expected: No issues found - [ ] **Step 3: 手动测试贴纸放置** 操作: 1. 点击贴纸工具 2. 底部弹出贴纸面板 3. 选择一个 emoji 4. emoji 应出现在画布上,可拖拽 - [ ] **Step 4: Commit** ```bash git add app/lib/features/editor/ git commit -m "feat(app): 集成贴纸选择到编辑器 — 底部面板 + 自动放置" ``` --- ## Chunk 4: 模板数据传递 ### Task 4.1: EditorPage 读取 template 参数 **Files:** - Modify: `app/lib/features/editor/views/editor_page.dart:27-150` - [ ] **Step 1: 在 EditorPage 中读取 template 查询参数** 在 `EditorPage.build()` 中,获取 `template` 查询参数: ```dart // 在现有 journalId 获取之后 final journalId = state.pathParameters['id'] ?? ''; final templateId = state.uri.queryParameters['template']; ``` - [ ] **Step 2: 当 templateId 存在时预填充编辑器** 在 `_EditorView` 或 `EditorPage` 中添加模板加载逻辑: ```dart // 如果有 templateId 且没有 journalId(新建+模板场景) if (templateId != null && journalId.isEmpty) { // TODO: Phase 1 简化 — 模板仅影响标题和默认心情 // 后续版本从 API 加载模板数据 WidgetsBinding.instance.addPostFrameCallback((_) { // 可以根据 templateId 预设一些内容 // 当前仅记录选择,不做预填充 }); } ``` 注意:Phase 1 模板功能简化为"模板选择记录",不做完整预填充。模板数据传递的框架先搭建好,后续接入模板 API。 - [ ] **Step 3: 更新模板画廊页跳转** 确认 `template_gallery_page.dart` 的跳转 URL 已正确编码 template 参数。 - [ ] **Step 4: 验证编译通过** Run: `cd app && flutter analyze` Expected: No issues found - [ ] **Step 5: Commit** ```bash git add app/lib/features/editor/views/editor_page.dart git commit -m "feat(app): EditorPage 读取 template 参数 — 模板选择框架" ``` --- ## Chunk 5: 集成验证 + 最终打磨 ### Task 5.1: 全链路手动测试 - [ ] **Step 1: 启动前后端** ```bash # 终端 1: 后端 cd g:/nj && ERP__DATABASE__URL=postgres://postgres:123123@localhost:5432/nuanji ERP__REDIS__URL=redis://localhost:6379 ERP__JWT__SECRET=nuanji-dev-jwt-secret-2024-warm-notes-hmac-key-32b ERP__AUTH__SUPER_ADMIN_PASSWORD=admin123 ERP__CRYPTO__KEK=0000000000000000000000000000000000000000000000000000000000000000 cargo run --bin erp-server # 终端 2: 前端 cd g:/nj/app && flutter run -d chrome --web-port=5173 ``` - [ ] **Step 2: 测试手写输入** 操作:打开编辑器 → 用钢笔工具画一笔 → 验证笔迹渲染正常 - [ ] **Step 3: 测试文字输入** 操作:点击 T 工具 → 输入"今天天气真好" → 选大字号 → 选珊瑚色 → 点击"添加到日记" → 验证文字出现在画布 → 拖拽移动 - [ ] **Step 4: 测试图片上传** 操作:点击图片工具 → 选择"从相册" → 选择图片 → 验证图片出现在画布 → 拖拽移动 - [ ] **Step 5: 测试贴纸放置** 操作:点击贴纸工具 → 底部弹出面板 → 选择 🐱 → 验证 emoji 出现在画布 → 拖拽移动 - [ ] **Step 6: 测试组合日记** 操作:在一篇日记中同时使用手写 + 文字 + 图片 + 贴纸 → 点击完成 → 验证保存成功 → 返回列表 → 再次打开验证数据加载 - [ ] **Step 7: Final commit** ```bash git add -A git commit -m "feat(app): 编辑器体验完善 — 手写+文字+贴纸+照片完整日记 - 文字输入:叠加式 TextInputOverlay + 小中大字号 + 8色选择 - 图片上传:image_picker + 压缩 + 缩略图 + 拖拽定位 - 贴纸接入:6类60个emoji贴纸 + 底部面板 + 自动放置 - 模板传递:EditorPage 读取 template 参数框架 - JournalElement:text/image/sticker 便利工厂方法 Week 1 完成: 小学生可以写出包含手写+文字+贴纸+照片的完整日记" ``` --- ## 验收清单 - [ ] `flutter analyze` 零 error - [ ] 编辑器文字工具可用(输入 → 字号 → 颜色 → 放置 → 拖拽) - [ ] 编辑器图片工具可用(拍照/相册 → 压缩 → 放置 → 拖拽) - [ ] 编辑器贴纸工具可用(面板选择 → 放置 → 拖拽) - [ ] 组合日记可保存和加载 - [ ] 所有新文件 ≤ 400 行 - [ ] 所有触摸目标 ≥ 44px