5 Chunk / 12 Task 详细实施计划: - Chunk 1: 文字输入 (便利工厂 + TextInputOverlay + 集成 + 渲染) - Chunk 2: 图片上传 (依赖 + ImagePickerHandler + 集成) - Chunk 3: 贴纸接入 (StickerPickerSheet + 集成) - Chunk 4: 模板数据传递 - Chunk 5: 全链路集成验证 TDD 风格,每个 Step 包含完整代码、验证命令、Commit
33 KiB
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
操作:
- 打开编辑器
- 点击文字工具(T 图标)
- 应弹出文字输入面板
- 输入文字 → 选字号 → 选颜色 → 点击"添加到日记"
- 文字应出现在画布上,可拖拽
- 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 (桌面端更方便测试图片选择)
操作:
- 点击图片工具
- 选择"从相册"
- 选择图片
- 图片应出现在画布上,可拖拽
- 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: 手动测试贴纸放置
操作:
- 点击贴纸工具
- 底部弹出贴纸面板
- 选择一个 emoji
- 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 存在时预填充编辑器
在 _EditorView 或 EditorPage 中添加模板加载逻辑:
// 如果有 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 参数框架
- JournalElement:text/image/sticker 便利工厂方法
Week 1 完成: 小学生可以写出包含手写+文字+贴纸+照片的完整日记"
验收清单
flutter analyze零 error- 编辑器文字工具可用(输入 → 字号 → 颜色 → 放置 → 拖拽)
- 编辑器图片工具可用(拍照/相册 → 压缩 → 放置 → 拖拽)
- 编辑器贴纸工具可用(面板选择 → 放置 → 拖拽)
- 组合日记可保存和加载
- 所有新文件 ≤ 400 行
- 所有触摸目标 ≥ 44px