feat(app): 集成图片上传到编辑器 — 拍照/相册 + 压缩 + 拖拽定位
This commit is contained in:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user