feat: 添加首页图片选择功能(拍照/相册)
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user