feat: 添加首页图片选择功能(拍照/相册)
This commit is contained in:
@@ -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<void> sendMessage(String content) async {
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
final streamToken = ++_activeStreamToken;
|
||||
final runInput = _buildRunInput(content: content);
|
||||
final runInput = await _buildRunInput(content: content, images: images);
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/agent/runs',
|
||||
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 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 {
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'state': <String, dynamic>{},
|
||||
'messages': [
|
||||
{'id': _nextId('user_'), 'role': 'user', 'content': content},
|
||||
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
|
||||
],
|
||||
'tools': _buildTools(),
|
||||
'context': <Map<String, dynamic>>[],
|
||||
|
||||
@@ -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<ChatState> {
|
||||
.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(
|
||||
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: content,
|
||||
@@ -385,7 +386,7 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
),
|
||||
);
|
||||
try {
|
||||
await _service.sendMessage(content);
|
||||
await _service.sendMessage(content, images: images);
|
||||
} catch (error) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
||||
@@ -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<HomeScreen>
|
||||
bool _isRecording = false;
|
||||
bool _isTranscribing = false;
|
||||
int _unreadCount = 0;
|
||||
final List<XFile> _selectedImages = [];
|
||||
|
||||
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||
|
||||
@@ -154,6 +156,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(child: _buildChatArea(context, state)),
|
||||
_buildImagePreview(),
|
||||
_buildInputContainer(context, state),
|
||||
],
|
||||
),
|
||||
@@ -515,6 +518,71 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
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<HomeScreen>
|
||||
|
||||
Future<void> _sendMessage(BuildContext context) async {
|
||||
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();
|
||||
_messageController.clear();
|
||||
await context.read<ChatBloc>().sendMessage(content);
|
||||
setState(() {
|
||||
_selectedImages.clear();
|
||||
});
|
||||
|
||||
await context.read<ChatBloc>().sendMessage(content, images: images);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
@@ -814,7 +889,16 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
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));
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<XFile>) 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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePhotoTap(BuildContext context) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<void> sendMessage(String content) async {
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
await mockEventStream(content);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> sendMessage(String content) async {}
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {}
|
||||
}
|
||||
|
||||
class _ThrowingAgUiService extends AgUiService {
|
||||
_ThrowingAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content) async {
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
throw StateError('network down');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _pending = Completer<void>();
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content) async {
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
return _pending.future;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user