From 92a70ca2ed4f32e85fd0fc7ec1be6bda5aaeef54 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 21:03:32 +0800 Subject: [PATCH] =?UTF-8?q?docs(plans):=20Week=201=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E4=BD=93=E9=AA=8C=E5=AE=8C=E5=96=84=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 Chunk / 12 Task 详细实施计划: - Chunk 1: 文字输入 (便利工厂 + TextInputOverlay + 集成 + 渲染) - Chunk 2: 图片上传 (依赖 + ImagePickerHandler + 集成) - Chunk 3: 贴纸接入 (StickerPickerSheet + 集成) - Chunk 4: 模板数据传递 - Chunk 5: 全链路集成验证 TDD 风格,每个 Step 包含完整代码、验证命令、Commit --- .../plans/2026-06-01-editor-experience.md | 1130 +++++++++++++++++ 1 file changed, 1130 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-editor-experience.md diff --git a/docs/superpowers/plans/2026-06-01-editor-experience.md b/docs/superpowers/plans/2026-06-01-editor-experience.md new file mode 100644 index 0000000..adbf27f --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-editor-experience.md @@ -0,0 +1,1130 @@ +# 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