feat: 添加首页图片选择功能(拍照/相册)

This commit is contained in:
qzl
2026-03-11 17:20:35 +08:00
parent e20e7d2a02
commit 9f2b060282
8 changed files with 163 additions and 18 deletions
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:dio/dio.dart'; 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/i_api_client.dart';
import 'package:social_app/core/api/mock_api_client.dart'; import 'package:social_app/core/api/mock_api_client.dart';
@@ -41,9 +42,9 @@ class AgUiService {
} }
} }
Future<void> sendMessage(String content) async { Future<void> sendMessage(String content, {List<XFile>? images}) async {
final streamToken = ++_activeStreamToken; final streamToken = ++_activeStreamToken;
final runInput = _buildRunInput(content: content); final runInput = await _buildRunInput(content: content, images: images);
final response = await _apiClient.post<Map<String, dynamic>>( final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs', '/api/v1/agent/runs',
data: runInput, data: runInput,
@@ -238,15 +239,50 @@ class AgUiService {
} }
} }
Map<String, dynamic> _buildRunInput({required String content}) { Future<Map<String, dynamic>> _buildRunInput({
required String content,
List<XFile>? images,
}) async {
final threadId = _threadId ?? _newUuid(); final threadId = _threadId ?? _newUuid();
final runId = _nextId(_runIdPrefix); 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 dynamic messageContent;
if (contentBlocks.isEmpty) {
messageContent = '';
} else if (contentBlocks.length == 1 &&
contentBlocks[0]['type'] == 'text') {
messageContent = contentBlocks[0]['text'];
} else {
messageContent = contentBlocks;
}
return { return {
'threadId': threadId, 'threadId': threadId,
'runId': runId, 'runId': runId,
'state': <String, dynamic>{}, 'state': <String, dynamic>{},
'messages': [ 'messages': [
{'id': _nextId('user_'), 'role': 'user', 'content': content}, {'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
], ],
'tools': _buildTools(), 'tools': _buildTools(),
'context': <Map<String, dynamic>>[], 'context': <Map<String, dynamic>>[],
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_bloc/flutter_bloc.dart'; 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/i_api_client.dart';
import 'package:social_app/core/api/mock_api_client.dart'; import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/core/di/injection.dart'; import 'package:social_app/core/di/injection.dart';
@@ -367,7 +368,7 @@ class ChatBloc extends Cubit<ChatState> {
.reduce((a, b) => a.isBefore(b) ? a : b); .reduce((a, b) => a.isBefore(b) ? a : b);
} }
Future<void> sendMessage(String content) async { Future<void> sendMessage(String content, {List<XFile>? images}) async {
final userMessage = TextMessageItem( final userMessage = TextMessageItem(
id: 'user-${DateTime.now().millisecondsSinceEpoch}', id: 'user-${DateTime.now().millisecondsSinceEpoch}',
content: content, content: content,
@@ -385,7 +386,7 @@ class ChatBloc extends Cubit<ChatState> {
), ),
); );
try { try {
await _service.sendMessage(content); await _service.sendMessage(content, images: images);
} catch (error) { } catch (error) {
emit( emit(
state.copyWith( state.copyWith(
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/api/api_exception.dart'; import '../../../../core/api/api_exception.dart';
import '../../../../core/di/injection.dart'; import '../../../../core/di/injection.dart';
@@ -73,6 +74,7 @@ class _HomeScreenState extends State<HomeScreen>
bool _isRecording = false; bool _isRecording = false;
bool _isTranscribing = false; bool _isTranscribing = false;
int _unreadCount = 0; int _unreadCount = 0;
final List<XFile> _selectedImages = [];
bool get _hasMessage => _messageController.text.trim().isNotEmpty; bool get _hasMessage => _messageController.text.trim().isNotEmpty;
@@ -154,6 +156,7 @@ class _HomeScreenState extends State<HomeScreen>
children: [ children: [
_buildHeader(context), _buildHeader(context),
Expanded(child: _buildChatArea(context, state)), Expanded(child: _buildChatArea(context, state)),
_buildImagePreview(),
_buildInputContainer(context, state), _buildInputContainer(context, state),
], ],
), ),
@@ -515,6 +518,71 @@ class _HomeScreenState extends State<HomeScreen>
return UiSchemaRenderer.render(item.uiCard); 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) { Widget _buildInputContainer(BuildContext context, ChatState state) {
final isWaitingAgent = final isWaitingAgent =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling; state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
@@ -625,10 +693,17 @@ class _HomeScreenState extends State<HomeScreen>
Future<void> _sendMessage(BuildContext context) async { Future<void> _sendMessage(BuildContext context) async {
final content = _messageController.text.trim(); final content = _messageController.text.trim();
if (content.isEmpty) return; if (content.isEmpty && _selectedImages.isEmpty) return;
final images = List<XFile>.from(_selectedImages);
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
_messageController.clear(); _messageController.clear();
await context.read<ChatBloc>().sendMessage(content); setState(() {
_selectedImages.clear();
});
await context.read<ChatBloc>().sendMessage(content, images: images);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
@@ -814,7 +889,16 @@ class _HomeScreenState extends State<HomeScreen>
context: context, context: context,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
isScrollControlled: true, 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));
}
});
},
),
); );
} }
} }
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/theme/design_tokens.dart'; import '../../../../core/theme/design_tokens.dart';
class HomeSheet extends StatelessWidget { class HomeSheet extends StatelessWidget {
const HomeSheet({super.key}); final Function(List<XFile>) onImagesSelected;
const HomeSheet({super.key, required this.onImagesSelected});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -103,11 +106,28 @@ class HomeSheet extends StatelessWidget {
); );
} }
void _handleCameraTap(BuildContext context) { Future<void> _handleCameraTap(BuildContext context) async {
Navigator.of(context).pop(); 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) { Future<void> _handlePhotoTap(BuildContext context) async {
Navigator.of(context).pop(); final picker = ImagePicker();
final images = await picker.pickMultiImage(imageQuality: 80, limit: 3);
if (images.isNotEmpty) {
onImagesSelected(images);
}
if (context.mounted) {
Navigator.of(context).pop();
}
} }
} }
+1
View File
@@ -22,6 +22,7 @@ dependencies:
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
json_annotation: ^4.8.1 json_annotation: ^4.8.1
record: ^6.1.1 record: ^6.1.1
image_picker: ^1.0.7
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; 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/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/ai/ai_decision_engine.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
@@ -12,7 +13,7 @@ class TestableAgUiService extends AgUiService {
TestableAgUiService({super.onEvent}); TestableAgUiService({super.onEvent});
@override @override
Future<void> sendMessage(String content) async { Future<void> sendMessage(String content, {List<XFile>? images}) async {
await mockEventStream(content); await mockEventStream(content);
} }
+3 -2
View File
@@ -1,5 +1,6 @@
import 'package:bloc_test/bloc_test.dart'; import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_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/ag_ui_event.dart';
import 'package:social_app/features/chat/data/models/chat_list_item.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'; import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
@@ -9,14 +10,14 @@ class MockAgUiService extends AgUiService {
MockAgUiService() : super(onEvent: (_) {}); MockAgUiService() : super(onEvent: (_) {});
@override @override
Future<void> sendMessage(String content) async {} Future<void> sendMessage(String content, {List<XFile>? images}) async {}
} }
class _ThrowingAgUiService extends AgUiService { class _ThrowingAgUiService extends AgUiService {
_ThrowingAgUiService() : super(onEvent: (_) {}); _ThrowingAgUiService() : super(onEvent: (_) {});
@override @override
Future<void> sendMessage(String content) async { Future<void> sendMessage(String content, {List<XFile>? images}) async {
throw StateError('network down'); throw StateError('network down');
} }
} }
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/core/api/api_exception.dart'; import 'package:social_app/core/api/api_exception.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
@@ -38,7 +39,7 @@ class _WaitingAgUiService extends AgUiService {
final Completer<void> _pending = Completer<void>(); final Completer<void> _pending = Completer<void>();
@override @override
Future<void> sendMessage(String content) async { Future<void> sendMessage(String content, {List<XFile>? images}) async {
onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
return _pending.future; return _pending.future;
} }