5 Chunk / 12 Task 详细实施计划: - Chunk 1: 文字输入 (便利工厂 + TextInputOverlay + 集成 + 渲染) - Chunk 2: 图片上传 (依赖 + ImagePickerHandler + 集成) - Chunk 3: 贴纸接入 (StickerPickerSheet + 集成) - Chunk 4: 模板数据传递 - Chunk 5: 全链路集成验证 TDD 风格,每个 Step 包含完整代码、验证命令、Commit
1131 lines
33 KiB
Markdown
1131 lines
33 KiB
Markdown
# 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 参数框架
|
||
- JournalElement:text/image/sticker 便利工厂方法
|
||
|
||
Week 1 完成: 小学生可以写出包含手写+文字+贴纸+照片的完整日记"
|
||
```
|
||
|
||
---
|
||
|
||
## 验收清单
|
||
|
||
- [ ] `flutter analyze` 零 error
|
||
- [ ] 编辑器文字工具可用(输入 → 字号 → 颜色 → 放置 → 拖拽)
|
||
- [ ] 编辑器图片工具可用(拍照/相册 → 压缩 → 放置 → 拖拽)
|
||
- [ ] 编辑器贴纸工具可用(面板选择 → 放置 → 拖拽)
|
||
- [ ] 组合日记可保存和加载
|
||
- [ ] 所有新文件 ≤ 400 行
|
||
- [ ] 所有触摸目标 ≥ 44px
|