diff --git a/app/lib/backend/http/api/messages.dart b/app/lib/backend/http/api/messages.dart index 8f5394ee8..22644d931 100644 --- a/app/lib/backend/http/api/messages.dart +++ b/app/lib/backend/http/api/messages.dart @@ -54,7 +54,7 @@ Future> clearChatServer({String? pluginId}) async { } } -Future sendMessageServer(String text, {String? appId}) { +Future sendMessageServer(String text, {String? appId, List? fileIds}) { var url = '${Env.apiBaseUrl}v1/messages?plugin_id=$appId'; if (appId == null || appId.isEmpty || appId == 'null' || appId == 'no_selected') { url = '${Env.apiBaseUrl}v1/messages'; @@ -63,7 +63,7 @@ Future sendMessageServer(String text, {String? appId}) { url: url, headers: {}, method: 'POST', - body: jsonEncode({'text': text}), + body: jsonEncode({'text': text, 'file_ids': fileIds}), ).then((response) { if (response == null) throw Exception('Failed to send message'); if (response.statusCode == 200) { @@ -104,7 +104,7 @@ ServerMessageChunk? parseMessageChunk(String line, String messageId) { return null; } -Stream sendMessageStreamServer(String text, {String? appId}) async* { +Stream sendMessageStreamServer(String text, {String? appId, List? filesId}) async* { var url = '${Env.apiBaseUrl}v2/messages?plugin_id=$appId'; if (appId == null || appId.isEmpty || appId == 'null' || appId == 'no_selected') { url = '${Env.apiBaseUrl}v2/messages'; @@ -114,7 +114,7 @@ Stream sendMessageStreamServer(String text, {String? appId}) final request = await HttpClient().postUrl(Uri.parse(url)); request.headers.set('Authorization', await getAuthHeader()); request.headers.contentType = ContentType.json; - request.write(jsonEncode({'text': text})); + request.write(jsonEncode({'text': text, 'file_ids': filesId})); final response = await request.close(); @@ -262,6 +262,44 @@ Future> sendVoiceMessageServer(List files) async { } } +Future?> uploadFilesServer(List files, {String? appId}) async { + var url = '${Env.apiBaseUrl}v1/files?plugin_id=$appId'; + if (appId == null || appId.isEmpty || appId == 'null' || appId == 'no_selected') { + url = '${Env.apiBaseUrl}v1/files'; + } + var request = http.MultipartRequest( + 'POST', + Uri.parse(url), + ); + request.headers.addAll({'Authorization': await getAuthHeader()}); + for (var file in files) { + var stream = http.ByteStream(file.openRead()); + var length = await file.length(); + var multipartFile = http.MultipartFile( + 'files', + stream, + length, + filename: basename(file.path), + ); + request.files.add(multipartFile); + } + + try { + var streamedResponse = await request.send(); + var response = await http.Response.fromStream(streamedResponse); + if (response.statusCode == 200) { + debugPrint('uploadFileServer response body: ${jsonDecode(response.body)}'); + return MessageFile.fromJsonList(jsonDecode(response.body)); + } else { + debugPrint('Failed to upload file. Status code: ${response.statusCode} ${response.body}'); + throw Exception('Failed to upload file. Status code: ${response.statusCode}'); + } + } catch (e) { + debugPrint('An error occurred uploadFileServer: $e'); + throw Exception('An error occurred uploadFileServer: $e'); + } +} + Future reportMessageServer(String messageId) async { var response = await makeApiCall( url: '${Env.apiBaseUrl}v1/messages/$messageId/report', diff --git a/app/lib/backend/schema/message.dart b/app/lib/backend/schema/message.dart index 7e12e88d0..f865bc035 100644 --- a/app/lib/backend/schema/message.dart +++ b/app/lib/backend/schema/message.dart @@ -59,6 +59,54 @@ class MessageConversation { } } +class MessageFile { + String id; + String openaiFileId; + String? thumbnail; + String? thumbnailName; + String name; + String mimeType; + DateTime createdAt; + + MessageFile(this.openaiFileId, this.thumbnail, this.name, this.mimeType, this.id, this.createdAt, this.thumbnailName); + + static MessageFile fromJson(Map json) { + return MessageFile( + json['openai_file_id'], + json['thumbnail'], + json['name'], + json['mime_type'], + json['id'], + DateTime.parse(json['created_at']).toLocal(), + json['thumb_name'], + ); + } + + static List fromJsonList(List json) { + return json.map((e) => MessageFile.fromJson(e)).toList(); + } + + Map toJson() { + return { + 'openai_file_id': openaiFileId, + 'thumbnail': thumbnail, + 'name': name, + 'mime_type': mimeType, + 'id': id, + 'created_at': createdAt.toUtc().toIso8601String(), + 'thumb_name': thumbnailName, + }; + } + + String mimeTypeToFileType() { + if (mimeType.contains('image')) { + return 'image'; + } else { + return 'file'; + } + } +} + class ServerMessage { String id; DateTime createdAt; @@ -69,6 +117,9 @@ class ServerMessage { String? appId; bool fromIntegration; + List files; + List filesId; + List memories; bool askForNps = false; @@ -82,6 +133,8 @@ class ServerMessage { this.type, this.appId, this.fromIntegration, + this.files, + this.filesId, this.memories, { this.askForNps = false, }); @@ -95,6 +148,8 @@ class ServerMessage { MessageType.valuesFromString(json['type']), json['plugin_id'], json['from_integration'] ?? false, + ((json['files'] ?? []) as List).map((m) => MessageFile.fromJson(m)).toList(), + (json['files_id'] ?? []).map((m) => m.toString()).toList(), ((json['memories'] ?? []) as List).map((m) => MessageConversation.fromJson(m)).toList(), askForNps: json['ask_for_nps'] ?? false, ); @@ -111,9 +166,19 @@ class ServerMessage { 'from_integration': fromIntegration, 'memories': memories.map((m) => m.toJson()).toList(), 'ask_for_nps': askForNps, + 'files': files.map((m) => m.toJson()).toList(), }; } + bool areFilesOfSameType() { + if (files.isEmpty) { + return true; + } + + final firstType = files.first.mimeTypeToFileType(); + return files.every((element) => element.mimeTypeToFileType() == firstType); + } + static ServerMessage empty({String? appId}) { return ServerMessage( '0000', @@ -124,6 +189,8 @@ class ServerMessage { appId, false, [], + [], + [], ); } @@ -137,6 +204,8 @@ class ServerMessage { null, false, [], + [], + [], ); } diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index 08c3e68ef..866d25003 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -225,9 +225,13 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { if (chatIndex != 0) message.askForNps = false; double bottomPadding = chatIndex == 0 - ? Platform.isAndroid - ? 200 - : 170 + ? provider.selectedFiles.isNotEmpty + ? (Platform.isAndroid + ? MediaQuery.sizeOf(context).height * 0.32 + : MediaQuery.sizeOf(context).height * 0.3) + : (Platform.isAndroid + ? MediaQuery.sizeOf(context).height * 0.21 + : MediaQuery.sizeOf(context).height * 0.19) : 0; return GestureDetector( onLongPress: () { @@ -353,78 +357,233 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { return Align( alignment: Alignment.bottomCenter, - child: Container( - width: double.maxFinite, - padding: EdgeInsets.only(left: 16, right: shouldShowSuffixIcon(provider) ? 4 : 16, bottom: 4), - margin: EdgeInsets.only(left: 20, right: 20, bottom: home.isChatFieldFocused ? 20 : 120), - decoration: const BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.all(Radius.circular(16)), - border: GradientBoxBorder( - gradient: LinearGradient(colors: [ - Color.fromARGB(127, 208, 208, 208), - Color.fromARGB(127, 188, 99, 121), - Color.fromARGB(127, 86, 101, 182), - Color.fromARGB(127, 126, 190, 236) - ]), - width: 1, - ), - shape: BoxShape.rectangle, - ), - child: TextField( - enabled: true, - controller: textController, - // textCapitalization: TextCapitalization.sentences, - obscureText: false, - focusNode: home.chatFieldFocusNode, - // canRequestFocus: true, - textAlign: TextAlign.start, - textAlignVertical: TextAlignVertical.center, - onChanged: (_) { - setShowSendButton(); - }, - decoration: InputDecoration( - hintText: 'Message', - hintStyle: const TextStyle(fontSize: 14.0, color: Colors.grey), - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - suffixIcon: shouldShowSuffixIcon(provider) - ? SizedBox( - width: 24, - height: 24, - child: shouldShowSendButton(provider) - ? IconButton( - splashColor: Colors.transparent, - splashRadius: 1, - onPressed: () async { - String message = textController.text; - if (message.isEmpty) return; - if (connectivityProvider.isConnected) { - _sendMessageUtil(message); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please check your internet connection and try again'), - duration: Duration(seconds: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.maxFinite, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + margin: EdgeInsets.only(left: 28, right: 28, bottom: home.isChatFieldFocused ? 40 : 120), + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.all(Radius.circular(16)), + border: GradientBoxBorder( + gradient: LinearGradient(colors: [ + Color.fromARGB(127, 208, 208, 208), + Color.fromARGB(127, 188, 99, 121), + Color.fromARGB(127, 86, 101, 182), + Color.fromARGB(127, 126, 190, 236) + ]), + width: 1, + ), + shape: BoxShape.rectangle, + ), + child: Column( + children: [ + Consumer(builder: (context, provider, child) { + if (provider.selectedFiles.isNotEmpty) { + return Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.118, + child: ListView.builder( + itemCount: provider.selectedFiles.length, + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemBuilder: (ctx, idx) { + return Container( + margin: const EdgeInsets.only(bottom: 10, top: 10, left: 10), + height: MediaQuery.sizeOf(context).width * 0.2, + width: MediaQuery.sizeOf(context).width * 0.2, + decoration: BoxDecoration( + color: Colors.grey[800], + image: provider.selectedFileTypes[idx] == 'image' + ? DecorationImage( + image: FileImage(provider.selectedFiles[idx]), + fit: BoxFit.cover, + ) + : null, + borderRadius: BorderRadius.circular(10), + ), + child: Stack( + children: [ + provider.selectedFileTypes[idx] != 'image' + ? const Center( + child: Icon( + Icons.insert_drive_file, + color: Colors.white, + size: 30, + ), + ) + : Container(), + if (provider.isFileUploading(provider.selectedFiles[idx].path)) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white70), + ), + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () { + provider.clearSelectedFile(idx); + }, + child: CircleAvatar( + radius: 12, + backgroundColor: Colors.grey[700], + child: const Icon(Icons.close, size: 16, color: Colors.white), + ), + ), + ), + ], ), ); - } - }, + }, + ), + ), + ), + ], + ); + } else { + return Container(); + } + }), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + icon: Icon( + Icons.add, + color: provider.selectedFiles.length > 3 ? Colors.grey : const Color(0xFFF7F4F4), + size: 24.0, + ), + onPressed: () { + if (provider.selectedFiles.length > 3) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('You can only upload 4 files at a time'), + duration: Duration(seconds: 2), + ), + ); + return; + } + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[850], + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16.0)), + ), + builder: (BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 40), + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.camera_alt, color: Colors.white), + title: + const Text("Take a Photo", style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + context.read().captureImage(); + }, + ), + ListTile( + leading: const Icon(Icons.photo, color: Colors.white), + title: + const Text("Select a Photo", style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + context.read().selectImage(); + }, + ), + ListTile( + leading: const Icon(Icons.insert_drive_file, color: Colors.white), + title: + const Text("Select a File", style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + context.read().selectFile(); + }, + ), + ], + ), + ); + }, + ); + }, + ), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 150, + ), + child: TextField( + enabled: true, + controller: textController, + obscureText: false, + onChanged: (value) { + setShowSendButton(); + }, + focusNode: home.chatFieldFocusNode, + textAlign: TextAlign.start, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + hintText: 'Message', + hintStyle: TextStyle(fontSize: 14.0, color: Colors.grey), + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + contentPadding: EdgeInsets.only(top: 8, bottom: 8), + ), + maxLines: null, + keyboardType: TextInputType.multiline, + style: TextStyle(fontSize: 14.0, color: Colors.grey.shade200, height: 24 / 14), + ), + ), + ), + !shouldShowSuffixIcon(provider) && !shouldShowSendButton(provider) + ? const SizedBox.shrink() + : IconButton( + splashColor: Colors.transparent, + splashRadius: 1, + onPressed: provider.sendingMessage || provider.isUploadingFiles + ? null + : () { + String message = textController.text; + if (message.isEmpty) return; + if (connectivityProvider.isConnected) { + _sendMessageUtil(message); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Please check your internet connection and try again'), + duration: Duration(seconds: 2), + ), + ); + } + }, icon: const Icon( Icons.arrow_upward_outlined, color: Color(0xFFF7F4F4), size: 20.0, ), - ) - : const SizedBox.shrink(), - ) - : null, + ), + ], + ), + ], + ), ), - maxLines: 8, - minLines: 1, - keyboardType: TextInputType.multiline, - style: TextStyle(fontSize: 14.0, color: Colors.grey.shade200, height: 24 / 14), - ), + ], ), ); }), @@ -435,7 +594,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { ); } - _sendMessageUtil(String text) async { + _sendMessageUtil(String text) { var provider = context.read(); MixpanelManager().chatMessageSent(text); provider.setSendingMessage(true); @@ -443,12 +602,23 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { if (appId == 'no_selected') { appId = null; } - var message = - ServerMessage(const Uuid().v4(), DateTime.now(), text, MessageSender.human, MessageType.text, appId, false, []); + var message = ServerMessage( + const Uuid().v4(), + DateTime.now(), + text, + MessageSender.human, + MessageType.text, + appId, + false, + provider.uploadedFiles, + provider.uploadedFiles.map((e) => e.id).toList(), + [], + ); provider.addMessage(message); scrollToBottom(); textController.clear(); - await provider.sendMessageStreamToServer(text, appId); + provider.sendMessageStreamToServer(text, appId); + provider.clearSelectedFiles(); scrollToBottom(); provider.setSendingMessage(false); } diff --git a/app/lib/pages/chat/widgets/files_handler_widget.dart b/app/lib/pages/chat/widgets/files_handler_widget.dart new file mode 100644 index 000000000..eca78a0e7 --- /dev/null +++ b/app/lib/pages/chat/widgets/files_handler_widget.dart @@ -0,0 +1,82 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/schema/message.dart'; + +class FilesHandlerWidget extends StatelessWidget { + final ServerMessage message; + const FilesHandlerWidget({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + if (message.files.isEmpty) { + return const SizedBox.shrink(); + } else { + return SizedBox( + width: MediaQuery.sizeOf(context).width * 0.9, + height: MediaQuery.sizeOf(context).height * 0.12, + child: ListView.separated( + itemCount: message.files.length, + shrinkWrap: true, + reverse: true, + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) { + return const SizedBox(width: 6); + }, + itemBuilder: (context, index) { + if (message.files[index].mimeTypeToFileType() == 'image') { + return CachedNetworkImage( + imageUrl: message.files[index].thumbnail ?? '', + imageBuilder: (context, imageProvider) => Container( + margin: const EdgeInsets.only(bottom: 6, top: 2), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + image: DecorationImage(image: imageProvider, fit: BoxFit.cover), + ), + width: MediaQuery.sizeOf(context).width * 0.28, + height: MediaQuery.sizeOf(context).width * 0.22, + ), + placeholder: (context, url) => SizedBox( + width: MediaQuery.sizeOf(context).width * 0.28, + height: MediaQuery.sizeOf(context).width * 0.22, + child: const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + errorWidget: (context, url, error) => const Icon(Icons.error, color: Colors.white), + ); + } else { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + margin: const EdgeInsets.only(bottom: 6, top: 2), + width: MediaQuery.sizeOf(context).width * 0.32, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.insert_drive_file, color: Colors.white), + const SizedBox(height: 6), + Text( + message.files[index].name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ); + } + }, + ), + ); + } + } +} diff --git a/app/lib/pages/chat/widgets/user_message.dart b/app/lib/pages/chat/widgets/user_message.dart index 9589d0be2..8b8f7857d 100644 --- a/app/lib/pages/chat/widgets/user_message.dart +++ b/app/lib/pages/chat/widgets/user_message.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:friend_private/backend/schema/message.dart'; +import 'package:friend_private/pages/chat/widgets/files_handler_widget.dart'; import 'package:friend_private/widgets/extensions/string.dart'; import 'package:friend_private/utils/other/temp.dart'; @@ -25,13 +26,19 @@ class HumanMessage extends StatelessWidget { ), ), ), + FilesHandlerWidget(message: message), Wrap( alignment: WrapAlignment.end, children: [ Container( decoration: BoxDecoration( color: Theme.of(context).primaryColor, - borderRadius: const BorderRadius.all(Radius.circular(16.0)), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(2.0), + bottomRight: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), ), padding: const EdgeInsets.all(16.0), child: Text( diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index f1879783e..93eeec7d4 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_provider_utilities/flutter_provider_utilities.dart'; import 'package:friend_private/backend/http/api/conversations.dart'; -import 'package:friend_private/backend/http/api/messages.dart'; import 'package:friend_private/backend/preferences.dart'; import 'package:friend_private/backend/schema/bt_device/bt_device.dart'; import 'package:friend_private/backend/schema/conversation.dart'; @@ -25,12 +23,9 @@ import 'package:friend_private/services/sockets/sdcard_socket.dart'; import 'package:friend_private/services/sockets/transcription_connection.dart'; import 'package:friend_private/services/wals.dart'; import 'package:friend_private/utils/analytics/mixpanel.dart'; -import 'package:friend_private/utils/audio/wav_bytes.dart'; import 'package:friend_private/utils/enums.dart'; -import 'package:friend_private/utils/file.dart'; import 'package:friend_private/utils/logger.dart'; import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:uuid/uuid.dart'; diff --git a/app/lib/providers/message_provider.dart b/app/lib/providers/message_provider.dart index 78c074da2..57ed94c5b 100644 --- a/app/lib/providers/message_provider.dart +++ b/app/lib/providers/message_provider.dart @@ -1,11 +1,17 @@ +import 'dart:io'; + import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:friend_private/backend/http/api/messages.dart'; import 'package:friend_private/backend/http/api/users.dart'; import 'package:friend_private/backend/preferences.dart'; import 'package:friend_private/backend/schema/app.dart'; import 'package:friend_private/backend/schema/message.dart'; import 'package:friend_private/providers/app_provider.dart'; +import 'package:friend_private/utils/alerts/app_snackbar.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:friend_private/utils/file.dart'; class MessageProvider extends ChangeNotifier { @@ -20,10 +26,37 @@ class MessageProvider extends ChangeNotifier { String firstTimeLoadingText = ''; + List selectedFiles = []; + List selectedFileTypes = []; + List uploadedFiles = []; + bool isUploadingFiles = false; + Map uploadingFiles = {}; + void updateAppProvider(AppProvider p) { appProvider = p; } + void setIsUploadingFiles() { + if (uploadingFiles.values.contains(true)) { + isUploadingFiles = true; + } else { + isUploadingFiles = false; + } + notifyListeners(); + } + + void setMultiUploadingFileStatus(List ids, bool value) { + for (var id in ids) { + uploadingFiles[id] = value; + } + setIsUploadingFiles(); + notifyListeners(); + } + + bool isFileUploading(String id) { + return uploadingFiles[id] ?? false; + } + void setHasCachedMessages(bool value) { hasCachedMessages = value; notifyListeners(); @@ -49,6 +82,95 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } + void captureImage() async { + var res = await ImagePicker().pickImage(source: ImageSource.camera); + if (res != null) { + selectedFiles.add(File(res.path)); + selectedFileTypes.add('image'); + var index = selectedFiles.length - 1; + await uploadFiles([selectedFiles[index]], appProvider?.selectedChatAppId); + notifyListeners(); + } + } + + void selectImage() async { + if (selectedFiles.length >= 4) { + AppSnackbar.showSnackbarError('You can only select up to 4 images'); + return; + } + List res = []; + if (4 - selectedFiles.length == 1) { + res = [await ImagePicker().pickImage(source: ImageSource.gallery)]; + } else { + res = await ImagePicker().pickMultiImage(limit: 4 - selectedFiles.length); + } + if (res.isNotEmpty) { + List files = []; + for (var r in res) { + files.add(File(r.path)); + } + if (files.isNotEmpty) { + selectedFiles.addAll(files); + selectedFileTypes.addAll(res.map((e) => 'image')); + await uploadFiles(files, appProvider?.selectedChatAppId); + } + notifyListeners(); + } + } + + void selectFile() async { + var res = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowMultiple: true, + allowedExtensions: ['jpeg', 'md', 'pdf', 'gif', 'doc', 'png', 'pptx', 'txt', 'xlsx', 'webp']); + if (res != null) { + List files = []; + for (var r in res.files) { + files.add(File(r.path!)); + } + if (files.isNotEmpty) { + selectedFiles.addAll(files); + selectedFileTypes.addAll(res.files.map((e) => 'file')); + await uploadFiles(files, appProvider?.selectedChatAppId); + } + + notifyListeners(); + } + } + + void clearSelectedFile(int index) { + selectedFiles.removeAt(index); + selectedFileTypes.removeAt(index); + uploadedFiles.removeAt(index); + notifyListeners(); + } + + void clearSelectedFiles() { + selectedFiles.clear(); + selectedFileTypes.clear(); + notifyListeners(); + } + + void clearUploadedFiles() { + uploadedFiles.clear(); + notifyListeners(); + } + + Future uploadFiles(List files, String? appId) async { + if (files.isNotEmpty) { + setMultiUploadingFileStatus(files.map((e) => e.path).toList(), true); + var res = await uploadFilesServer(files, appId: appId); + if (res != null) { + uploadedFiles.addAll(res); + } else { + clearSelectedFiles(); + AppSnackbar.showSnackbarError('Failed to upload file, please try again later'); + } + setMultiUploadingFileStatus(files.map((e) => e.path).toList(), false); + notifyListeners(); + } + } + void removeLocalMessage(String id) { messages.removeWhere((m) => m.id == id); notifyListeners(); @@ -79,13 +201,11 @@ class MessageProvider extends ChangeNotifier { } Future> getMessagesFromServer({bool dropdownSelected = false}) async { - print('getMessagesFromServer'); if (!hasCachedMessages) { firstTimeLoadingText = 'Reading your memories...'; notifyListeners(); } setLoadingMessages(true); - print('appProvider?.selectedChatAppId: ${appProvider?.selectedChatAppId}'); var mes = await getMessagesServer( pluginId: appProvider?.selectedChatAppId, dropdownSelected: dropdownSelected, @@ -187,9 +307,10 @@ class MessageProvider extends ChangeNotifier { var message = ServerMessage.empty(appId: appId); messages.insert(0, message); notifyListeners(); - + List fileIds = uploadedFiles.map((e) => e.id).toList(); + clearSelectedFiles(); try { - await for (var chunk in sendMessageStreamServer(text, appId: appId)) { + await for (var chunk in sendMessageStreamServer(text, appId: appId, filesId: fileIds)) { if (chunk.type == MessageChunkType.think) { message.thinkings.add(chunk.text); notifyListeners(); @@ -226,7 +347,8 @@ class MessageProvider extends ChangeNotifier { Future sendMessageToServer(String message, String? appId) async { setShowTypingIndicator(true); messages.insert(0, ServerMessage.empty(appId: appId)); - var mes = await sendMessageServer(message, appId: appId); + List fileIds = uploadedFiles.map((e) => e.id).toList(); + var mes = await sendMessageServer(message, appId: appId, fileIds: fileIds); if (messages[0].id == '0000') { messages[0] = mes; } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d20cb2194..04b6ff59f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -98,6 +98,7 @@ dependencies: timeago: ^3.7.0 app_links: ^6.3.2 flutter_svg: ^2.0.16 + file_picker: 8.0.7 dependency_overrides: