464 lines
11 KiB
Markdown
464 lines
11 KiB
Markdown
# 首页图片选择功能实现计划
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** 在首页聊天界面实现拍照/相册选择图片功能,最多3张,图片随文本一起发送
|
|
|
|
**Architecture:** 使用 image_picker 选择图片,通过 AG-UI 多模态消息格式发送到后端
|
|
|
|
**Tech Stack:** Flutter, image_picker, AG-UI Protocol
|
|
|
|
---
|
|
|
|
### Task 1: 添加 image_picker 依赖
|
|
|
|
**Files:**
|
|
- Modify: `apps/pubspec.yaml`
|
|
|
|
**Step 1: 添加依赖**
|
|
|
|
在 `dependencies` 节点下添加:
|
|
```yaml
|
|
image_picker: ^1.0.7
|
|
```
|
|
|
|
**Step 2: 安装依赖**
|
|
|
|
Run: `cd apps && flutter pub get`
|
|
|
|
Expected: image_picker 添加成功
|
|
|
|
---
|
|
|
|
### Task 2: 实现 HomeSheet 图片选择功能
|
|
|
|
**Files:**
|
|
- Modify: `apps/lib/features/home/ui/screens/home_sheet.dart:1-113`
|
|
|
|
**Step 1: 添加 image_picker 导入和修改 HomeSheet**
|
|
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import '../../../../core/theme/design_tokens.dart';
|
|
|
|
class HomeSheet extends StatelessWidget {
|
|
final Function(List<XFile>) onImagesSelected;
|
|
|
|
const HomeSheet({super.key, required this.onImagesSelected});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: () => Navigator.of(context).pop(),
|
|
child: Container(
|
|
color: const Color(0x4D0F172A),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () {},
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.slate300,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildSheetContent(context),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSheetContent(BuildContext context) {
|
|
return SizedBox(
|
|
height: 280,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildOptionCard(
|
|
context: context,
|
|
icon: LucideIcons.camera,
|
|
label: '拍照',
|
|
onTap: () => _handleCameraTap(context),
|
|
),
|
|
const SizedBox(width: 24),
|
|
_buildOptionCard(
|
|
context: context,
|
|
icon: LucideIcons.image,
|
|
label: '相册',
|
|
onTap: () => _handlePhotoTap(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOptionCard({
|
|
required BuildContext context,
|
|
required IconData icon,
|
|
required String label,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: 72,
|
|
height: 72,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.blue50,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Icon(icon, size: 32, color: AppColors.blue500),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.slate700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleCameraTap(BuildContext context) async {
|
|
final picker = ImagePicker();
|
|
final image = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
imageQuality: 80,
|
|
);
|
|
if (image != null) {
|
|
onImagesSelected([image]);
|
|
}
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
Future<void> _handlePhotoTap(BuildContext context) async {
|
|
final picker = ImagePicker();
|
|
final images = await picker.pickMultiImage(
|
|
imageQuality: 80,
|
|
limit: 3,
|
|
);
|
|
if (images.isNotEmpty) {
|
|
onImagesSelected(images);
|
|
}
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: 验证编译**
|
|
|
|
Run: `cd apps && flutter analyze lib/features/home/ui/screens/home_sheet.dart`
|
|
Expected: No errors
|
|
|
|
---
|
|
|
|
### Task 3: 修改 HomeScreen 添加图片预览区
|
|
|
|
**Files:**
|
|
- Modify: `apps/lib/features/home/ui/screens/home_screen.dart:1-820`
|
|
|
|
**Step 1: 添加导入和状态变量**
|
|
|
|
在文件顶部添加导入:
|
|
```dart
|
|
import 'package:image_picker/image_picker.dart';
|
|
```
|
|
|
|
在 `_HomeScreenState` 类中添加状态变量:
|
|
```dart
|
|
List<XFile> _selectedImages = [];
|
|
```
|
|
|
|
**Step 2: 添加图片预览 Widget**
|
|
|
|
在 `_buildInputContainer` 方法之前添加:
|
|
```dart
|
|
Widget _buildImagePreview() {
|
|
if (_selectedImages.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: _inputPadding,
|
|
right: _inputPadding,
|
|
bottom: AppSpacing.sm,
|
|
),
|
|
child: Wrap(
|
|
spacing: AppSpacing.sm,
|
|
runSpacing: AppSpacing.sm,
|
|
children: _selectedImages.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final image = entry.value;
|
|
return _buildImageThumbnail(image, index);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildImageThumbnail(XFile image, int index) {
|
|
return Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
child: Image.file(
|
|
File(image.path),
|
|
width: 80,
|
|
height: 80,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: GestureDetector(
|
|
onTap: () => _removeImage(index),
|
|
child: Container(
|
|
width: 24,
|
|
height: 24,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.red500,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
LucideIcons.x,
|
|
size: 14,
|
|
color: AppColors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _removeImage(int index) {
|
|
setState(() {
|
|
_selectedImages.removeAt(index);
|
|
});
|
|
}
|
|
```
|
|
|
|
**Step 3: 修改 _buildInputContainer 调用位置**
|
|
|
|
在 `_buildInputContainer` 调用之前插入图片预览:
|
|
```dart
|
|
// 在 build 方法中修改
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_buildHeader(context),
|
|
Expanded(child: _buildChatArea(context, state)),
|
|
_buildImagePreview(), // 添加这行
|
|
_buildInputContainer(context, state),
|
|
],
|
|
),
|
|
),
|
|
```
|
|
|
|
**Step 4: 修改 _showBottomSheet 传递回调**
|
|
|
|
将 `_showBottomSheet` 方法修改为:
|
|
```dart
|
|
void _showBottomSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
isScrollControlled: true,
|
|
builder: (context) => HomeSheet(
|
|
onImagesSelected: (images) {
|
|
setState(() {
|
|
// 限制最多3张
|
|
final remaining = 3 - _selectedImages.length;
|
|
if (remaining > 0) {
|
|
_selectedImages.addAll(images.take(remaining));
|
|
}
|
|
});
|
|
},
|
|
),
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 5: 验证编译**
|
|
|
|
Run: `cd apps && flutter analyze lib/features/home/ui/screens/home_screen.dart`
|
|
Expected: No errors
|
|
|
|
---
|
|
|
|
### Task 4: 修改 AgUiService 支持多模态消息
|
|
|
|
**Files:**
|
|
- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart:1-643`
|
|
|
|
**Step 1: 添加 base64 导入**
|
|
|
|
在文件顶部添加:
|
|
```dart
|
|
import 'dart:convert';
|
|
import 'package:image_picker/image_picker.dart';
|
|
```
|
|
|
|
**Step 2: 修改 sendMessage 方法签名**
|
|
|
|
修改 `sendMessage` 方法接受可选的图片参数:
|
|
```dart
|
|
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
|
final streamToken = ++_activeStreamToken;
|
|
final runInput = _buildRunInput(content: content, images: images);
|
|
// ... 后续代码不变
|
|
}
|
|
```
|
|
|
|
**Step 3: 修改 _buildRunInput 方法**
|
|
|
|
```dart
|
|
Map<String, dynamic> _buildRunInput({
|
|
required String content,
|
|
List<XFile>? images,
|
|
}) {
|
|
final threadId = _threadId ?? _newUuid();
|
|
final runId = _nextId(_runIdPrefix);
|
|
|
|
// 构建多模态内容块
|
|
final contentBlocks = <Map<String, dynamic>>[];
|
|
|
|
// 添加文本(如果有)
|
|
if (content.isNotEmpty) {
|
|
contentBlocks.add({'type': 'text', 'text': content});
|
|
}
|
|
|
|
// 添加图片(如果有)
|
|
if (images != null && images.isNotEmpty) {
|
|
for (final image in images) {
|
|
final bytes = await image.readAsBytes();
|
|
final base64 = base64Encode(bytes);
|
|
contentBlocks.add({
|
|
'type': 'image',
|
|
'source': {
|
|
'type': 'base64',
|
|
'media_type': 'image/jpeg',
|
|
'data': base64,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// 根据内容块数量决定消息格式
|
|
final messageContent;
|
|
if (contentBlocks.isEmpty) {
|
|
messageContent = '';
|
|
} else if (contentBlocks.length == 1 && contentBlocks[0]['type'] == 'text') {
|
|
// 纯文本使用简单格式(兼容现有逻辑)
|
|
messageContent = contentBlocks[0]['text'];
|
|
} else {
|
|
// 多模态消息使用内容块数组
|
|
messageContent = contentBlocks;
|
|
}
|
|
|
|
return {
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
'state': <String, dynamic>{},
|
|
'messages': [
|
|
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
|
|
],
|
|
'tools': _buildTools(),
|
|
'context': <Map<String, dynamic>>[],
|
|
'forwardedProps': <String, dynamic>{},
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 4: 修改 _sendMessage 方法传递图片**
|
|
|
|
在 `home_screen.dart` 中修改 `_sendMessage` 方法:
|
|
```dart
|
|
Future<void> _sendMessage(BuildContext context) async {
|
|
final content = _messageController.text.trim();
|
|
if (content.isEmpty && _selectedImages.isEmpty) return;
|
|
|
|
// 保存图片引用
|
|
final images = List<XFile>.from(_selectedImages);
|
|
|
|
FocusScope.of(context).unfocus();
|
|
_messageController.clear();
|
|
|
|
// 清除图片
|
|
setState(() {
|
|
_selectedImages.clear();
|
|
});
|
|
|
|
await context.read<ChatBloc>().sendMessage(content, images: images);
|
|
// ... 后续代码不变
|
|
}
|
|
```
|
|
|
|
**Step 5: 需要修改 ChatBloc 接口**
|
|
|
|
检查 ChatBloc 的 sendMessage 方法签名,如果需要修改,添加 images 参数。
|
|
|
|
Run: `grep -n "sendMessage" apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
|
|
|
|
根据结果修改 ChatBloc 和相关调用。
|
|
|
|
**Step 6: 验证编译**
|
|
|
|
Run: `cd apps && flutter analyze lib/features/chat/data/services/ag_ui_service.dart`
|
|
Expected: No errors
|
|
|
|
---
|
|
|
|
### Task 5: 测试验证
|
|
|
|
**Step 1: 运行 Flutter 分析**
|
|
|
|
Run: `cd apps && flutter analyze`
|
|
Expected: No errors
|
|
|
|
**Step 2: 运行单元测试(如果有)**
|
|
|
|
Run: `cd apps && flutter test`
|
|
Expected: Tests pass
|
|
|
|
---
|
|
|
|
### 实施提示
|
|
|
|
1. Task 2 和 Task 3 可以并行开发(HomeSheet 和 HomeScreen 独立)
|
|
2. Task 4 需要在 Task 3 完成后进行,因为需要确定 ChatBloc 接口
|
|
3. 如果遇到编译错误,检查 ImagePicker 是否正确导入
|
|
4. AG-UI 格式可以参考: https://docs.ag-ui.com (如需要)
|