diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index 16f9fc2..d88a72f 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:dio/dio.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/api/i_api_client.dart'; import 'package:social_app/core/api/mock_api_client.dart'; @@ -41,9 +42,9 @@ class AgUiService { } } - Future sendMessage(String content) async { + Future sendMessage(String content, {List? images}) async { final streamToken = ++_activeStreamToken; - final runInput = _buildRunInput(content: content); + final runInput = await _buildRunInput(content: content, images: images); final response = await _apiClient.post>( '/api/v1/agent/runs', data: runInput, @@ -238,15 +239,50 @@ class AgUiService { } } - Map _buildRunInput({required String content}) { + Future> _buildRunInput({ + required String content, + List? images, + }) async { final threadId = _threadId ?? _newUuid(); final runId = _nextId(_runIdPrefix); + + final contentBlocks = >[]; + + 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 dynamic 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': {}, 'messages': [ - {'id': _nextId('user_'), 'role': 'user', 'content': content}, + {'id': _nextId('user_'), 'role': 'user', 'content': messageContent}, ], 'tools': _buildTools(), 'context': >[], diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 218ec02..28bd29e 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/api/i_api_client.dart'; import 'package:social_app/core/api/mock_api_client.dart'; import 'package:social_app/core/di/injection.dart'; @@ -367,7 +368,7 @@ class ChatBloc extends Cubit { .reduce((a, b) => a.isBefore(b) ? a : b); } - Future sendMessage(String content) async { + Future sendMessage(String content, {List? images}) async { final userMessage = TextMessageItem( id: 'user-${DateTime.now().millisecondsSinceEpoch}', content: content, @@ -385,7 +386,7 @@ class ChatBloc extends Cubit { ), ); try { - await _service.sendMessage(content); + await _service.sendMessage(content, images: images); } catch (error) { emit( state.copyWith( diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 04441c9..094ab87 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/api/api_exception.dart'; import '../../../../core/di/injection.dart'; @@ -73,6 +74,7 @@ class _HomeScreenState extends State bool _isRecording = false; bool _isTranscribing = false; int _unreadCount = 0; + final List _selectedImages = []; bool get _hasMessage => _messageController.text.trim().isNotEmpty; @@ -154,6 +156,7 @@ class _HomeScreenState extends State children: [ _buildHeader(context), Expanded(child: _buildChatArea(context, state)), + _buildImagePreview(), _buildInputContainer(context, state), ], ), @@ -515,6 +518,71 @@ class _HomeScreenState extends State return UiSchemaRenderer.render(item.uiCard); } + 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); + }); + } + Widget _buildInputContainer(BuildContext context, ChatState state) { final isWaitingAgent = state.isWaitingFirstToken || state.isStreaming || state.isCancelling; @@ -625,10 +693,17 @@ class _HomeScreenState extends State Future _sendMessage(BuildContext context) async { final content = _messageController.text.trim(); - if (content.isEmpty) return; + if (content.isEmpty && _selectedImages.isEmpty) return; + + final images = List.from(_selectedImages); + FocusScope.of(context).unfocus(); _messageController.clear(); - await context.read().sendMessage(content); + setState(() { + _selectedImages.clear(); + }); + + await context.read().sendMessage(content, images: images); WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { @@ -814,7 +889,16 @@ class _HomeScreenState extends State context: context, backgroundColor: Colors.transparent, isScrollControlled: true, - builder: (context) => const HomeSheet(), + builder: (context) => HomeSheet( + onImagesSelected: (images) { + setState(() { + final remaining = 3 - _selectedImages.length; + if (remaining > 0) { + _selectedImages.addAll(images.take(remaining)); + } + }); + }, + ), ); } } diff --git a/apps/lib/features/home/ui/screens/home_sheet.dart b/apps/lib/features/home/ui/screens/home_sheet.dart index 65f5b09..a27b46b 100644 --- a/apps/lib/features/home/ui/screens/home_sheet.dart +++ b/apps/lib/features/home/ui/screens/home_sheet.dart @@ -1,9 +1,12 @@ 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 { - const HomeSheet({super.key}); + final Function(List) onImagesSelected; + + const HomeSheet({super.key, required this.onImagesSelected}); @override Widget build(BuildContext context) { @@ -103,11 +106,28 @@ class HomeSheet extends StatelessWidget { ); } - void _handleCameraTap(BuildContext context) { - Navigator.of(context).pop(); + Future _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(); + } } - void _handlePhotoTap(BuildContext context) { - Navigator.of(context).pop(); + Future _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(); + } } } diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 8718dba..1180dd9 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: shared_preferences: ^2.2.2 json_annotation: ^4.8.1 record: ^6.1.1 + image_picker: ^1.0.7 dev_dependencies: flutter_test: diff --git a/apps/test/features/chat/ag_ui_service_test.dart b/apps/test/features/chat/ag_ui_service_test.dart index cb97eb9..48dd5d5 100644 --- a/apps/test/features/chat/ag_ui_service_test.dart +++ b/apps/test/features/chat/ag_ui_service_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/api/mock_api_client.dart'; import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart'; import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; @@ -12,7 +13,7 @@ class TestableAgUiService extends AgUiService { TestableAgUiService({super.onEvent}); @override - Future sendMessage(String content) async { + Future sendMessage(String content, {List? images}) async { await mockEventStream(content); } diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart index 9b13b4c..6b58992 100644 --- a/apps/test/features/chat/chat_bloc_test.dart +++ b/apps/test/features/chat/chat_bloc_test.dart @@ -1,5 +1,6 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; import 'package:social_app/features/chat/data/models/chat_list_item.dart'; import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; @@ -9,14 +10,14 @@ class MockAgUiService extends AgUiService { MockAgUiService() : super(onEvent: (_) {}); @override - Future sendMessage(String content) async {} + Future sendMessage(String content, {List? images}) async {} } class _ThrowingAgUiService extends AgUiService { _ThrowingAgUiService() : super(onEvent: (_) {}); @override - Future sendMessage(String content) async { + Future sendMessage(String content, {List? images}) async { throw StateError('network down'); } } diff --git a/apps/test/features/home/ui/screens/home_screen_test.dart b/apps/test/features/home/ui/screens/home_screen_test.dart index 89d9abb..e289439 100644 --- a/apps/test/features/home/ui/screens/home_screen_test.dart +++ b/apps/test/features/home/ui/screens/home_screen_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:social_app/core/api/api_exception.dart'; import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; @@ -38,7 +39,7 @@ class _WaitingAgUiService extends AgUiService { final Completer _pending = Completer(); @override - Future sendMessage(String content) async { + Future sendMessage(String content, {List? images}) async { onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); return _pending.future; }