Files
social-app/docs/plans/2026-03-11-home-image-picker-impl.md
T

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


实施提示

  1. Task 2 和 Task 3 可以并行开发(HomeSheet 和 HomeScreen 独立)
  2. Task 4 需要在 Task 3 完成后进行,因为需要确定 ChatBloc 接口
  3. 如果遇到编译错误,检查 ImagePicker 是否正确导入
  4. AG-UI 格式可以参考: https://docs.ag-ui.com (如需要)