diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4401ef0..43fc3c0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - didvan - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - FirebaseAppDelegateProxyEnabled - - LSRequiresIPhoneOS - - NSCameraUsageDescription - We need to access to the user gallery to add user profile photo - NSMicrophoneUsageDescription - We need to access to the microphone to record audio file - NSPhotoLibraryUsageDescription - We need to access to the user gallery to add user profile photo - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - fetch - remote-notification - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - - UIViewControllerBasedStatusBarAppearance - - UNNotificationServiceExtension - - $(PRODUCT_BUNDLE_IDENTIFIER).MyNotificationServiceExtension - - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + didvan + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FirebaseAppDelegateProxyEnabled + + LSRequiresIPhoneOS + + NSCameraUsageDescription + We need to access to the user gallery to add user profile photo + NSMicrophoneUsageDescription + We need to access to the microphone to record audio file + NSPhotoLibraryUsageDescription + We need to access to the user gallery to add user profile photo + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + + UNNotificationServiceExtension + + $(PRODUCT_BUNDLE_IDENTIFIER).MyNotificationServiceExtension + + NSMicrophoneUsageDescription + Some message to describe why you need this permission + + \ No newline at end of file diff --git a/lib/models/ai/ai_chat_args.dart b/lib/models/ai/ai_chat_args.dart index 0caf703..d0ab3cf 100644 --- a/lib/models/ai/ai_chat_args.dart +++ b/lib/models/ai/ai_chat_args.dart @@ -1,8 +1,9 @@ import 'package:didvan/models/ai/bots_model.dart'; +import 'package:didvan/models/ai/chats_model.dart'; class AiChatArgs { final BotsModel bot; - final int? chatId; + final ChatsModel? chat; - AiChatArgs({required this.bot, this.chatId}); + AiChatArgs({required this.bot, this.chat}); } diff --git a/lib/models/ai/chats_model.dart b/lib/models/ai/chats_model.dart index 02e71a1..7c07d41 100644 --- a/lib/models/ai/chats_model.dart +++ b/lib/models/ai/chats_model.dart @@ -10,17 +10,20 @@ class ChatsModel { String? updatedAt; BotsModel? bot; List? prompts; + bool? isEditing; - ChatsModel( - {this.id, - this.userId, - this.botId, - this.title, - this.placeholder, - this.createdAt, - this.updatedAt, - this.bot, - this.prompts}); + ChatsModel({ + this.id, + this.userId, + this.botId, + this.title, + this.placeholder, + this.createdAt, + this.updatedAt, + this.bot, + this.prompts, + this.isEditing = false, + }); ChatsModel.fromJson(Map json) { id = json['id']; @@ -56,6 +59,31 @@ class ChatsModel { } return data; } + + ChatsModel copyWith( + {int? id, + int? userId, + int? botId, + String? title, + String? placeholder, + String? createdAt, + String? updatedAt, + BotsModel? bot, + List? prompts, + bool? isEditing}) { + return ChatsModel( + id: id ?? this.id, + userId: userId ?? this.userId, + botId: botId ?? this.botId, + title: title ?? this.title, + placeholder: placeholder ?? this.placeholder, + bot: bot ?? this.bot, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + prompts: prompts ?? this.prompts, + isEditing: isEditing ?? this.isEditing, + ); + } } class Prompts { @@ -67,16 +95,19 @@ class Prompts { String? role; String? createdAt; bool? finished; + bool? error; - Prompts( - {this.id, - this.chatId, - this.text, - this.file, - this.fileName, - this.role, - this.createdAt, - this.finished}); + Prompts({ + this.id, + this.chatId, + this.text, + this.file, + this.fileName, + this.role, + this.createdAt, + this.finished, + this.error, + }); Prompts.fromJson(Map json) { id = json['id']; @@ -100,16 +131,16 @@ class Prompts { return data; } - Prompts copyWith({ - int? id, - int? chatId, - String? text, - String? file, - String? fileName, - String? role, - String? createdAt, - bool? finished, - }) { + Prompts copyWith( + {int? id, + int? chatId, + String? text, + String? file, + String? fileName, + String? role, + String? createdAt, + bool? finished, + bool? error}) { return Prompts( id: id ?? this.id, chatId: chatId ?? this.chatId, @@ -119,6 +150,7 @@ class Prompts { role: role ?? this.role, createdAt: createdAt ?? this.createdAt, finished: finished ?? this.finished, + error: error ?? this.error, ); } } diff --git a/lib/models/ai/files_model.dart b/lib/models/ai/files_model.dart new file mode 100644 index 0000000..d5c54ea --- /dev/null +++ b/lib/models/ai/files_model.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class FilesModel { + final String path; + late String basename; + late String extname; + late File main; + + FilesModel(this.path) { + basename = p.basename(path); + extname = p.extension(path); + main = File(path); + } +} diff --git a/lib/models/view/action_sheet_data.dart b/lib/models/view/action_sheet_data.dart index f1b533d..e463af9 100644 --- a/lib/models/view/action_sheet_data.dart +++ b/lib/models/view/action_sheet_data.dart @@ -12,6 +12,7 @@ class ActionSheetData { final Color? titleColor; final bool hasDismissButton; final bool hasConfirmButton; + final bool hasConfirmButtonClose; final bool withoutButtonMode; final bool smallDismissButton; final bool isBackgroundDropBlur; @@ -32,6 +33,7 @@ class ActionSheetData { this.smallDismissButton = false, this.withoutButtonMode = false, this.isBackgroundDropBlur = false, + this.hasConfirmButtonClose = true, this.backgroundColor, }); } diff --git a/lib/routes/route_generator.dart b/lib/routes/route_generator.dart index 6ded8a5..b23d322 100644 --- a/lib/routes/route_generator.dart +++ b/lib/routes/route_generator.dart @@ -306,6 +306,10 @@ class RouteGenerator { child: AiChatPage( args: settings.arguments as AiChatArgs, ))); + case Routes.aiHistory: + return _createRoute(HistoryAiChatPage( + archived: settings.arguments as bool?, + )); default: return _errorRoute(settings.name ?? ''); } diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index fd655d5..57e58ba 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -1,6 +1,8 @@ class Routes { static const String splash = '/'; static const String aiChat = '/ai-chat'; + static const String aiHistory = '/ai-history'; + static const String home = '/home'; static const String radars = '/radars'; static const String news = '/news'; diff --git a/lib/services/ai/ai_api_service.dart b/lib/services/ai/ai_api_service.dart index 47508a8..5ccc09f 100644 --- a/lib/services/ai/ai_api_service.dart +++ b/lib/services/ai/ai_api_service.dart @@ -1,17 +1,17 @@ // ignore_for_file: depend_on_referenced_packages import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:didvan/services/storage/storage.dart'; import 'package:http/http.dart' as http; -import 'package:http/http.dart'; +import 'package:mime/mime.dart'; import 'package:path/path.dart' as p; import 'package:http_parser/http_parser.dart' as parser; class AiApiService { static const String baseUrl = 'https://api.didvan.app/ai'; - static final _client = http.Client(); static Future initial( {required final String url, @@ -33,25 +33,40 @@ class AiApiService { if (file != null) { final length = await file.length(); String basename = p.basename(file.path); - String mediaExtension = p.extension(file.path).replaceAll('.', ''); - String mediaFormat = 'image'; - if (mediaExtension.contains('pdf')) { - mediaFormat = 'application'; + String? mimeType = + lookupMimeType(file.path); // Use MIME type instead of file extension + mimeType ??= 'application/octet-stream'; + if (mimeType.startsWith('audio')) { + mimeType = 'audio/${p.extension(file.path).replaceAll('.', '')}'; } request.files.add( http.MultipartFile('file', file.readAsBytes().asStream(), length, filename: basename, - contentType: parser.MediaType(mediaFormat, mediaExtension)), + contentType: parser.MediaType.parse( + mimeType)), // Use MediaType.parse to parse the MIME type ); } return request; } - static Future getResponse(http.MultipartRequest request) async { - final res = _client.send(request).timeout( - const Duration(seconds: 30), - ); - final http.StreamedResponse response = await res; - return response.stream; + static Future>> getResponse( + http.MultipartRequest req) async { + try { + final response = await http.Client().send(req); + if (response.statusCode == 400) { + // Handle 400 response + final errorResponse = await response.stream.bytesToString(); + final errorJson = jsonDecode(errorResponse); + throw Exception(errorJson['error'] ?? 'Bad Request'); + } else if (response.statusCode != 200) { + // Handle other non-200 responses + throw Exception('Failed to load data'); + } else { + return response.stream; + } + } catch (e) { + // Handle any other errors + throw Exception('Failed to load data'); + } } } diff --git a/lib/services/app_initalizer.dart b/lib/services/app_initalizer.dart index e65d2f8..dc04a4a 100644 --- a/lib/services/app_initalizer.dart +++ b/lib/services/app_initalizer.dart @@ -1,4 +1,3 @@ - import 'package:didvan/main.dart'; import 'package:didvan/models/notification_message.dart'; import 'package:didvan/models/requests/news.dart'; @@ -171,4 +170,24 @@ class AppInitializer { int id = int.parse('${data.userId}${data.id}$t'); return id; } + + static Map messagesData(String dataMessgae) { + dataMessgae = dataMessgae.replaceAll('{{{', ''); + dataMessgae = dataMessgae.replaceAll('}}}', ''); + final pairs = dataMessgae.substring(1, dataMessgae.length - 1).split(','); + + // Create a map to store the key-value pairs + final data = {}; + // Iterate over the key-value pairs and add them to the map + for (final pair in pairs) { + final keyValue = pair.split(':'); + if (keyValue.length == 2) { + final key = keyValue[0].trim().replaceAll('"', ''); + final value = keyValue[1].trim().replaceAll('"', ''); + data[key] = int.parse(value.replaceAll(' ', '')); + } + } + + return data; + } } diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index fcb9f96..c7d2d08 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -109,4 +109,11 @@ class MediaService { ); return result; } + + static Future pickAudioFile() async { + return await FilePicker.platform.pickFiles( + type: FileType.audio, + allowMultiple: false, + ); + } } diff --git a/lib/services/network/request.dart b/lib/services/network/request.dart index 89a4e64..6f60897 100644 --- a/lib/services/network/request.dart +++ b/lib/services/network/request.dart @@ -66,7 +66,7 @@ class RequestService { headers: _headers, ) .timeout( - const Duration(seconds: 100), + const Duration(seconds: 30), ) .catchError( (e) => throw e, @@ -86,7 +86,7 @@ class RequestService { headers: _headers, ) .timeout( - const Duration(seconds: 100), + const Duration(seconds: 30), ) .catchError( (e) { @@ -108,7 +108,7 @@ class RequestService { headers: _headers, ) .timeout( - const Duration(seconds: 100), + const Duration(seconds: 30), ) .catchError( (e) => throw e, @@ -149,7 +149,7 @@ class RequestService { final streamedResponse = await request .send() .timeout( - const Duration(seconds: 100), + const Duration(seconds: 30), ) .catchError( (e) => throw e, @@ -170,7 +170,7 @@ class RequestService { headers: _headers, ) .timeout( - const Duration(seconds: 100), + const Duration(seconds: 30), ) .catchError( (e) => throw e, diff --git a/lib/services/network/request_helper.dart b/lib/services/network/request_helper.dart index 39c2d71..b447b17 100644 --- a/lib/services/network/request_helper.dart +++ b/lib/services/network/request_helper.dart @@ -201,14 +201,33 @@ class RequestHelper { static String reportComment(int id) => '$baseUrl/comment/$id/report'; static String widgetNews() => '$baseUrl/user/widget'; static String aiChats() => '$baseUrl/ai/chat'; + static String aiArchived() => '$baseUrl/ai/chat${_urlConcatGenerator([ + const MapEntry('archived', true), + ])}'; static String aiBots() => '$baseUrl/ai/bot'; static String aiSearchBots(String q) => '$baseUrl/ai/bot${_urlConcatGenerator([ MapEntry('q', q), ])}'; + static String aiSearchChats(String q) => + '$baseUrl/ai/chat${_urlConcatGenerator([ + MapEntry('q', q), + ])}'; + static String aiSearchArchived(String q) => + '$baseUrl/ai/chat${_urlConcatGenerator([ + MapEntry('q', q), + const MapEntry('archived', true), + ])}'; static String aiAChat(int id) => '$baseUrl/ai/chat/$id'; static String aiChatId() => '$baseUrl/ai/chat/id'; static String aiDeleteChats() => '$baseUrl/ai/chat'; + static String aiChangeChats(int id) => '$baseUrl/ai/chat/$id/title'; + static String deleteChat(int id) => '$baseUrl/ai/chat/$id'; + static String deleteMessage(int chatId, int messageId) => + '$baseUrl/ai/chat/$chatId/message/$messageId'; + static String deleteAllChats() => '$baseUrl/ai/chat/all'; + static String archivedChat(int id) => '$baseUrl/ai/chat/$id/archive'; + static String placeholder(int id) => '$baseUrl/ai/chat/$id/placeholder'; static String _urlConcatGenerator(List> additions) { String result = ''; diff --git a/lib/utils/action_sheet.dart b/lib/utils/action_sheet.dart index 602ebfc..cd0f7a0 100644 --- a/lib/utils/action_sheet.dart +++ b/lib/utils/action_sheet.dart @@ -2,14 +2,17 @@ import 'dart:async'; import 'dart:ui'; import 'package:bot_toast/bot_toast.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:didvan/config/design_config.dart'; import 'package:didvan/config/theme_data.dart'; import 'package:didvan/constants/assets.dart'; import 'package:didvan/models/enums.dart'; import 'package:didvan/models/view/action_sheet_data.dart'; import 'package:didvan/models/view/alert_data.dart'; +import 'package:didvan/views/ai/history_ai_chat_state.dart'; import 'package:didvan/views/widgets/didvan/button.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; +import 'package:didvan/views/widgets/state_handlers/empty_state.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -253,8 +256,10 @@ class ActionSheetUtils { Expanded( child: DidvanButton( onPressed: () { - pop(); data.onConfirmed?.call(); + if (data.hasConfirmButtonClose) { + pop(); + } }, title: data.confrimTitle ?? 'تایید', ), @@ -269,6 +274,117 @@ class ActionSheetUtils { ); } + static Future botsDialogSelect( + {required final BuildContext context, + required final HistoryAiChatState state}) async { + ActionSheetUtils.context = context; + ActionSheetUtils.openDialog( + data: ActionSheetData( + hasConfirmButton: false, + hasDismissButton: false, + content: Column( + children: [ + // Row( + // mainAxisAlignment: MainAxisAlignment.end, + // children: [ + // Padding( + // padding: const EdgeInsets.symmetric(vertical: 8.0), + // child: InkWell( + // onTap: () { + // ActionSheetUtils.pop(); + // }, + // child: const Icon(DidvanIcons.close_solid)), + // ) + // ], + // ), + // SearchField( + // title: 'هوش مصنوعی', + // value: state.search, + // onChanged: (value) { + // state.search = value; + // if (value.isEmpty) { + // state.getBots(); + // return; + // } + // state.timer?.cancel(); + // state.timer = Timer(const Duration(seconds: 1), () { + // state.getSearchBots(value); + // }); + // }, + // focusNode: FocusNode()), + // const SizedBox( + // height: 12, + // ), + SizedBox( + width: double.infinity, + height: MediaQuery.sizeOf(context).height / 3, + child: ValueListenableBuilder( + valueListenable: state.loadingBots, + builder: (context, value, child) => value + ? Center( + child: Image.asset( + Assets.loadingAnimation, + width: 60, + height: 60, + ), + ) + : state.bots.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0), + child: EmptyState( + asset: Assets.emptyResult, + title: 'نتیجه‌ای پیدا نشد', + height: 120, + ), + ) + : ListView.builder( + padding: + const EdgeInsets.symmetric(vertical: 12), + itemCount: state.bots.length, + physics: const BouncingScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + final bot = state.bots[index]; + return InkWell( + onTap: () { + ActionSheetUtils.pop(); + state.bot = bot; + state.update(); + }, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + vertical: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context) + .colorScheme + .border, + width: 1))), + child: Row( + children: [ + ClipOval( + child: CachedNetworkImage( + imageUrl: bot.image.toString(), + width: 42, + height: 42, + ), + ), + const SizedBox(width: 12), + Text(bot.name.toString()) + ], + ), + ), + ); + }), + ), + ) + ], + ))); + } + static void pop() { DesignConfig.updateSystemUiOverlayStyle(); Navigator.of(context).pop(); diff --git a/lib/views/ai/ai.dart b/lib/views/ai/ai.dart index 8b017c1..61d7afe 100644 --- a/lib/views/ai/ai.dart +++ b/lib/views/ai/ai.dart @@ -1,6 +1,18 @@ // ignore_for_file: library_private_types_in_public_api +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:didvan/config/design_config.dart'; +import 'package:didvan/config/theme_data.dart'; +import 'package:didvan/constants/app_icons.dart'; +import 'package:didvan/constants/assets.dart'; +import 'package:didvan/models/ai/ai_chat_args.dart'; +import 'package:didvan/routes/routes.dart'; +import 'package:didvan/utils/action_sheet.dart'; +import 'package:didvan/views/ai/history_ai_chat_state.dart'; +import 'package:didvan/views/home/home.dart'; +import 'package:didvan/views/widgets/didvan/text.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class Ai extends StatefulWidget { const Ai({Key? key}) : super(key: key); @@ -10,8 +22,162 @@ class Ai extends StatefulWidget { } class _AiState extends State { + @override + void initState() { + final state = context.read(); + Future.delayed( + Duration.zero, + () { + // state.getChats(); + state.getBots(); + }, + ); + + super.initState(); + } + @override Widget build(BuildContext context) { - return Container(); + return Consumer( + builder: (context, state, child) { + if (state.bots.isEmpty) { + return Center( + child: Image.asset( + Assets.loadingAnimation, + width: 60, + height: 60, + ), + ); + } + final bot = state.bot!; + return Stack( + children: [ + Column( + children: [ + const SizedBox( + height: 12, + ), + Icon( + DidvanIcons.ai_solid, + size: MediaQuery.sizeOf(context).width / 5, + ), + const DidvanText('هوشان'), + const SizedBox( + height: 24, + ), + InkWell( + onTap: () => ActionSheetUtils.botsDialogSelect( + context: context, state: state), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(DidvanIcons.caret_down_solid), + Text(bot.name.toString()), + ], + ), + ), + const SizedBox( + height: 8, + ), + Container( + width: MediaQuery.sizeOf(context).height / 5, + height: MediaQuery.sizeOf(context).height / 5, + decoration: BoxDecoration( + borderRadius: DesignConfig.highBorderRadius, + color: Theme.of(context).colorScheme.focused), + padding: const EdgeInsets.all(12), + child: CachedNetworkImage( + imageUrl: bot.image.toString(), + ), + ), + const SizedBox( + height: 24, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + "به هوشان, هوش مصنوعی دیدوان خوش آمدید. \nبرای شروع گفتگو پیام مورد نظر خود را در کادر زیر بنویسید.\n دریافت پاسخ از: ${bot.name}", + textAlign: TextAlign.center, + ), + ) + ], + ), + Positioned( + bottom: 32, + left: 20, + right: 20, + child: InkWell( + onTap: () => Navigator.of(context).pushNamed(Routes.aiChat, + arguments: AiChatArgs( + bot: bot, + )), + child: Container( + decoration: BoxDecoration( + boxShadow: DesignConfig.defaultShadow, + color: Theme.of(context).colorScheme.white, + borderRadius: BorderRadius.circular(360)), + child: Row( + children: [ + const SizedBox( + width: 8, + ), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.border), + child: const Icon( + DidvanIcons.mic_regular, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: Form( + child: TextFormField( + textInputAction: TextInputAction.newline, + style: Theme.of(context).textTheme.bodyMedium, + minLines: 1, + enabled: false, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'بنویسید...', + hintStyle: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .disabledText), + ), + )))) + ], + ), + )), + ), + Positioned( + top: 32, + right: 0, + child: InkWell( + onTap: () => homeScaffKey.currentState!.openDrawer(), + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12)), + boxShadow: DesignConfig.defaultShadow), + child: const Icon(DidvanIcons.angle_left_light), + )), + ) + ], + ); + }, + ); } } diff --git a/lib/views/ai/ai_chat_page.dart b/lib/views/ai/ai_chat_page.dart index aa38c53..16b85b4 100644 --- a/lib/views/ai/ai_chat_page.dart +++ b/lib/views/ai/ai_chat_page.dart @@ -1,39 +1,32 @@ // ignore_for_file: library_private_types_in_public_api, deprecated_member_use, depend_on_referenced_packages -import 'dart:io'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:didvan/config/design_config.dart'; import 'package:didvan/config/theme_data.dart'; import 'package:didvan/constants/app_icons.dart'; import 'package:didvan/constants/assets.dart'; -import 'package:didvan/main.dart'; import 'package:didvan/models/ai/ai_chat_args.dart'; import 'package:didvan/models/ai/chats_model.dart'; -import 'package:didvan/models/ai/messages_model.dart'; +import 'package:didvan/models/ai/files_model.dart'; import 'package:didvan/models/enums.dart'; +import 'package:didvan/models/view/action_sheet_data.dart'; import 'package:didvan/models/view/alert_data.dart'; -import 'package:didvan/services/media/media.dart'; +import 'package:didvan/services/storage/storage.dart'; import 'package:didvan/utils/action_sheet.dart'; import 'package:didvan/utils/date_time.dart'; import 'package:didvan/views/ai/ai_chat_state.dart'; import 'package:didvan/views/ai/history_ai_chat_state.dart'; -import 'package:didvan/views/widgets/animated_visibility.dart'; +import 'package:didvan/views/ai/widgets/ai_message_bar.dart'; import 'package:didvan/views/widgets/didvan/icon_button.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; -import 'package:didvan/views/widgets/marquee_text.dart'; -import 'package:didvan/views/widgets/state_handlers/empty_state.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:image_cropper/image_cropper.dart'; -import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; import 'package:persian_number_utility/persian_number_utility.dart'; import 'package:provider/provider.dart'; -import 'package:path/path.dart' as p; +import 'package:voice_message_package/voice_message_package.dart'; class AiChatPage extends StatefulWidget { final AiChatArgs args; @@ -44,15 +37,32 @@ class AiChatPage extends StatefulWidget { } class _AiChatPageState extends State { - TextEditingController message = TextEditingController(); - + FocusNode focusNode = FocusNode(); @override void initState() { final state = context.read(); - state.chatId = widget.args.chatId; - if (state.chatId != null) { - state.getAllMessages(state.chatId!); + if (widget.args.chat != null) { + state.chatId = widget.args.chat!.id!; + state.chat = widget.args.chat; } + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (state.chatId != null) { + state.getAllMessages(state.chatId!).then((value) => Future.delayed( + const Duration( + milliseconds: 100, + ), + () => focusNode.requestFocus(), + )); + } else { + Future.delayed( + const Duration( + milliseconds: 100, + ), + () => focusNode.requestFocus(), + ); + } + }); super.initState(); } @@ -63,372 +73,216 @@ class _AiChatPageState extends State { context.read().getChats(); return true; }, - child: Scaffold( - appBar: AppBar( - shadowColor: Theme.of(context).colorScheme.border, - title: Row( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: ClipOval( - child: CachedNetworkImage( - width: 32, - height: 32, - imageUrl: widget.args.bot.image.toString(), - ), - ), - ), - Text('چت با ${widget.args.bot.name}'), - ], - ), - automaticallyImplyLeading: false, - actions: [ - DidvanIconButton( - icon: DidvanIcons.angle_left_regular, - onPressed: () { - context.read().getChats(); - navigatorKey.currentState!.pop(); - }, - ) - ], - ), - body: Consumer( - builder: (BuildContext context, AiChatState state, Widget? child) => - state.loading - ? Center( - child: Image.asset( - Assets.loadingAnimation, - width: 60, - height: 60, - ), - ) - : state.messages.isEmpty - ? Center( - child: EmptyState( - asset: Assets.emptyChat, - title: 'اولین پیام را بنویسید...', - ), - ) - : SingleChildScrollView( - reverse: true, - controller: state.scrollController, - child: ListView.builder( - itemCount: state.messages.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 90), - itemBuilder: (context, mIndex) { - final prompts = state.messages[mIndex].prompts; - final time = state.messages[mIndex].dateTime; - return Column( - children: [ - timeLabel(context, time), - ListView.builder( - itemCount: prompts.length, - shrinkWrap: true, - physics: - const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final message = prompts[index]; - return messageBubble(message, context, - state, index, mIndex); - }, - ), - ], - ); - }), - ), - ), - bottomSheet: Consumer( - builder: (BuildContext context, AiChatState state, Widget? child) { - return Container( - width: MediaQuery.sizeOf(context).width, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.cardBorder, - ), - ), - color: Theme.of(context).colorScheme.surface, - ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Consumer( + builder: (context, state, child) => Scaffold( + appBar: AppBar( + shadowColor: Theme.of(context).colorScheme.border, + title: Text(widget.args.bot.name.toString()), + leading: Row( children: [ - if (widget.args.bot.attachment! == 2) fileContainer(context), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox( - width: 12, - ), - SizedBox( - width: 46, - height: 46, - child: Center( - child: state.onResponsing - ? Center( - child: SpinKitThreeBounce( - size: 18, - color: Theme.of(context) - .colorScheme - .focusedBorder, - ), - ) - : DidvanIconButton( - icon: DidvanIcons.send_solid, - size: 32, - color: Theme.of(context) - .colorScheme - .focusedBorder, - onPressed: () async { - if (state.file == null && - message.text.isEmpty) { - return; - } - - if (state.messages.isNotEmpty && - DateTime.parse( - state.messages.last.dateTime) - .toPersianDateStr() - .contains(DateTime.parse( - DateTime.now() - .subtract( - const Duration( - minutes: 210)) - .toIso8601String()) - .toPersianDateStr())) { - state.messages.last.prompts.add(Prompts( - text: message.text, - file: p.basename(state.file!.path), - fileName: p.basename(state.file!.path), - finished: true, - role: 'user', - createdAt: DateTime.now() - .subtract( - const Duration(minutes: 210)) - .toIso8601String(), - )); - } else { - state.messages.add(MessageModel( - dateTime: DateTime.now() - .subtract( - const Duration(minutes: 210)) - .toIso8601String(), - prompts: [ - Prompts( - text: message.text, - finished: true, - file: - p.basename(state.file!.path), - fileName: - p.basename(state.file!.path), - role: 'user', - createdAt: DateTime.now() - .subtract(const Duration( - minutes: 210)) - .toIso8601String(), - ) - ])); - } - - await state.postMessage(widget.args.bot); - message.clear(); - state.file = null; - }, - ), - ), - ), - const SizedBox( - width: 12, - ), - Expanded( - child: Form( - child: widget.args.bot.attachment! != 1 - ? TextFormField( - textInputAction: TextInputAction.newline, - style: Theme.of(context).textTheme.bodyMedium, - maxLines: 6, - minLines: 1, - // keyboardType: TextInputType.text, - controller: message, - enabled: !state.onResponsing, - decoration: InputDecoration( - border: InputBorder.none, - hintText: 'بنویسید...', - hintStyle: Theme.of(context) - .textTheme - .bodySmall! - .copyWith( - color: Theme.of(context) - .colorScheme - .disabledText), - ), - onChanged: (value) {}, - ) - : fileContainer(context), - ), - ), - const SizedBox( - width: 12, - ), - if (widget.args.bot.attachment! != 0) - SizedBox( - width: 46, - height: 46, - child: Center( - child: PopupMenuButton( - onSelected: (value) async { - switch (value) { - case 'Pdf': - FilePickerResult? result = - await MediaService.pickPdfFile(); - if (result != null) { - final File file = - File(result.files.single.path!); - state.file = file; - // Do something with the selected PDF file - } - // else { - //// User cancelled the file selection - // } - break; - - case 'Image': - final pickedFile = - await MediaService.pickImage( - source: ImageSource.gallery); - File? file; - if (pickedFile != null && !kIsWeb) { - file = await ImageCropper().cropImage( - sourcePath: pickedFile.path, - androidUiSettings: - const AndroidUiSettings( - toolbarTitle: 'برش تصویر'), - iosUiSettings: const IOSUiSettings( - title: 'برش تصویر', - doneButtonTitle: 'تایید', - cancelButtonTitle: 'بازگشت', - ), - compressQuality: 30, - ); - if (file == null) return; - } - if (pickedFile == null) return; - state.file = - kIsWeb ? File(pickedFile.path) : file; - - break; - default: - } - - state.update(); - }, - itemBuilder: (BuildContext context) => - [ - popUpBtns(value: 'Pdf'), - popUpBtns(value: 'Image'), - ], - offset: const Offset(0, -140), - position: PopupMenuPosition.over, - child: Icon( - Icons.attach_file_rounded, - color: - Theme.of(context).colorScheme.focusedBorder, - ), - ), - ), - ), - ], + DidvanIconButton( + icon: DidvanIcons.angle_right_solid, + onPressed: () { + Navigator.of(context).pop(); + context.read().getChats(); + }, ), ], - )); - }), - ), - ); - } - - AnimatedVisibility fileContainer(BuildContext context) { - final state = context.read(); - String basename = ''; - if (state.file != null) { - basename = p.basename(state.file!.path); - } - return AnimatedVisibility( - isVisible: state.file != null, - duration: DesignConfig.lowAnimationDuration, - child: Container( - decoration: BoxDecoration( - borderRadius: DesignConfig.mediumBorderRadius, - color: Theme.of(context).colorScheme.border, - ), - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - margin: widget.args.bot.attachment! != 1 - ? const EdgeInsets.symmetric(horizontal: 12) - : EdgeInsets.zero, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon(Icons.file_copy), - const SizedBox( - width: 12, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 160, - height: 24, - child: MarqueeText( - text: basename, - style: const TextStyle(fontSize: 14), - stop: const Duration(seconds: 3), - ), - ), - if (state.file != null) - FutureBuilder( - future: state.file!.length(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - return DidvanText( - 'File Size ${(snapshot.data! / 1000).round()} KB', - fontSize: 12, - ); - }) - ], - ) + ), + actions: [ + if (state.chatId != null) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: InkWell( + onTap: () { + final TextEditingController placeholder = + TextEditingController( + text: state.chat?.placeholder); + ActionSheetUtils.openDialog( + data: ActionSheetData( + hasConfirmButtonClose: false, + onConfirmed: () async { + final state = context.read(); + await state + .changePlaceHolder(placeholder.text); + ActionSheetUtils.pop(); + }, + content: ValueListenableBuilder( + valueListenable: state.changingPlaceHolder, + builder: (context, value, child) => Column( + children: [ + Stack( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + DidvanText( + 'شخصی‌سازی دستورات', + style: Theme.of(context) + .textTheme + .titleMedium, + ), + ], + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Center( + child: InkWell( + onTap: () { + ActionSheetUtils.pop(); + }, + child: const Icon( + DidvanIcons.close_solid, + size: 24, + ), + ), + )), + ], + ), + const SizedBox( + height: 12, + ), + const DidvanText( + 'دوست دارید هوشان، چه چیزهایی را درباره شما بداند تا بتواند پاسخ‌های بهتری ارائه دهد؟ \nدستورات و اطلاعات ارائه شما، بر روی تمامی پیام‌هایی که از این به بعد ارسال می‌کنید، اعمال خواهد شد.'), + const SizedBox( + height: 12, + ), + value + ? Center( + child: Image.asset( + Assets.loadingAnimation, + width: 60, + height: 60, + ), + ) + : TextField( + controller: placeholder, + style: (Theme.of(context) + .textTheme + .bodyMedium)! + .copyWith( + fontFamily: DesignConfig + .fontFamily + .padRight(3)), + decoration: InputDecoration( + filled: true, + fillColor: Theme.of(context) + .colorScheme + .secondCTA, + contentPadding: + const EdgeInsets.fromLTRB( + 10, 10, 10, 0), + border: const OutlineInputBorder( + borderRadius: DesignConfig + .lowBorderRadius), + errorStyle: const TextStyle( + height: 0.01), + ), + ) + ], + ), + ))); + }, + child: const Icon(Icons.shopping_bag_outlined)), + ) ], + centerTitle: true, + automaticallyImplyLeading: false, ), - InkWell( - onTap: () { - state.file = null; - state.update(); - }, - child: const Icon(DidvanIcons.close_circle_solid)) - ], - ), - ), - ); - } - - PopupMenuItem popUpBtns({required final String value}) { - return PopupMenuItem( - value: value, - height: 46, - child: Row( - children: [ - const Icon(Icons.picture_as_pdf_rounded), - const SizedBox( - width: 12, - ), - DidvanText( - value, - ), - ], + body: state.loading + ? Center( + child: Image.asset( + Assets.loadingAnimation, + width: 60, + height: 60, + ), + ) + : state.messages.isEmpty + ? Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox( + height: 12, + ), + Center( + child: Icon( + DidvanIcons.ai_solid, + size: MediaQuery.sizeOf(context).width / 5, + ), + ), + const DidvanText('هوشان'), + const SizedBox( + height: 24, + ), + Container( + width: MediaQuery.sizeOf(context).height / 5, + height: MediaQuery.sizeOf(context).height / 5, + decoration: BoxDecoration( + borderRadius: DesignConfig.highBorderRadius, + color: Theme.of(context).colorScheme.focused), + padding: const EdgeInsets.all(12), + child: CachedNetworkImage( + imageUrl: widget.args.bot.image.toString(), + ), + ), + const SizedBox( + height: 24, + ), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + "به هوشان, هوش مصنوعی دیدوان خوش آمدید. \nبرای شروع گفتگو پیام مورد نظر خود را در کادر زیر بنویسید.\n دریافت پاسخ از: ${widget.args.bot.name}", + textAlign: TextAlign.center, + ), + ) + ], + ) + : SingleChildScrollView( + reverse: true, + controller: state.scrollController, + child: ListView.builder( + itemCount: state.messages.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 90), + itemBuilder: (context, mIndex) { + final prompts = state.messages[mIndex].prompts; + final time = state.messages[mIndex].dateTime; + return Column( + children: [ + timeLabel(context, time), + ListView.builder( + itemCount: prompts.length, + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final message = prompts[index]; + return messageBubble(message, context, + state, index, mIndex); + }, + ), + ], + ); + }), + ), + bottomSheet: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 8, bottom: 24.0, left: 12, right: 12), + child: AiMessageBar( + bot: widget.args.bot, + focusNode: focusNode, + ), + ), + ], + )), ), ); } @@ -455,6 +309,9 @@ class _AiChatPageState extends State { Padding messageBubble(Prompts message, BuildContext context, AiChatState state, int index, int mIndex) { + FilesModel? file = + message.file == null ? null : FilesModel(message.file.toString()); + MarkdownStyleSheet defaultMarkdownStyleSheet = MarkdownStyleSheet( code: TextStyle( backgroundColor: Theme.of(context).colorScheme.black, @@ -489,10 +346,12 @@ class _AiChatPageState extends State { ? Radius.zero : null, ), - color: (message.role.toString().contains('user') - ? Theme.of(context).colorScheme.surface - : Theme.of(context).colorScheme.focused) - .withOpacity(0.9), + color: message.error != null && message.error! + ? Theme.of(context).colorScheme.error.withOpacity(0.4) + : (message.role.toString().contains('user') + ? Theme.of(context).colorScheme.surface + : Theme.of(context).colorScheme.focused) + .withOpacity(0.9), border: Border.all( color: Theme.of(context).colorScheme.border, width: 0.5, @@ -502,66 +361,131 @@ class _AiChatPageState extends State { constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width / 1.5), child: message.finished != null && !message.finished! - ? StreamBuilder( - stream: state.messageOnstream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - return Markdown( - data: "${snapshot.data}...", - selectable: false, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - styleSheet: defaultMarkdownStyleSheet); - }, + ? Column( + children: [ + ValueListenableBuilder>( + valueListenable: state.messageOnstream, + builder: (context, value, child) { + return StreamBuilder( + stream: value, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return Markdown( + data: "${snapshot.data}...", + selectable: false, + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + styleSheet: + defaultMarkdownStyleSheet); + }, + ); + }), + SpinKitThreeBounce( + color: Theme.of(context).colorScheme.primary, + size: 18, + ) + ], ) : Column( children: [ if (message.role.toString().contains('user') && - message.file != null) - Container( - decoration: BoxDecoration( - borderRadius: DesignConfig.mediumBorderRadius, - color: Theme.of(context).colorScheme.border, - ), - padding: - const EdgeInsets.fromLTRB(12, 8, 12, 8), - margin: widget.args.bot.attachment! != 1 - ? const EdgeInsets.symmetric(horizontal: 12) - : EdgeInsets.zero, - child: Row( - children: [ - const Icon(Icons.file_copy), - const SizedBox( - width: 12, - ), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - SizedBox( - width: 200, - child: DidvanText( - (message.fileName.toString())), - ), - if (state.file != null) - FutureBuilder( - future: state.file!.length(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - return DidvanText( - 'File Size ${(snapshot.data! / 1000).round()} KB', - fontSize: 12, - ); - }) - ], + file != null) + lookupMimeType(file.path)?.startsWith('audio/') ?? + false + ? Directionality( + textDirection: TextDirection.ltr, + child: FutureBuilder( + future: StorageService.getValue( + key: 'token'), + builder: (context, snapshot) { + return VoiceMessageView( + size: 32, + controller: VoiceController( + audioSrc: file.path + .startsWith('/uploads') + ? 'https://api.didvan.app${file.path}?accessToken=${snapshot.data}' + : file.path, + onComplete: () { + /// do something on complete + }, + onPause: () { + /// do something on pause + }, + onPlaying: () { + /// do something on playing + }, + onError: (err) { + /// do somethin on error + }, + isFile: !file.path + .startsWith('/uploads'), + maxDuration: + const Duration(seconds: 10), + ), + innerPadding: 0, + cornerRadius: 20, + circlesColor: Theme.of(context) + .colorScheme + .primary, + activeSliderColor: + Theme.of(context) + .colorScheme + .primary, + ); + }), ) - ], - ), - ), + : Container( + decoration: BoxDecoration( + borderRadius: + DesignConfig.mediumBorderRadius, + color: Theme.of(context) + .colorScheme + .border, + ), + constraints: + const BoxConstraints(minWidth: 200), + padding: const EdgeInsets.fromLTRB( + 12, 8, 12, 8), + margin: const EdgeInsets.symmetric( + horizontal: 12), + child: Row( + children: [ + const Icon(Icons.file_copy), + const SizedBox( + width: 12, + ), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: DidvanText((message + .fileName + .toString())), + ), + if (state.file != null) + FutureBuilder( + future: state.file!.main + .length(), + builder: + (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return DidvanText( + 'File Size ${(snapshot.data! / 1000).round()} KB', + fontSize: 12, + ); + }) + ], + ) + ], + ), + ), if (message.text != null) Markdown( data: message.text.toString(), @@ -570,33 +494,75 @@ class _AiChatPageState extends State { physics: const NeverScrollableScrollPhysics(), styleSheet: defaultMarkdownStyleSheet, ), - if (!message.role.toString().contains('user')) - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (message.error != null && message.error!) Padding( padding: const EdgeInsets.all(8.0), child: InkWell( onTap: () async { - await Clipboard.setData(ClipboardData( - text: state.messages[mIndex] - .prompts[index].text - .toString())); - ActionSheetUtils.showAlert(AlertData( - message: "متن با موفقیت کپی شد", - aLertType: ALertType.success)); + state.messages.last.prompts + .remove(message); + state.messages.last.prompts.add( + message.copyWith(error: false)); + state.update(); + await state + .postMessage(widget.args.bot); }, child: Icon( - DidvanIcons.copy_regular, + DidvanIcons.refresh_solid, size: 18, color: Theme.of(context) .colorScheme .focusedBorder, ), ), - ) - ], - ) + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () async { + await Clipboard.setData(ClipboardData( + text: state.messages[mIndex] + .prompts[index].text + .toString())); + ActionSheetUtils.showAlert(AlertData( + message: "متن با موفقیت کپی شد", + aLertType: ALertType.success)); + }, + child: Icon( + DidvanIcons.copy_regular, + size: 18, + color: Theme.of(context) + .colorScheme + .focusedBorder, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () async { + if (message.id != null) { + state.deleteMessage( + message.id!, mIndex, index); + } else { + state.messages[mIndex].prompts + .removeAt(index); + } + }, + child: Icon( + DidvanIcons.trash_solid, + size: 18, + color: Theme.of(context) + .colorScheme + .focusedBorder, + ), + ), + ), + ], + ) ], ), ), diff --git a/lib/views/ai/ai_chat_state.dart b/lib/views/ai/ai_chat_state.dart index 73b2fb5..55d8048 100644 --- a/lib/views/ai/ai_chat_state.dart +++ b/lib/views/ai/ai_chat_state.dart @@ -1,28 +1,33 @@ import 'dart:convert'; -import 'dart:io'; import 'package:didvan/models/ai/bots_model.dart'; import 'package:didvan/models/ai/chats_model.dart'; +import 'package:didvan/models/ai/files_model.dart'; import 'package:didvan/models/ai/messages_model.dart'; import 'package:didvan/models/enums.dart'; import 'package:didvan/models/view/alert_data.dart'; import 'package:didvan/providers/core.dart'; import 'package:didvan/services/ai/ai_api_service.dart'; +import 'package:didvan/services/app_initalizer.dart'; import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request_helper.dart'; import 'package:didvan/utils/action_sheet.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:persian_number_utility/persian_number_utility.dart'; class AiChatState extends CoreProvier { - Stream messageOnstream = const Stream.empty(); + ValueNotifier> messageOnstream = + ValueNotifier(const Stream.empty()); List messages = []; bool onResponsing = false; bool loading = false; + ValueNotifier changingPlaceHolder = ValueNotifier(false); final ScrollController scrollController = ScrollController(); int? chatId; - File? file; + ChatsModel? chat; + FilesModel? file; Future _scrolledEnd() async { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -38,7 +43,7 @@ class AiChatState extends CoreProvier { onResponsing = false; messages.last.prompts.removeLast(); messages.last.prompts.removeLast(); - messageOnstream = const Stream.empty(); + messageOnstream.value = const Stream.empty(); await ActionSheetUtils.showAlert(AlertData( message: 'خطا در برقراری ارتباط', aLertType: ALertType.error)); @@ -54,6 +59,7 @@ class AiChatState extends CoreProvier { if (service.isSuccess) { final id = service.result['id']; chatId = id; + chat ??= ChatsModel(id: chatId); } return service; } @@ -61,6 +67,7 @@ class AiChatState extends CoreProvier { Future getAllMessages(int chatId) async { loading = true; onResponsing = true; + update(); final service = RequestService( RequestHelper.aiAChat(chatId), ); @@ -99,6 +106,35 @@ class AiChatState extends CoreProvier { update(); } + Future changePlaceHolder(String placeholder) async { + changingPlaceHolder.value = true; + update(); + await Future.delayed(const Duration(seconds: 3)); + + final service = RequestService(RequestHelper.placeholder(chatId!), + body: {'placeholder': placeholder}); + await service.put(); + if (service.isSuccess) { + appState = AppState.idle; + + // Add this code to scroll to maxScrollExtent after the ListView is built + if (chat == null) { + chat = ChatsModel(id: chatId, placeholder: placeholder); + } else { + chat = chat!.copyWith(placeholder: placeholder); + } + changingPlaceHolder.value = false; + + update(); + + return; + } + appState = AppState.failed; + changingPlaceHolder.value = false; + + update(); + } + Future postMessage(BotsModel bot) async { onResponsing = true; @@ -107,6 +143,7 @@ class AiChatState extends CoreProvier { messages.last.prompts.add(Prompts( finished: false, + error: false, text: '...', role: 'bot', createdAt: DateTime.now() @@ -118,18 +155,27 @@ class AiChatState extends CoreProvier { url: '/${bot.id}/${bot.name}'.toLowerCase(), message: message, chatId: chatId, - file: file); + file: file?.main); final res = await AiApiService.getResponse(req).catchError((e) { _onError(e); - throw e; + return e; }); + String responseMessgae = ''; + String dataMessgae = ''; + file = null; + update(); var r = res.listen((value) async { var str = utf8.decode(value); + if (str.contains('{{{')) { + dataMessgae += str; + update(); + return; + } responseMessgae += str; - messageOnstream = Stream.value(responseMessgae); + messageOnstream.value = Stream.value(responseMessgae); - update(); + // update(); }); r.onDone(() async { @@ -141,11 +187,39 @@ class AiChatState extends CoreProvier { } } onResponsing = false; - messages.last.prompts.last = messages.last.prompts.last.copyWith( - finished: true, - text: responseMessgae, - ); - messageOnstream = const Stream.empty(); + if (responseMessgae.isEmpty) { + messages.last.prompts.removeLast(); + messages.last.prompts.last = + messages.last.prompts.last.copyWith(error: true); + messageOnstream.value = const Stream.empty(); + update(); + _scrolledEnd(); + return; + } else { + int? humanMessageId; + int? aiMessageId; + try { + final data = AppInitializer.messagesData(dataMessgae); + humanMessageId = data['HUMAN_MESSAGE_ID']; + aiMessageId = data['AI_MESSAGE_ID']; + } catch (e) { + e.printError(); + + return; + } + // Access the values + messages.last.prompts.last = messages.last.prompts.last + .copyWith(finished: true, text: responseMessgae, id: aiMessageId); + if (messages.last.prompts.length > 2) { + messages.last.prompts[messages.last.prompts.length - 2] = messages + .last.prompts[messages.last.prompts.length - 2] + .copyWith(id: humanMessageId); + } else { + messages.last.prompts.first = + messages.last.prompts.first.copyWith(id: humanMessageId); + } + messageOnstream.value = const Stream.empty(); + } update(); _scrolledEnd(); @@ -153,4 +227,27 @@ class AiChatState extends CoreProvier { r.onError(_onError); } + + Future deleteMessage(int id, int mIndex, int index) async { + final service = RequestService(RequestHelper.deleteMessage(chatId!, id)); + await service.delete(); + if (service.isSuccess) { + if (messages[mIndex].prompts.length <= 1) { + messages.removeAt(mIndex); + } else { + messages[mIndex].prompts.removeAt(index); + } + + appState = AppState.idle; + + update(); + + return; + } + appState = AppState.failed; + await ActionSheetUtils.showAlert(AlertData( + message: 'خطا در برقراری ارتباط', aLertType: ALertType.error)); + + update(); + } } diff --git a/lib/views/ai/history_ai_chat_page.dart b/lib/views/ai/history_ai_chat_page.dart index 9081880..4e94225 100644 --- a/lib/views/ai/history_ai_chat_page.dart +++ b/lib/views/ai/history_ai_chat_page.dart @@ -1,4 +1,4 @@ -// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: library_private_types_in_public_api, deprecated_member_use import 'dart:async'; @@ -9,12 +9,14 @@ import 'package:didvan/constants/assets.dart'; import 'package:didvan/main.dart'; import 'package:didvan/models/ai/ai_chat_args.dart'; import 'package:didvan/models/view/action_sheet_data.dart'; +import 'package:didvan/models/view/app_bar_data.dart'; import 'package:didvan/routes/routes.dart'; import 'package:didvan/utils/action_sheet.dart'; import 'package:didvan/utils/date_time.dart'; import 'package:didvan/views/ai/history_ai_chat_state.dart'; import 'package:didvan/views/widgets/didvan/scaffold.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; +import 'package:didvan/views/widgets/search_field.dart'; import 'package:didvan/views/widgets/shimmer_placeholder.dart'; import 'package:didvan/views/widgets/state_handlers/empty_state.dart'; import 'package:didvan/views/widgets/state_handlers/sliver_state_handler.dart'; @@ -22,7 +24,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class HistoryAiChatPage extends StatefulWidget { - const HistoryAiChatPage({Key? key}) : super(key: key); + final bool? archived; + const HistoryAiChatPage({Key? key, required this.archived}) : super(key: key); @override _HistoryAiChatPageState createState() => _HistoryAiChatPageState(); @@ -35,73 +38,184 @@ class _HistoryAiChatPageState extends State { final state = context.read(); Future.delayed( Duration.zero, - () => state.getChats(), + () => state.getChats(archived: widget.archived), ); super.initState(); } + Timer? _timer; + late bool archived = widget.archived ?? false; + @override Widget build(BuildContext context) { - return DidvanScaffold( - hidePlayer: true, - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 92), - floatingActionButton: openAiListBtn(context), - scrollController: scrollController, - slivers: [ - Consumer( - builder: (context, state, child) { - return SliverStateHandler( - state: state, - emptyState: EmptyState( - asset: Assets.emptyResult, - title: 'لیست خالی است', - ), - enableEmptyState: state.chats.isEmpty, - placeholder: const _HistoryPlaceholder(), - placeholderCount: 8, + return WillPopScope( + onWillPop: () async { + await context.read().getChats(); + return true; + }, + child: DidvanScaffold( + hidePlayer: true, + physics: const BouncingScrollPhysics(), + // floatingActionButton: openAiListBtn(context), + padding: EdgeInsets.zero, + scrollController: scrollController, + showSliversFirst: false, + slivers: [ + Consumer( + builder: (context, state, child) { + return SliverStateHandler( + state: state, + emptyState: EmptyState( + asset: Assets.emptyResult, + title: 'لیست خالی است', + ), + enableEmptyState: state.chats.isEmpty, + placeholder: const _HistoryPlaceholder(), + placeholderCount: 8, - // builder: (context, state, index) => _HistoryPlaceholder(), - builder: (context, state, index) { - final chat = state.chats[index]; + // builder: (context, state, index) => _HistoryPlaceholder(), + builder: (context, state, index) { + final chat = state.chats[index]; + TextEditingController title = + TextEditingController(text: state.chats[index].title); + return Dismissible( + key: UniqueKey(), + background: Container( + color: Theme.of(context).colorScheme.error, + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Icon( + DidvanIcons.trash_solid, + color: Theme.of(context).colorScheme.white, + ), + ), + secondaryBackground: Container( + color: Theme.of(context).colorScheme.primary, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Icon( + archived + ? Icons.folder_delete + : Icons.create_new_folder_rounded, + color: Theme.of(context).colorScheme.white), + ), + movementDuration: const Duration(milliseconds: 600), + confirmDismiss: (direction) async { + bool result = false; - return InkWell( - onTap: () { - if (state.chatsToDelete.isEmpty) { - navigatorKey.currentState!.pushNamed(Routes.aiChat, - arguments: - AiChatArgs(bot: chat.bot!, chatId: chat.id!)); - } else { - if (state.chatsToDelete.contains(chat.id)) { - state.chatsToDelete.remove(chat.id!); + if (direction == DismissDirection.startToEnd) { + ActionSheetUtils.context = context; + await ActionSheetUtils.openDialog( + data: ActionSheetData( + onConfirmed: () async { + final state = + context.read(); + await state.deleteChat(chat.id!, index); + result = true; + }, + content: Column( + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Icon( + DidvanIcons.trash_solid, + color: Theme.of(context) + .colorScheme + .error, + ), + const SizedBox( + width: 8, + ), + SizedBox( + child: DidvanText( + 'پاک کردن گفت‌و‌گو', + color: Theme.of(context) + .colorScheme + .error, + fontSize: 20, + ), + ), + ], + ), + const SizedBox( + height: 12, + ), + SizedBox( + child: RichText( + text: TextSpan( + text: + 'آیا از پاک کردن گفت‌و‌گوی ', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .text), + children: [ + TextSpan( + text: "\"${chat.title}\"", + style: const TextStyle( + fontWeight: + FontWeight.bold)), + const TextSpan( + text: + ' با هوشان اطمینان دارید؟ '), + ]), + ), + ), + ], + ))); } else { - state.chatsToDelete.add(chat.id!); + result = await state.archivedChat(chat.id!, index); } - } - state.update(); - }, - onLongPress: () { - if (state.chatsToDelete.isEmpty) { - state.chatsToDelete.add(chat.id!); - } - state.update(); - }, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: - Theme.of(context).colorScheme.border))), - child: Stack( - children: [ - Row( + return result; + }, + child: InkWell( + onTap: () { + // if (state.chatsToDelete.isEmpty) { + navigatorKey.currentState!.pushNamed(Routes.aiChat, + arguments: + AiChatArgs(bot: chat.bot!, chat: chat)); + // } else { + // if (state.chatsToDelete.contains(chat.id)) { + // state.chatsToDelete.remove(chat.id!); + // } else { + // state.chatsToDelete.add(chat.id!); + // } + // } + // state.update(); + }, + onLongPress: () { + state.chats[index] = + state.chats[index].copyWith(isEditing: true); + state.update(); + + // if (state.chatsToDelete.isEmpty) { + // state.chatsToDelete.add(chat.id!); + // } + // state.update(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: 20), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context) + .colorScheme + .border))), + child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox( + SizedBox( width: 46, height: 46, + child: ClipOval( + child: CachedNetworkImage( + imageUrl: chat.bot!.image.toString(), + ), + ), ), const SizedBox( width: 18, @@ -117,6 +231,7 @@ class _HistoryAiChatPageState extends State { DidvanText( chat.bot!.name.toString(), fontWeight: FontWeight.bold, + // fontSize: 18, ), DidvanText( DateTimeUtils.momentGenerator( @@ -126,10 +241,103 @@ class _HistoryAiChatPageState extends State { ], ), SizedBox( - child: DidvanText( - chat.prompts![0].text.toString(), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + chat.isEditing != null && + chat.isEditing! + ? Row( + children: [ + Expanded( + child: TextFormField( + controller: title, + style: + const TextStyle( + fontSize: 12), + textAlignVertical: + TextAlignVertical + .bottom, + maxLines: 1, + decoration: + const InputDecoration( + isDense: true, + contentPadding: + EdgeInsets + .symmetric( + vertical: + 5, + horizontal: + 10), + border: + OutlineInputBorder(), + )), + ), + const SizedBox( + width: 12, + ), + state.loadingchangeTitle + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator()) + : InkWell( + onTap: () async { + if (title.text + .toString() == + chat.title + .toString()) { + chat.isEditing = + false; + state.update(); + return; + } + if (title.text + .isNotEmpty) { + await state + .changeNameChat( + chat + .id!, + index, + title + .text); + title.clear(); + } + if (chat.isEditing != + null) { + chat.isEditing = + !chat + .isEditing!; + state.update(); + return; + } + chat.isEditing = + true; + + state.update(); + }, + child: const Icon( + DidvanIcons + .check_circle_solid), + ) + ], + ) + : DidvanText( + chat.title.toString(), + maxLines: 1, + overflow: + TextOverflow.ellipsis, + // fontWeight: FontWeight.bold, + // fontSize: 16, + ), + DidvanText( + chat.prompts![0].text.toString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + fontSize: 12, + ), + ], ), ), ], @@ -137,184 +345,111 @@ class _HistoryAiChatPageState extends State { ), ], ), - Positioned( - bottom: 0, - right: 0, - top: 0, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: ClipOval( - child: CachedNetworkImage( - imageUrl: chat.bot!.image.toString(), - ), - ), - ), - ), - if (state.chatsToDelete.contains(chat.id)) - Positioned( - right: 32, - bottom: 0, - child: Container( - // ignore: prefer_const_constructors - decoration: BoxDecoration( - color: Theme.of(context) - .scaffoldBackgroundColor, - shape: BoxShape.circle), - child: Icon(DidvanIcons.check_circle_solid, - size: 20, - color: - Theme.of(context).colorScheme.success), - ), - ) - ], + ), ), - ), - ); + ); + }, + childCount: state.chats.length, + onRetry: state.getChats); + }, + ) + ], + appBarData: AppBarData( + title: archived ? 'گفت‌و‌گو‌های آرشیو شده' : 'تاریخچه گفت‌وگوها', + hasBack: true, + hasElevation: true, + trailing: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: InkWell( + onTap: () async { + ActionSheetUtils.context = context; + await ActionSheetUtils.openDialog( + data: ActionSheetData( + onConfirmed: () async { + final state = context.read(); + await state.deleteAllChat(); + }, + content: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + DidvanIcons.trash_solid, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox( + width: 8, + ), + DidvanText( + 'پاک کردن همه گفت‌وگوها', + color: Theme.of(context).colorScheme.error, + fontSize: 20, + ), + ], + ), + const SizedBox( + height: 12, + ), + const DidvanText( + 'آیا از پاک کردن تمامی گفت‌وگوهای انجام شده با هوشان اطمینان دارید؟'), + ], + ))); }, - childCount: state.chats.length, - onRetry: state.getChats); - }, - ) - ], - appBarData: null, + child: DidvanText( + archived ? 'خارج کردن همه' : 'حذف همه', + color: Theme.of(context).colorScheme.error, + ), + ), + )), + children: [ + Container( + color: Theme.of(context).colorScheme.surface, + padding: const EdgeInsets.only(right: 20, left: 20, bottom: 24), + child: SearchField( + title: 'گفت‌و‌گو‌ها', + onChanged: (value) { + final state = context.read(); + if (value.isEmpty) { + state.getChats(archived: widget.archived); + return; + } + _timer?.cancel(); + _timer = Timer(const Duration(seconds: 1), () { + state.search = value; + state.getSearchChats(q: value, archived: widget.archived); + }); + }, + focusNode: FocusNode()), + ), + ], + ), ); } - Widget openAiListBtn(BuildContext context) { - final watch = context.watch(); - final state = context.read(); - return FloatingActionButton( - backgroundColor: watch.chatsToDelete.isEmpty - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.error, - shape: const OvalBorder(), - mini: true, - onPressed: () { - if (watch.chatsToDelete.isEmpty) { - state.getBots(); - state.search = ''; - _botsDialogSelect(context); - } else { - state.addChatToDelete(); - } - }, - child: watch.chatsToDelete.isEmpty - ? const Icon(DidvanIcons.add_regular) - : const Icon(DidvanIcons.trash_regular), - ); - } - - void _botsDialogSelect(BuildContext context) { - final state = context.read(); - - ActionSheetUtils.context = context; - ActionSheetUtils.openDialog( - data: ActionSheetData( - hasConfirmButton: false, - hasDismissButton: false, - content: Column( - children: [ - // Row( - // mainAxisAlignment: MainAxisAlignment.end, - // children: [ - // Padding( - // padding: const EdgeInsets.symmetric(vertical: 8.0), - // child: InkWell( - // onTap: () { - // ActionSheetUtils.pop(); - // }, - // child: const Icon(DidvanIcons.close_solid)), - // ) - // ], - // ), - // SearchField( - // title: 'هوش مصنوعی', - // value: state.search, - // onChanged: (value) { - // state.search = value; - // if (value.isEmpty) { - // state.getBots(); - // return; - // } - // state.timer?.cancel(); - // state.timer = Timer(const Duration(seconds: 1), () { - // state.getSearchBots(value); - // }); - // }, - // focusNode: FocusNode()), - // const SizedBox( - // height: 12, - // ), - SizedBox( - width: double.infinity, - height: MediaQuery.sizeOf(context).height / 3, - child: ValueListenableBuilder( - valueListenable: state.loadingBots, - builder: (context, value, child) => value - ? Center( - child: Image.asset( - Assets.loadingAnimation, - width: 60, - height: 60, - ), - ) - : state.bots.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0), - child: EmptyState( - asset: Assets.emptyResult, - title: 'نتیجه‌ای پیدا نشد', - height: 120, - ), - ) - : ListView.builder( - padding: - const EdgeInsets.symmetric(vertical: 12), - itemCount: state.bots.length, - physics: const BouncingScrollPhysics(), - shrinkWrap: true, - itemBuilder: (context, index) { - final bot = state.bots[index]; - return InkWell( - onTap: () { - ActionSheetUtils.pop(); - navigatorKey.currentState!.pushNamed( - Routes.aiChat, - arguments: AiChatArgs(bot: bot)); - }, - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - vertical: 8), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context) - .colorScheme - .border, - width: 1))), - child: Row( - children: [ - ClipOval( - child: CachedNetworkImage( - imageUrl: bot.image.toString(), - width: 42, - height: 42, - ), - ), - const SizedBox(width: 12), - Text(bot.name.toString()) - ], - ), - ), - ); - }), - ), - ) - ], - ))); - } + // Widget openAiListBtn(BuildContext context) { + // final watch = context.watch(); + // final state = context.read(); + // return FloatingActionButton( + // backgroundColor: watch.chatsToDelete.isEmpty + // ? Theme.of(context).colorScheme.primary + // : Theme.of(context).colorScheme.error, + // shape: const OvalBorder(), + // mini: true, + // onPressed: () { + // if (watch.chatsToDelete.isEmpty) { + // state.getBots(); + // state.search = ''; + // _botsDialogSelect(context); + // } else { + // state.addChatToDelete(); + // } + // }, + // child: watch.chatsToDelete.isEmpty + // ? const Icon(DidvanIcons.add_regular) + // : const Icon(DidvanIcons.trash_regular), + // ); + // } } class _HistoryPlaceholder extends StatelessWidget { diff --git a/lib/views/ai/history_ai_chat_state.dart b/lib/views/ai/history_ai_chat_state.dart index 6da7030..31ba4bc 100644 --- a/lib/views/ai/history_ai_chat_state.dart +++ b/lib/views/ai/history_ai_chat_state.dart @@ -12,15 +12,20 @@ import 'package:flutter/cupertino.dart'; class HistoryAiChatState extends CoreProvier { final List chats = []; - final List chatsToDelete = []; + // final List chatsToDelete = []; final List bots = []; + BotsModel? bot; ValueNotifier loadingBots = ValueNotifier(false); + bool loadingchangeTitle = false; + bool loadingdeleteAll = false; Timer? timer; String search = ''; - Future getChats() async { + Future getChats({final bool? archived}) async { final service = RequestService( - RequestHelper.aiChats(), + archived != null && archived + ? RequestHelper.aiArchived() + : RequestHelper.aiChats(), ); await service.httpGet(); if (service.isSuccess) { @@ -37,6 +42,29 @@ class HistoryAiChatState extends CoreProvier { update(); } + Future getSearchChats( + {required final String q, final bool? archived}) async { + final service = RequestService( + archived != null && archived + ? RequestHelper.aiSearchArchived(q) + : RequestHelper.aiSearchChats(q), + ); + await service.httpGet(); + if (service.isSuccess) { + chats.clear(); + final ch = service.result['chats']; + for (var i = 0; i < ch.length; i++) { + chats.add(ChatsModel.fromJson(ch[i])); + } + appState = AppState.idle; + update(); + + return; + } + appState = AppState.failed; + update(); + } + Future getBots() async { loadingBots.value = true; final service = RequestService( @@ -51,6 +79,7 @@ class HistoryAiChatState extends CoreProvier { } appState = AppState.idle; loadingBots.value = false; + bot = bots.first; update(); return; } @@ -82,22 +111,44 @@ class HistoryAiChatState extends CoreProvier { update(); } - Future addChatToDelete() async { - final service = RequestService(RequestHelper.aiDeleteChats(), - body: {"ids": chatsToDelete}); - await service.delete(); + // Future addChatToDelete() async { + // final service = RequestService(RequestHelper.aiDeleteChats(), + // body: {"ids": chatsToDelete}); + // await service.delete(); + // if (service.isSuccess) { + // final List cs = []; + // for (var chat in chats) { + // if (!chatsToDelete.contains(chat.id)) { + // cs.add(chat); + // } + // } + // chatsToDelete.clear(); + // chats.clear(); + // chats.addAll(cs); + + // appState = AppState.idle; + // update(); + + // return; + // } + // appState = AppState.failed; + // await ActionSheetUtils.showAlert(AlertData( + // message: 'خطا در برقراری ارتباط', aLertType: ALertType.error)); + + // update(); + // } + + Future changeNameChat(int id, int index, String title) async { + loadingchangeTitle = true; + update(); + final service = + RequestService(RequestHelper.aiChangeChats(id), body: {"title": title}); + await service.put(); if (service.isSuccess) { - final List cs = []; - for (var chat in chats) { - if (!chatsToDelete.contains(chat.id)) { - cs.add(chat); - } - } - chatsToDelete.clear(); - chats.clear(); - chats.addAll(cs); + chats[index].title = title; appState = AppState.idle; + loadingchangeTitle = false; update(); return; @@ -105,7 +156,77 @@ class HistoryAiChatState extends CoreProvier { appState = AppState.failed; await ActionSheetUtils.showAlert(AlertData( message: 'خطا در برقراری ارتباط', aLertType: ALertType.error)); + loadingchangeTitle = false; update(); } + + Future deleteChat(int id, int index) async { + final service = RequestService(RequestHelper.deleteChat(id)); + await service.delete(); + if (service.isSuccess) { + chats.removeAt(index); + + appState = AppState.idle; + loadingchangeTitle = false; + + update(); + + return; + } + appState = AppState.failed; + await ActionSheetUtils.showAlert(AlertData( + message: 'خطا در برقراری ارتباط', aLertType: ALertType.error)); + loadingchangeTitle = false; + + update(); + } + + Future deleteAllChat() async { + loadingdeleteAll = true; + update(); + final service = RequestService( + RequestHelper.deleteAllChats(), + ); + await service.delete(); + if (service.isSuccess) { + chats.clear(); + + appState = AppState.idle; + loadingdeleteAll = false; + + update(); + + return; + } + appState = AppState.failed; + await ActionSheetUtils.showAlert(AlertData( + message: 'خطا در برقراری ارتباط', aLertType: ALertType.error)); + loadingdeleteAll = false; + + update(); + } + + Future archivedChat(int id, int index) async { + update(); + final service = RequestService( + RequestHelper.archivedChat(id), + ); + await service.put(); + if (service.isSuccess) { + chats.removeAt(index); + + appState = AppState.idle; + + update(); + + return true; + } + appState = AppState.failed; + await ActionSheetUtils.showAlert(AlertData( + message: 'خطا در برقراری ارتباط', aLertType: ALertType.error)); + + update(); + return false; + } } diff --git a/lib/views/ai/widgets/ai_message_bar.dart b/lib/views/ai/widgets/ai_message_bar.dart new file mode 100644 index 0000000..cf1c9b2 --- /dev/null +++ b/lib/views/ai/widgets/ai_message_bar.dart @@ -0,0 +1,633 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:didvan/config/design_config.dart'; +import 'package:didvan/config/theme_data.dart'; +import 'package:didvan/constants/app_icons.dart'; +import 'package:didvan/models/ai/bots_model.dart'; +import 'package:didvan/models/ai/chats_model.dart'; +import 'package:didvan/models/ai/files_model.dart'; +import 'package:didvan/models/ai/messages_model.dart'; +import 'package:didvan/services/media/media.dart'; +import 'package:didvan/services/storage/storage.dart'; +import 'package:didvan/utils/date_time.dart'; +import 'package:didvan/views/ai/ai_chat_state.dart'; +import 'package:didvan/views/ai/history_ai_chat_state.dart'; +import 'package:didvan/views/ai/widgets/message_bar_btn.dart'; +import 'package:didvan/views/ai/widgets/voice_message_view.dart'; +import 'package:didvan/views/widgets/animated_visibility.dart'; +import 'package:didvan/views/widgets/didvan/text.dart'; +import 'package:didvan/views/widgets/marquee_text.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:get/get.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:persian_number_utility/persian_number_utility.dart'; +import 'package:provider/provider.dart'; +import 'package:record/record.dart'; + +import 'package:path/path.dart' as p; +import 'package:voice_message_package/voice_message_package.dart'; + +class AiMessageBar extends StatefulWidget { + final FocusNode? focusNode; + final BotsModel bot; + const AiMessageBar({ + super.key, + this.focusNode, + required this.bot, + }); + + @override + State createState() => _AiMessageBarState(); + + static PopupMenuItem popUpBtns({ + required final String value, + required final IconData icon, + final Color? color, + final double? height, + final double? size, + }) { + return PopupMenuItem( + value: value, + height: height ?? 46, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: color, + size: size, + ), + const SizedBox( + width: 12, + ), + DidvanText( + value, + color: color, + fontSize: size, + ), + ], + ), + ); + } +} + +class _AiMessageBarState extends State { + TextEditingController message = TextEditingController(); + + @override + void initState() { + widget.focusNode?.addListener(() {}); + + super.initState(); + } + + final record = AudioRecorder(); + + @override + void dispose() { + super.dispose(); + record.dispose(); + try { + _timer.cancel(); + } catch (e) { + e.printError(); + } + } + + late Timer _timer; + final ValueNotifier _countTimer = ValueNotifier(0); + void startTimer() { + const oneSec = Duration(seconds: 1); + _timer = Timer.periodic( + oneSec, + (Timer timer) { + _countTimer.value++; + }, + ); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, state, child) { + final historyState = context.read(); + + return IgnorePointer( + ignoring: state.onResponsing, + child: Column( + children: [ + fileContainer(), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + children: [ + audioContainer(), + Container( + decoration: BoxDecoration( + boxShadow: DesignConfig.defaultShadow, + color: Theme.of(context).colorScheme.white, + borderRadius: BorderRadius.circular(360)), + child: Row( + children: [ + const SizedBox( + width: 8, + ), + Expanded( + child: StreamBuilder( + stream: record.onStateChanged(), + builder: (context, snapshot) { + return Row( + children: [ + (snapshot.hasData && + snapshot.data! != + RecordState.stop) + ? MessageBarBtn( + enable: true, + icon: DidvanIcons + .stop_circle_solid, + click: () async { + final path = + await record.stop(); + state.file = FilesModel( + path.toString()); + _timer.cancel(); + _countTimer.value = 0; + state.update(); + }, + ) + : widget.bot.attachmentType! + .contains('audio') && + message.text.isEmpty && + state.file == null && + widget.bot.attachment != 0 + ? MessageBarBtn( + enable: false, + icon: + DidvanIcons.mic_regular, + click: () async { + if (await record + .hasPermission()) { + Directory? downloadDir = + await getDownloadsDirectory(); + + record.start( + const RecordConfig(), + path: + '${downloadDir!.path}/${DateTime.now().millisecondsSinceEpoch ~/ 1000}.m4a'); + startTimer(); + } + }, + ) + : MessageBarBtn( + enable: message + .text.isNotEmpty || + state.file != null, + icon: + DidvanIcons.send_light, + click: () async { + if (state.file == null && + message + .text.isEmpty) { + return; + } + + if (state.messages + .isNotEmpty && + DateTime.parse(state + .messages + .last + .dateTime) + .toPersianDateStr() + .contains(DateTime.parse(DateTime + .now() + .subtract(const Duration( + minutes: + 210)) + .toIso8601String()) + .toPersianDateStr())) { + state.messages.last + .prompts + .add(Prompts( + error: false, + text: message.text, + file: + state.file?.path, + fileName: state + .file == + null + ? null + : p.basename(state + .file!.path), + finished: true, + role: 'user', + createdAt: DateTime + .now() + .subtract( + const Duration( + minutes: + 210)) + .toIso8601String(), + )); + } else { + state.messages.add(MessageModel( + dateTime: DateTime + .now() + .subtract( + const Duration( + minutes: + 210)) + .toIso8601String(), + prompts: [ + Prompts( + error: false, + text: message + .text, + finished: true, + file: state + .file?.path, + fileName: state + .file == + null + ? null + : p.basename(state + .file! + .path), + role: 'user', + createdAt: DateTime + .now() + .subtract(const Duration( + minutes: + 210)) + .toIso8601String(), + ) + ])); + } + message.clear(); + await state.postMessage( + widget.bot); + }, + ), + const SizedBox( + width: 12, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: snapshot.hasData && + (snapshot.data! == + RecordState + .record || + snapshot.data! == + RecordState.pause) + ? Padding( + padding: + const EdgeInsets.all( + 8.0), + child: Row( + mainAxisAlignment: + MainAxisAlignment + .center, + children: [ + SpinKitWave( + color: + Theme.of(context) + .colorScheme + .primary, + size: 32, + ), + const SizedBox( + width: 24, + ), + ValueListenableBuilder< + int>( + valueListenable: + _countTimer, + builder: (context, + value, + child) => + DidvanText(DateTimeUtils + .normalizeTimeDuration( + Duration( + seconds: + value))), + ) + ], + ), + ) + : Form( + child: TextFormField( + textInputAction: + TextInputAction.newline, + style: Theme.of(context) + .textTheme + .bodyMedium, + maxLines: 2, + minLines: 1, + // keyboardType: TextInputType.text, + + controller: message, + focusNode: widget.focusNode, + enabled: !(state.file != + null && + widget.bot.attachment == + 1), + + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'بنویسید...', + hintStyle: Theme.of( + context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of( + context) + .colorScheme + .disabledText), + ), + onChanged: (value) { + setState(() {}); + }, + )), + ), + ), + if (snapshot.hasData) + snapshot.data! == RecordState.record + ? MessageBarBtn( + enable: false, + icon: DidvanIcons.pause_solid, + click: () async { + await record.pause(); + _timer.cancel(); + }, + ) + : snapshot.data! == + RecordState.pause + ? MessageBarBtn( + enable: false, + icon: DidvanIcons + .play_solid, + click: () async { + await record.resume(); + startTimer(); + }, + ) + : const SizedBox(), + const SizedBox( + width: 8, + ), + ], + ); + }), + ), + const SizedBox( + width: 8, + ), + ], + ), + ), + ], + )), + const SizedBox( + width: 12, + ), + if (context + .read() + .bot! + .attachmentType! + .isNotEmpty && + (widget.bot.attachment != 0 && + (widget.bot.attachment == 1 && + message.text.isEmpty)) || + widget.bot.attachment == 2) + SizedBox( + width: 46, + height: 46, + child: Center( + child: PopupMenuButton( + onOpened: () { + }, + onSelected: (value) async { + switch (value) { + case 'Pdf': + FilePickerResult? result = + await MediaService.pickPdfFile(); + if (result != null) { + state.file = + FilesModel(result.files.single.path!); + // Do something with the selected PDF file + } + // else { + //// User cancelled the file selection + // } + break; + + case 'Image': + final pickedFile = + await MediaService.pickImage( + source: ImageSource.gallery); + File? file; + if (pickedFile != null && !kIsWeb) { + file = await ImageCropper().cropImage( + sourcePath: pickedFile.path, + androidUiSettings: + const AndroidUiSettings( + toolbarTitle: 'برش تصویر'), + iosUiSettings: const IOSUiSettings( + title: 'برش تصویر', + doneButtonTitle: 'تایید', + cancelButtonTitle: 'بازگشت', + ), + compressQuality: 30, + ); + if (file == null) return; + } + if (pickedFile == null) return; + state.file = kIsWeb + ? FilesModel(pickedFile.path) + : FilesModel(file!.path); + + break; + + case 'Audio': + FilePickerResult? result = + await MediaService.pickAudioFile(); + if (result != null) { + state.file = + FilesModel(result.files.single.path!); + } + break; + default: + } + + state.update(); + }, + itemBuilder: (BuildContext context) => [ + if (historyState.bot!.attachmentType! + .contains('pdf')) + AiMessageBar.popUpBtns( + value: 'Pdf', icon: Icons.picture_as_pdf), + if (historyState.bot!.attachmentType! + .contains('image')) + AiMessageBar.popUpBtns( + value: 'Image', icon: Icons.image), + if (historyState.bot!.attachmentType! + .contains('audio')) + AiMessageBar.popUpBtns( + value: 'Audio', icon: Icons.audio_file), + ], + offset: Offset( + 0, widget.focusNode!.hasFocus ? -999 : 999), + position: PopupMenuPosition.over, + useRootNavigator: true, + child: Icon( + Icons.attach_file_rounded, + color: + Theme.of(context).colorScheme.focusedBorder, + ), + ), + )), + ], + ), + ], + ), + ); + }, + ); + } + + AnimatedVisibility audioContainer() { + final state = context.watch(); + + return AnimatedVisibility( + isVisible: state.file != null && + lookupMimeType(state.file!.path)!.startsWith('audio/'), + duration: DesignConfig.lowAnimationDuration, + child: Directionality( + textDirection: TextDirection.ltr, + child: FutureBuilder( + future: StorageService.getValue(key: 'token'), + builder: (context, snapshot) => SizedBox( + width: MediaQuery.sizeOf(context).width, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: Container( + height: 46, + decoration: BoxDecoration( + boxShadow: DesignConfig.defaultShadow, + color: Theme.of(context).colorScheme.white, + borderRadius: BorderRadius.circular(360)), + padding: const EdgeInsets.symmetric(horizontal: 20), + child: state.file == null + ? const SizedBox() + : MyVoiceMessageView( + size: 32, + controller: VoiceController( + audioSrc: state.file!.path.startsWith('/uploads') + ? 'https://api.didvan.app${state.file!.path}?accessToken=${snapshot.data}' + : state.file!.path, + onComplete: () { + /// do something on complete + }, + onPause: () { + /// do something on pause + }, + onPlaying: () { + /// do something on playing + }, + onError: (err) { + /// do somethin on error + }, + isFile: state.file!.path.startsWith('/uploads'), + maxDuration: const Duration(seconds: 10), + ), + innerPadding: 0, + cornerRadius: 20, + circlesColor: Theme.of(context).colorScheme.primary, + activeSliderColor: + Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ), + )); + } + + AnimatedVisibility fileContainer() { + final state = context.watch(); + String basename = ''; + if (state.file != null) { + basename = p.basename(state.file!.path); + } + return AnimatedVisibility( + isVisible: state.file != null && + !lookupMimeType(state.file!.path)!.startsWith('audio/'), + duration: DesignConfig.lowAnimationDuration, + child: Container( + decoration: BoxDecoration( + borderRadius: DesignConfig.mediumBorderRadius, + color: Theme.of(context).colorScheme.border, + ), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + margin: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.file_copy), + const SizedBox( + width: 12, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 260, + height: 24, + child: MarqueeText( + text: basename, + style: const TextStyle(fontSize: 14), + stop: const Duration(seconds: 3), + ), + ), + if (state.file != null) + FutureBuilder( + future: state.file!.main.length(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return DidvanText( + 'File Size ${(snapshot.data! / 1000).round()} KB', + fontSize: 12, + ); + }) + ], + ) + ], + ), + InkWell( + onTap: () { + state.file = null; + state.update(); + }, + child: const Icon(DidvanIcons.close_circle_solid)) + ], + ), + ), + ); + } +} diff --git a/lib/views/ai/widgets/message_bar_btn.dart b/lib/views/ai/widgets/message_bar_btn.dart new file mode 100644 index 0000000..d167f7c --- /dev/null +++ b/lib/views/ai/widgets/message_bar_btn.dart @@ -0,0 +1,37 @@ +import 'package:didvan/config/theme_data.dart'; +import 'package:flutter/material.dart'; + +class MessageBarBtn extends StatelessWidget { + final bool enable; + final IconData icon; + final Function()? click; + final Color? color; + const MessageBarBtn( + {Key? key, + required this.enable, + required this.icon, + this.click, + this.color}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: enable + ? color ?? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.border), + child: InkWell( + onTap: click, + child: Icon( + icon, + size: 18, + color: enable ? Theme.of(context).colorScheme.white : null, + ), + ), + ); + } +} diff --git a/lib/views/ai/widgets/voice_message_view.dart b/lib/views/ai/widgets/voice_message_view.dart new file mode 100644 index 0000000..02f124f --- /dev/null +++ b/lib/views/ai/widgets/voice_message_view.dart @@ -0,0 +1,294 @@ +// ignore_for_file: implementation_imports, unused_element + +import 'package:didvan/constants/app_icons.dart'; +import 'package:didvan/views/ai/ai_chat_state.dart'; +import 'package:didvan/views/ai/widgets/message_bar_btn.dart'; +import 'package:flutter/material.dart'; +import 'package:mime/mime.dart'; +import 'package:provider/provider.dart'; +import 'package:voice_message_package/src/helpers/play_status.dart'; +import 'package:voice_message_package/src/helpers/utils.dart'; +import 'package:voice_message_package/src/voice_controller.dart'; +import 'package:voice_message_package/src/widgets/noises.dart'; +import 'package:voice_message_package/src/widgets/play_pause_button.dart'; + +/// A widget that displays a voice message view with play/pause functionality. +/// +/// The [VoiceMessageView] widget is used to display a voice message with customizable appearance and behavior. +/// It provides a play/pause button, a progress slider, and a counter for the remaining time. +/// The appearance of the widget can be customized using various properties such as background color, slider color, and text styles. +/// +class MyVoiceMessageView extends StatelessWidget { + const MyVoiceMessageView( + {Key? key, + required this.controller, + this.backgroundColor = Colors.white, + this.activeSliderColor = Colors.red, + this.notActiveSliderColor, + this.circlesColor = Colors.red, + this.innerPadding = 12, + this.cornerRadius = 20, + // this.playerWidth = 170, + this.size = 38, + this.refreshIcon = const Icon( + Icons.refresh, + color: Colors.white, + ), + this.pauseIcon = const Icon( + Icons.pause_rounded, + color: Colors.white, + ), + this.playIcon = const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + ), + this.stopDownloadingIcon = const Icon( + Icons.close, + color: Colors.white, + ), + this.playPauseButtonDecoration, + this.circlesTextStyle = const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + this.counterTextStyle = const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + this.playPauseButtonLoadingColor = Colors.white}) + : super(key: key); + + /// The controller for the voice message view. + final VoiceController controller; + + /// The background color of the voice message view. + final Color backgroundColor; + + /// + final Color circlesColor; + + /// The color of the active slider. + final Color activeSliderColor; + + /// The color of the not active slider. + final Color? notActiveSliderColor; + + /// The text style of the circles. + final TextStyle circlesTextStyle; + + /// The text style of the counter. + final TextStyle counterTextStyle; + + /// The padding between the inner content and the outer container. + final double innerPadding; + + /// The corner radius of the outer container. + final double cornerRadius; + + /// The size of the play/pause button. + final double size; + + /// The refresh icon of the play/pause button. + final Widget refreshIcon; + + /// The pause icon of the play/pause button. + final Widget pauseIcon; + + /// The play icon of the play/pause button. + final Widget playIcon; + + /// The stop downloading icon of the play/pause button. + final Widget stopDownloadingIcon; + + /// The play Decoration of the play/pause button. + final Decoration? playPauseButtonDecoration; + + /// The loading Color of the play/pause button. + final Color playPauseButtonLoadingColor; + + @override + + /// Build voice message view. + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final color = circlesColor; + final newTHeme = theme.copyWith( + sliderTheme: SliderThemeData( + trackShape: CustomTrackShape(), + thumbShape: SliderComponentShape.noThumb, + minThumbSeparation: 0, + ), + splashColor: Colors.transparent, + ); + + final state = context.read(); + + return Container( + width: 160 + (controller.noiseCount * .72.w()), + padding: EdgeInsets.all(innerPadding), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(cornerRadius), + ), + child: ValueListenableBuilder( + /// update ui when change play status + valueListenable: controller.updater, + builder: (context, value, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 12), + + /// play pause button + PlayPauseButton( + controller: controller, + color: color, + loadingColor: playPauseButtonLoadingColor, + size: size, + refreshIcon: refreshIcon, + pauseIcon: pauseIcon, + playIcon: playIcon, + stopDownloadingIcon: stopDownloadingIcon, + buttonDecoration: playPauseButtonDecoration, + ), + const SizedBox(width: 12), + + /// + Text(controller.remindingTime, style: counterTextStyle), + + const SizedBox(width: 12), + + /// slider & noises + Expanded( + child: _noises(newTHeme), + ), + const SizedBox(width: 12), + + /// + + /// speed button + // _changeSpeedButton(color), + + /// + if (state.file != null && + (lookupMimeType(state.file!.path)?.startsWith('audio/') ?? + false)) + MessageBarBtn( + enable: true, + icon: DidvanIcons.trash_solid, + color: Theme.of(context).colorScheme.error, + click: () async { + state.file = null; + state.update(); + }), + const SizedBox(width: 12), + ], + ); + }, + ), + ); + } + + SizedBox _noises(ThemeData newTHeme) => SizedBox( + child: Stack( + alignment: Alignment.center, + children: [ + /// noises + Noises( + rList: controller.randoms!, + activeSliderColor: activeSliderColor, + ), + + /// slider + AnimatedBuilder( + animation: CurvedAnimation( + parent: controller.animController, + curve: Curves.ease, + ), + builder: (BuildContext context, Widget? child) { + return Positioned( + left: controller.animController.value, + child: Container( + width: controller.noiseWidth, + height: 6.w(), + color: + notActiveSliderColor ?? backgroundColor.withOpacity(.4), + ), + ); + }, + ), + Opacity( + opacity: 0, + child: Container( + width: controller.noiseWidth, + color: Colors.transparent.withOpacity(1), + child: Theme( + data: newTHeme, + child: Slider( + value: controller.currentMillSeconds, + max: controller.maxMillSeconds, + onChangeStart: controller.onChangeSliderStart, + onChanged: controller.onChanging, + onChangeEnd: (value) { + controller.onSeek( + Duration(milliseconds: value.toInt()), + ); + controller.play(); + }, + ), + ), + ), + ), + ], + ), + ); + + Transform _changeSpeedButton(Color color) => Transform.translate( + offset: const Offset(0, -7), + child: GestureDetector( + onTap: () { + controller.changeSpeed(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + controller.speed.playSpeedStr, + style: circlesTextStyle, + ), + ), + ), + ); +} + +/// +/// A custom track shape for a slider that is rounded rectangular in shape. +/// Extends the [RoundedRectSliderTrackShape] class. +class CustomTrackShape extends RoundedRectSliderTrackShape { + @override + + /// Returns the preferred rectangle for the voice message view. + /// + /// The preferred rectangle is calculated based on the current state and layout + /// of the voice message view. It represents the area where the view should be + /// displayed on the screen. + /// + /// Returns a [Rect] object representing the preferred rectangle. + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + const double trackHeight = 10; + final double trackLeft = offset.dx, + trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; + final double trackWidth = parentBox.size.width; + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } +} diff --git a/lib/views/direct/direct_state.dart b/lib/views/direct/direct_state.dart index 1b97b8e..86a5744 100644 --- a/lib/views/direct/direct_state.dart +++ b/lib/views/direct/direct_state.dart @@ -10,10 +10,11 @@ import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request_helper.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_vibrate/flutter_vibrate.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; class DirectState extends CoreProvier { - final _recorder = Record(); + final _recorder = AudioRecorder(); final List messages = []; late final int typeId; final Map> dailyMessages = {}; @@ -59,7 +60,10 @@ class DirectState extends CoreProvier { Vibrate.feedback(FeedbackType.medium); } isRecording = true; - _recorder.start(); + Directory tempDir = await getTemporaryDirectory(); + _recorder.start(const RecordConfig(), + path: + '${tempDir.path}/${DateTime.now().millisecondsSinceEpoch ~/ 1000}.m4a'); notifyListeners(); } diff --git a/lib/views/home/home.dart b/lib/views/home/home.dart index b989425..9ea6046 100644 --- a/lib/views/home/home.dart +++ b/lib/views/home/home.dart @@ -1,26 +1,40 @@ // ignore_for_file: deprecated_member_use +import 'package:cached_network_image/cached_network_image.dart'; import 'package:didvan/config/design_config.dart'; +import 'package:didvan/config/theme_data.dart'; +import 'package:didvan/constants/app_icons.dart'; +import 'package:didvan/constants/assets.dart'; +import 'package:didvan/main.dart'; +import 'package:didvan/models/ai/ai_chat_args.dart'; import 'package:didvan/models/notification_message.dart'; import 'package:didvan/models/view/action_sheet_data.dart'; import 'package:didvan/providers/theme.dart'; +import 'package:didvan/routes/routes.dart'; import 'package:didvan/services/app_initalizer.dart'; import 'package:didvan/services/notification/notification_service.dart'; import 'package:didvan/utils/action_sheet.dart'; -import 'package:didvan/views/ai/history_ai_chat_page.dart'; +import 'package:didvan/views/ai/ai.dart'; +import 'package:didvan/views/ai/history_ai_chat_state.dart'; +import 'package:didvan/views/ai/widgets/ai_message_bar.dart'; import 'package:didvan/views/home/categories/categories_page.dart'; import 'package:didvan/views/home/main/main_page.dart'; import 'package:didvan/views/home/home_state.dart'; import 'package:didvan/views/home/new_statistic/new_statistic.dart'; import 'package:didvan/views/home/search/search.dart'; +import 'package:didvan/views/widgets/didvan/divider.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; import 'package:didvan/views/widgets/logo_app_bar.dart'; import 'package:didvan/views/widgets/didvan/bnb.dart'; +import 'package:didvan/views/widgets/state_handlers/empty_state.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../services/app_home_widget/home_widget_repository.dart'; +final GlobalKey homeScaffKey = GlobalKey(); + class Home extends StatefulWidget { const Home({Key? key}) : super(key: key); @@ -42,6 +56,9 @@ class _HomeState extends State state.tabController = _tabController; _tabController.addListener(() { state.currentPageIndex = _tabController.index; + if (_tabController.index == 2) { + context.read().getChats(); + } }); Future.delayed(Duration.zero, () { @@ -64,7 +81,420 @@ class _HomeState extends State @override Widget build(BuildContext context) { return Scaffold( - appBar: const LogoAppBar(), + key: homeScaffKey, + appBar: LogoAppBar( + canSearch: context.watch().tabController.index != 2, + ), + resizeToAvoidBottomInset: false, + drawer: context.watch().tabController.index == 2 + ? Drawer( + child: Consumer( + builder: (context, state, child) { + return Column( + children: [ + const SizedBox( + height: 8, + ), + Padding( + padding: const EdgeInsets.only(left: 20.0, top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + InkWell( + onTap: () => + homeScaffKey.currentState!.closeDrawer(), + child: const Icon( + DidvanIcons.close_regular, + ), + ) + ], + ), + ), + Icon( + DidvanIcons.ai_solid, + size: MediaQuery.sizeOf(context).width / 5, + ), + const DidvanText('هوشان'), + const SizedBox( + height: 24, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + children: [ + drawerBtn( + icon: Icons.handshake_rounded, + text: 'ساخت دستیار شخصی', + crossAxisAlignment: + CrossAxisAlignment.start, + enable: false), + const DidvanDivider(), + drawerBtn( + icon: CupertinoIcons.doc_text_search, + text: 'جستجو در مدل‌ها', + click: () { + ActionSheetUtils.botsDialogSelect( + context: context, state: state); + homeScaffKey.currentState! + .closeDrawer(); + }, + enable: false), + const DidvanDivider(), + drawerBtn( + icon: DidvanIcons.chats_regular, + text: 'تاریخچه همه گفتگوها', + label: 'حذف همه', + click: () { + Navigator.of(context) + .pushNamed(Routes.aiHistory); + }, + labelClick: state.chats.isEmpty + ? null + : () async { + ActionSheetUtils.context = + context; + await ActionSheetUtils.openDialog( + data: ActionSheetData( + onConfirmed: () async { + await state + .deleteAllChat(); + }, + content: Column( + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Icon( + DidvanIcons + .trash_solid, + color: Theme.of( + context) + .colorScheme + .error, + ), + const SizedBox( + width: 8, + ), + DidvanText( + 'پاک کردن همه گفت‌وگوها', + color: Theme.of( + context) + .colorScheme + .error, + fontSize: 20, + ), + ], + ), + const SizedBox( + height: 12, + ), + const DidvanText( + 'آیا از پاک کردن تمامی گفت‌وگوهای انجام شده با هوشان اطمینان دارید؟'), + ], + ))); + }, + ), + const SizedBox( + height: 12, + ), + // SearchField( + // title: 'title', + // onChanged: (value) {}, + // focusNode: FocusNode()), + // SizedBox( + // height: 12, + // ), + Expanded( + child: state.chats.isEmpty + ? Padding( + padding: + const EdgeInsets.all(12.0), + child: EmptyState( + asset: Assets.emptyResult, + title: 'لیست خالی است', + ), + ) + : state.loadingdeleteAll + ? Center( + child: Image.asset( + Assets.loadingAnimation, + width: 60, + height: 60, + ), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: state.chats.length, + padding: const EdgeInsets + .symmetric( + horizontal: 12), + physics: + const BouncingScrollPhysics(), + itemBuilder: + (context, index) { + final chat = + state.chats[index]; + TextEditingController + title = + TextEditingController( + text: chat.title); + + return Padding( + padding: const EdgeInsets + .symmetric( + vertical: 8.0), + child: InkWell( + onTap: () { + navigatorKey + .currentState! + .pushNamed( + Routes.aiChat, + arguments: AiChatArgs( + bot: chat + .bot!, + chat: + chat)); + }, + child: Row( + children: [ + ClipOval( + child: + CachedNetworkImage( + imageUrl: chat + .bot!.image + .toString(), + width: 24, + height: 24, + )), + const SizedBox( + width: 12, + ), + Expanded( + child: chat.isEditing != + null && + chat + .isEditing! + ? TextFormField( + controller: + title, + style: const TextStyle( + fontSize: + 12), + textAlignVertical: + TextAlignVertical + .bottom, + maxLines: + 1, + decoration: + const InputDecoration( + isDense: + true, + contentPadding: EdgeInsets.symmetric( + vertical: + 5, + horizontal: + 10), + border: + OutlineInputBorder(), + )) + : Text( + chat.title + .toString(), + overflow: + TextOverflow + .ellipsis, + maxLines: + 1, + ), + ), + const SizedBox( + width: 24, + ), + Row( + children: [ + chat.isEditing != + null && + chat + .isEditing! && + state + .loadingchangeTitle + ? const SizedBox( + width: + 12, + height: + 12, + child: + CircularProgressIndicator()) + : InkWell( + onTap: + () async { + if (title + .text + .isNotEmpty) { + await state.changeNameChat( + chat.id!, + index, + title.text); + title.clear(); + } + if (chat.isEditing != + null) { + chat.isEditing = + !chat.isEditing!; + state.update(); + return; + } + chat.isEditing = + true; + + state + .update(); + }, + child: + Icon( + chat.isEditing != null && chat.isEditing! + ? Icons.save + : Icons.edit_outlined, + size: + 18, + ), + ), + const SizedBox( + width: 8, + ), + PopupMenuButton( + onSelected: + (value) async { + switch ( + value) { + case 'حذف پیام': + await state.deleteChat( + chat.id!, + index); + break; + + case 'آرشیو': + await state.archivedChat( + chat.id!, + index); + await state + .getChats(); + break; + default: + } + + state + .update(); + }, + itemBuilder: + (BuildContext + context) { + return [ + AiMessageBar.popUpBtns( + value: + 'حذف پیام', + icon: DidvanIcons + .trash_regular, + color: Theme.of(context) + .colorScheme + .error, + height: + 24, + size: + 12), + AiMessageBar + .popUpBtns( + value: + 'آرشیو', + icon: Icons + .folder_copy, + height: + 24, + size: + 12, + ), + ]; + }, + offset: + const Offset( + 0, 0), + position: + PopupMenuPosition + .under, + useRootNavigator: + true, + child: + const Icon( + Icons + .more_vert, + size: 18, + ), + ), + ], + ) + ], + ), + ), + ); + }, + ), + ), + // SizedBox( + // height: 12, + // ), + // Text('نمایش قدیمی‌ترها') + ], + ), + ), + Column( + children: [ + const DidvanDivider(), + drawerBtn( + icon: Icons.folder_copy_outlined, + text: 'گفت‌وگوهای آرشیو شده', + click: () { + Navigator.of(context).pushNamed( + Routes.aiHistory, + arguments: true); + }, + ), + const SizedBox( + height: 12, + ), + drawerBtn( + icon: DidvanIcons.support_regular, + text: 'پیام به پشتیبانی', + click: () { + Navigator.of(context).pushNamed( + Routes.direct, + arguments: { + 'type': 'پشتیبانی اپلیکیشن' + }, + ); + }, + ), + ], + ) + ], + ), + ), + ), + const SizedBox( + height: 32, + ) + ], + ); + }, + ), + ) + : null, body: WillPopScope( onWillPop: () async { if (context.read().tabController.index == 0) { @@ -101,7 +531,7 @@ class _HomeState extends State children: const [ MainPage(), CategoriesPage(), - HistoryAiChatPage(), + Ai(), NewStatistic(), //Statistic(), // Bookmarks(), @@ -125,4 +555,58 @@ class _HomeState extends State ), ); } + + Widget drawerBtn( + {final CrossAxisAlignment? crossAxisAlignment, + required final IconData icon, + required final String text, + final bool enable = true, + final String? label, + final Function()? labelClick, + final Function()? click}) { + return InkWell( + onTap: enable ? click : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + crossAxisAlignment: + crossAxisAlignment ?? CrossAxisAlignment.center, + children: [ + Icon( + icon, + color: enable + ? null + : Theme.of(context).colorScheme.disabledText, + ), + const SizedBox( + width: 8, + ), + Column( + children: [ + DidvanText(text, + color: enable + ? null + : Theme.of(context).colorScheme.disabledText), + // if (!enable) Text('در حال توسعه ...') + ], + ) + ], + ), + if (label != null) + InkWell( + onTap: labelClick, + child: DidvanText( + label, + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + ), + ) + ], + ), + ), + ); + } } diff --git a/lib/views/profile/profile.dart b/lib/views/profile/profile.dart index 2fda0e0..b70b858 100644 --- a/lib/views/profile/profile.dart +++ b/lib/views/profile/profile.dart @@ -176,8 +176,8 @@ class _ProfilePageState extends State { .title, ))), AnimatedVisibility( - isVisible: state.showThemes, duration: DesignConfig.lowAnimationDuration, + isVisible: state.showThemes, child: Padding( padding: const EdgeInsets.symmetric( vertical: 12.0), @@ -242,6 +242,7 @@ class _ProfilePageState extends State { AnimatedVisibility( isVisible: state.showContactUs, duration: DesignConfig.lowAnimationDuration, + fadeMode: FadeMode.vertical, child: Padding( padding: const EdgeInsets.only(right: 8.0, top: 8), diff --git a/lib/views/widgets/didvan/bnb.dart b/lib/views/widgets/didvan/bnb.dart index 06eb1ca..17e4fe9 100644 --- a/lib/views/widgets/didvan/bnb.dart +++ b/lib/views/widgets/didvan/bnb.dart @@ -59,7 +59,7 @@ class DidvanBNB extends StatelessWidget { ), _NavBarItem( isSelected: currentTabIndex == 2, - title: 'هوش مصنوعی', + title: 'هوشان', selectedIcon: DidvanIcons.ai_solid, unselectedIcon: DidvanIcons.ai_regular, onTap: () => onTabChanged(2), diff --git a/lib/views/widgets/logo_app_bar.dart b/lib/views/widgets/logo_app_bar.dart index 7cdcb6e..ea3b626 100644 --- a/lib/views/widgets/logo_app_bar.dart +++ b/lib/views/widgets/logo_app_bar.dart @@ -18,7 +18,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class LogoAppBar extends StatelessWidget implements PreferredSizeWidget { - const LogoAppBar({Key? key}) : super(key: key); + final bool canSearch; + const LogoAppBar({Key? key, this.canSearch = true}) : super(key: key); @override Size get preferredSize => const Size(double.infinity, 144); @@ -28,7 +29,7 @@ class LogoAppBar extends StatelessWidget implements PreferredSizeWidget { final state = context.read(); final MediaQueryData d = MediaQuery.of(context); return Container( - height: 144, + height: canSearch ? 144 : 100, decoration: BoxDecoration( borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(20)), color: Theme.of(context).colorScheme.surface, @@ -100,23 +101,24 @@ class LogoAppBar extends StatelessWidget implements PreferredSizeWidget { ], ), const SizedBox(height: 16), - Consumer( - builder: (context, state, child) => SearchField( - key: state.search.isEmpty ? ValueKey(state.search) : null, - value: state.search, - title: 'دیدوان', - onChanged: (value) => _onChanged(value, context), - focusNode: state.searchFieldFocusNode, - onFilterButtonPressed: () => _showFilterBottomSheet(context), - isFiltered: state.filtering, - onGoBack: state.filtering - ? () { - state.resetFilters(false); - FocusScope.of(context).unfocus(); - } - : null, + if (canSearch) + Consumer( + builder: (context, state, child) => SearchField( + key: state.search.isEmpty ? ValueKey(state.search) : null, + value: state.search, + title: 'دیدوان', + onChanged: (value) => _onChanged(value, context), + focusNode: state.searchFieldFocusNode, + onFilterButtonPressed: () => _showFilterBottomSheet(context), + isFiltered: state.filtering, + onGoBack: state.filtering + ? () { + state.resetFilters(false); + FocusScope.of(context).unfocus(); + } + : null, + ), ), - ), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 259678e..3ec0782 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -726,7 +726,7 @@ packages: source: hosted version: "1.11.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" @@ -766,7 +766,7 @@ packages: source: hosted version: "3.0.1" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -937,50 +937,58 @@ packages: dependency: "direct main" description: name: record - sha256: f703397f5a60d9b2b655b3acc94ba079b2d9a67dc0725bdb90ef2fee2441ebf7 + sha256: "4a5cf4d083d1ee49e0878823c4397d073f8eb0a775f31215d388e2bc47a9e867" url: "https://pub.dev" source: hosted - version: "4.4.4" + version: "5.1.2" + record_android: + dependency: transitive + description: + name: record_android + sha256: d7af0b3119725a0f561817c72b5f5eca4d7a76d441deef519ae04e4824c0734c + url: "https://pub.dev" + source: hosted + version: "1.2.6" + record_darwin: + dependency: transitive + description: + name: record_darwin + sha256: fe90d302acb1f3cee1ade5df9c150ca5cee33b48d8cdf1cf433bf577d7f00134 + url: "https://pub.dev" + source: hosted + version: "1.1.2" record_linux: dependency: transitive description: name: record_linux - sha256: "348db92c4ec1b67b1b85d791381c8c99d7c6908de141e7c9edc20dad399b15ce" + sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" url: "https://pub.dev" source: hosted - version: "0.4.1" - record_macos: - dependency: transitive - description: - name: record_macos - sha256: d1d0199d1395f05e218207e8cacd03eb9dc9e256ddfe2cfcbbb90e8edea06057 - url: "https://pub.dev" - source: hosted - version: "0.2.2" + version: "0.7.2" record_platform_interface: dependency: transitive description: name: record_platform_interface - sha256: "7a2d4ce7ac3752505157e416e4e0d666a54b1d5d8601701b7e7e5e30bec181b4" + sha256: "11f8b03ea8a0e279b0e306571dbe0db0202c0b8e866495c9fa1ad2281d5e4c15" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "1.1.0" record_web: - dependency: "direct main" + dependency: transitive description: name: record_web - sha256: "219ffb4ca59b4338117857db56d3ffadbde3169bcaf1136f5f4d4656f4a2372d" + sha256: "0ef370d1e6553ad33c39dd03103b374e7861f3518b0533e64c94d73f988a5ffa" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "1.1.0" record_windows: dependency: transitive description: name: record_windows - sha256: "42d545155a26b20d74f5107648dbb3382dbbc84dc3f1adc767040359e57a1345" + sha256: e653555aa3fda168aded7c34e11bd82baf0c6ac84e7624553def3c77ffefd36f url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "1.0.3" rive: dependency: "direct main" description: @@ -1066,6 +1074,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: e17dcc7a1d0701e84d0a83c0040503cdcc6c72e44db0d733ab4c706dd5b8b9f8 + url: "https://pub.dev" + source: hosted + version: "25.2.7" + syncfusion_flutter_sliders: + dependency: transitive + description: + name: syncfusion_flutter_sliders + sha256: "842a452fd73fd61fbebbff72d726ffea4cdaacf088ca2738aeaf7f4454b3861e" + url: "https://pub.dev" + source: hosted + version: "25.2.7" synchronized: dependency: transitive description: @@ -1282,6 +1306,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.0.0" + voice_message_package: + dependency: "direct main" + description: + name: voice_message_package + sha256: cd7a717751e60908d37624309c6995c1c62c1bab6b54f7d15c2d71289f0b0cb6 + url: "https://pub.dev" + source: hosted + version: "2.2.1" wakelock_plus: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 08e41ba..139aca2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,8 +51,7 @@ dependencies: carousel_slider: ^4.0.0 flutter_vibrate: ^1.3.0 universal_html: ^2.0.8 - record: ^4.4.3 - record_web: ^0.5.0 + record: ^5.1.2 persian_datetime_picker: ^2.6.0 persian_number_utility: ^1.1.1 bot_toast: ^4.0.1 @@ -90,8 +89,12 @@ dependencies: flutter_markdown: ^0.7.3+1 file_picker: ^8.0.5 marquee: ^2.2.3 + voice_message_package: ^2.2.1 + mime: ^1.0.2 + # onesignal_flutter: ^3.5.0 + path: any dev_dependencies: flutter_test: sdk: flutter