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

1131 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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<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));
},
),
```
需要添加导入:
```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<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**
```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<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`
```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 = <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**
```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<EditorBloc>().add(ElementAdded(
element: JournalElement.createSticker(
journalId: widget.journalId,
emoji: emoji,
position: center,
),
));
context.read<EditorBloc>().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 参数框架
- JournalElementtext/image/sticker 便利工厂方法
Week 1 完成: 小学生可以写出包含手写+文字+贴纸+照片的完整日记"
```
---
## 验收清单
- [ ] `flutter analyze` 零 error
- [ ] 编辑器文字工具可用(输入 → 字号 → 颜色 → 放置 → 拖拽)
- [ ] 编辑器图片工具可用(拍照/相册 → 压缩 → 放置 → 拖拽)
- [ ] 编辑器贴纸工具可用(面板选择 → 放置 → 拖拽)
- [ ] 组合日记可保存和加载
- [ ] 所有新文件 ≤ 400 行
- [ ] 所有触摸目标 ≥ 44px