Files
nj/docs/superpowers/plans/2026-06-01-editor-experience.md
iven 92a70ca2ed docs(plans): Week 1 编辑器体验完善实施计划
5 Chunk / 12 Task 详细实施计划:
- Chunk 1: 文字输入 (便利工厂 + TextInputOverlay + 集成 + 渲染)
- Chunk 2: 图片上传 (依赖 + ImagePickerHandler + 集成)
- Chunk 3: 贴纸接入 (StickerPickerSheet + 集成)
- Chunk 4: 模板数据传递
- Chunk 5: 全链路集成验证

TDD 风格,每个 Step 包含完整代码、验证命令、Commit
2026-06-01 21:03:32 +08:00

33 KiB
Raw Blame History

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() 工厂方法之后添加:

  /// 文字元素便利工厂
  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
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 组件

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<TextInputOverlay> createState() => _TextInputOverlayState();
}

class _TextInputOverlayState extends State<TextInputOverlay> {
  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
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 里添加:

// 在 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<EditorBloc>().add(ElementAdded(
        element: JournalElement.createText(
          journalId: widget.journalId,
          text: text,
          position: center,
          fontSize: fontSize,
          fontColor: fontColor,
        ),
      ));
      // 切回选择工具
      context.read<EditorBloc>().add(const ToolChanged(EditorTool.select));
    },
    onCancelled: () {
      context.read<EditorBloc>().add(const ToolChanged(EditorTool.select));
    },
  ),

需要添加导入:

import 'text_input_overlay.dart';
  • Step 2: 更新工具栏选项行

editor_toolbar.dart_buildOptionsRow() 方法中,将静态占位替换为:

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
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 的分支替换为:

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
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 中添加:

  image_picker: ^1.0.0

注意:flutter_image_compress 已在 pubspec.yaml 中声明。

  • Step 2: 安装依赖

Run: cd app && flutter pub get

  • Step 3: Commit
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 工具类

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<String?> 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<String> 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<String> _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
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 时显示选择按钮:

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 类中添加方法:

  Future<void> _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<EditorBloc>().add(ElementAdded(
      element: JournalElement.createImage(
        journalId: widget.journalId,
        filePath: filePath,
        position: center,
        thumbnailPath: thumbnailPath,
      ),
    ));
    context.read<EditorBloc>().add(const ToolChanged(EditorTool.select));
  }

添加辅助 Widget _ImageSourceButton

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),
          ],
        ),
      ),
    );
  }
}

需要添加导入:

import 'dart:io';
import 'image_picker_handler.dart';
  • Step 2: 更新 DraggableElement 图片渲染

_buildElementContent()ElementType.image 分支替换为:

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();

添加辅助方法:

  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
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 底部面板

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 = <String, List<String>>{
    '心情': ['😊', '😢', '😡', '🤔', '😐', '🥰', '😋', '🤗', '😴', '🎉'],
    '动物': ['🐱', '🐶', '🐰', '🐻', '🦊', '🐼', '🐨', '🦄', '🐸', '🦋'],
    '自然': ['🌸', '🌺', '🌻', '🍀', '🌈', '⭐', '🌙', '☀️', '❄️', '🍃'],
    '食物': ['🍰', '🍩', '🍦', '🍓', '🍎', '🧁', '🍪', '🍕', '🍔', '🥤'],
    '学校': ['📚', '✏️', '🎨', '📝', '📐', '🎒', '🏆', '📖', '💡', '🔔'],
    '装饰': ['💕', '✨', '🎀', '🎵', '🎶', '💫', '🦋', '🌸', '🍀', '💎'],
  };

  @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
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() 中添加:

if (state.activeTool == EditorTool.sticker)
  // 贴纸面板通过工具栏按钮触发
  const SizedBox.shrink(),

_EditorStack.initState()didChangeDependencies 中监听工具变化:

// 在 _EditorStack 的 build 方法开始处,监听 sticker 工具切换
// 使用 addPostFrameCallback 避免在 build 中弹窗
if (state.activeTool == EditorTool.sticker) {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _showStickerPicker(context);
  });
}

添加方法:

  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<EditorBloc>().add(ElementAdded(
            element: JournalElement.createSticker(
              journalId: widget.journalId,
              emoji: emoji,
              position: center,
            ),
          ));
          context.read<EditorBloc>().add(const ToolChanged(EditorTool.select));
        },
      ),
    );
  }

需要添加导入:

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
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 查询参数:

// 在现有 journalId 获取之后
final journalId = state.pathParameters['id'] ?? '';
final templateId = state.uri.queryParameters['template'];
  • Step 2: 当 templateId 存在时预填充编辑器

_EditorViewEditorPage 中添加模板加载逻辑:

// 如果有 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
git add app/lib/features/editor/views/editor_page.dart
git commit -m "feat(app): EditorPage 读取 template 参数 — 模板选择框架"

Chunk 5: 集成验证 + 最终打磨

Task 5.1: 全链路手动测试

  • Step 1: 启动前后端
# 终端 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
git add -A
git commit -m "feat(app): 编辑器体验完善 — 手写+文字+贴纸+照片完整日记

- 文字输入:叠加式 TextInputOverlay + 小中大字号 + 8色选择
- 图片上传image_picker + 压缩 + 缩略图 + 拖拽定位
- 贴纸接入6类60个emoji贴纸 + 底部面板 + 自动放置
- 模板传递EditorPage 读取 template 参数框架
- JournalElementtext/image/sticker 便利工厂方法

Week 1 完成: 小学生可以写出包含手写+文字+贴纸+照片的完整日记"

验收清单

  • flutter analyze 零 error
  • 编辑器文字工具可用(输入 → 字号 → 颜色 → 放置 → 拖拽)
  • 编辑器图片工具可用(拍照/相册 → 压缩 → 放置 → 拖拽)
  • 编辑器贴纸工具可用(面板选择 → 放置 → 拖拽)
  • 组合日记可保存和加载
  • 所有新文件 ≤ 400 行
  • 所有触摸目标 ≥ 44px