feat(app): 集成图片上传到编辑器 — 拍照/相册 + 压缩 + 拖拽定位

This commit is contained in:
iven
2026-06-01 21:35:43 +08:00
parent cd86156590
commit 89c1cefb11
2 changed files with 135 additions and 21 deletions

View File

@@ -23,6 +23,7 @@ import '../widgets/stroke_model.dart';
import '../widgets/draggable_element.dart';
import '../widgets/editor_toolbar.dart';
import '../widgets/text_input_overlay.dart';
import '../widgets/image_picker_handler.dart';
/// 手账编辑器页面
class EditorPage extends StatelessWidget {
@@ -257,19 +258,25 @@ class _EditorView extends StatelessWidget {
/// Layer 1 (底层): HandwritingCanvas
/// Layer 2 (中层): 可拖拽元素(贴纸/照片/文字)
/// Layer 3 (顶层): 由 _EditorView 中的工具栏处理
class _EditorStack extends StatelessWidget {
class _EditorStack extends StatefulWidget {
final EditorState state;
final String? journalId;
const _EditorStack({required this.state, this.journalId});
@override
State<_EditorStack> createState() => _EditorStackState();
}
class _EditorStackState extends State<_EditorStack> {
@override
Widget build(BuildContext context) {
final state = widget.state;
return Stack(
fit: StackFit.expand,
children: [
// Layer 1: 手写画布(底层)
// 始终渲染,通过 IgnorePointer 控制交互(避免模式切换时销毁重建)
IgnorePointer(
ignoring: !state.isDrawingMode,
child: HandwritingCanvas(
@@ -285,7 +292,7 @@ class _EditorStack extends StatelessWidget {
// Layer 2: 可拖拽元素(中层)
if (state.elements.isNotEmpty)
_buildElementLayer(context),
_buildElementLayer(context, state),
// 文字输入覆盖层(文字工具激活时显示)
if (state.activeTool == EditorTool.text)
@@ -297,7 +304,7 @@ class _EditorStack extends StatelessWidget {
);
context.read<EditorBloc>().add(ElementAdded(
JournalElement.createText(
journalId: journalId ?? '',
journalId: widget.journalId ?? '',
text: text,
position: center,
fontSize: fontSize,
@@ -311,15 +318,60 @@ class _EditorStack extends StatelessWidget {
},
),
// 图片选择覆盖层(图片工具激活时显示)
if (state.activeTool == EditorTool.image)
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ImageSourceButton(
icon: Icons.camera_alt_outlined,
label: '拍照',
onTap: () => _pickImage(fromCamera: true),
),
const SizedBox(width: 24),
_ImageSourceButton(
icon: Icons.photo_library_outlined,
label: '从相册',
onTap: () => _pickImage(fromCamera: false),
),
],
),
),
// 空状态提示
if (state.strokes.isEmpty && state.elements.isEmpty)
if (state.strokes.isEmpty && state.elements.isEmpty && state.activeTool == EditorTool.select)
_buildEmptyHint(context),
],
);
}
/// 图片选择逻辑
Future<void> _pickImage({required bool fromCamera}) async {
final filePath = await ImagePickerHandler.pickImage(fromCamera: fromCamera);
if (filePath == null || !mounted) return;
final thumbnailPath = await ImagePickerHandler.generateThumbnail(filePath);
if (!mounted) return;
final center = Offset(
MediaQuery.of(context).size.width / 2 - 80,
MediaQuery.of(context).size.height / 3,
);
context.read<EditorBloc>().add(ElementAdded(
JournalElement.createImage(
journalId: widget.journalId ?? '',
filePath: filePath,
position: center,
thumbnailPath: thumbnailPath,
),
));
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
}
/// 元素层 — 所有日记元素叠加显示
Widget _buildElementLayer(BuildContext context) {
Widget _buildElementLayer(BuildContext context, EditorState state) {
// 按 zIndex 排序
final sorted = List<JournalElement>.from(state.elements)
..sort((a, b) => a.zIndex.compareTo(b.zIndex));
@@ -371,3 +423,46 @@ class _EditorStack extends StatelessWidget {
);
}
}
/// 图片来源按钮 — 拍照 / 从相册
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),
],
),
),
);
}
}

View File

@@ -7,6 +7,8 @@
// - 单击选中/取消选中
// - 选中时显示边框和删除按钮
import 'dart:io';
import 'package:flutter/material.dart';
import '../../../data/models/journal_element.dart';
@@ -176,21 +178,18 @@ class _DraggableElementState extends State<DraggableElement> {
);
case ElementType.image:
return Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image_rounded, size: 32, color: Colors.grey.shade400),
const SizedBox(height: 4),
Text(
'照片',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
);
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();
case ElementType.tape:
final tapeColor = _parseColor(element.content['tapeColor'] as String?);
@@ -230,4 +229,24 @@ class _DraggableElementState extends State<DraggableElement> {
if (value == null) return const Color(0xFF2D2420);
return Color(0xFF000000 + value);
}
/// 图片加载失败时的占位符
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)),
],
),
);
}
}