11 KiB
首页图片选择功能实现计划
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 节点下添加:
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
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: 添加导入和状态变量
在文件顶部添加导入:
import 'package:image_picker/image_picker.dart';
在 _HomeScreenState 类中添加状态变量:
List<XFile> _selectedImages = [];
Step 2: 添加图片预览 Widget
在 _buildInputContainer 方法之前添加:
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 调用之前插入图片预览:
// 在 build 方法中修改
body: SafeArea(
child: Column(
children: [
_buildHeader(context),
Expanded(child: _buildChatArea(context, state)),
_buildImagePreview(), // 添加这行
_buildInputContainer(context, state),
],
),
),
Step 4: 修改 _showBottomSheet 传递回调
将 _showBottomSheet 方法修改为:
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 导入
在文件顶部添加:
import 'dart:convert';
import 'package:image_picker/image_picker.dart';
Step 2: 修改 sendMessage 方法签名
修改 sendMessage 方法接受可选的图片参数:
Future<void> sendMessage(String content, {List<XFile>? images}) async {
final streamToken = ++_activeStreamToken;
final runInput = _buildRunInput(content: content, images: images);
// ... 后续代码不变
}
Step 3: 修改 _buildRunInput 方法
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 方法:
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
实施提示
- Task 2 和 Task 3 可以并行开发(HomeSheet 和 HomeScreen 独立)
- Task 4 需要在 Task 3 完成后进行,因为需要确定 ChatBloc 接口
- 如果遇到编译错误,检查 ImagePicker 是否正确导入
- AG-UI 格式可以参考: https://docs.ag-ui.com (如需要)