diff --git a/lib/models/ai/files_model.dart b/lib/models/ai/files_model.dart index a6781cb..c2620d5 100644 --- a/lib/models/ai/files_model.dart +++ b/lib/models/ai/files_model.dart @@ -27,12 +27,18 @@ class FilesModel { this.duration, }) { basename = name ?? p.basename(path); - extname = p.extension(path); + extname = path.isNotEmpty + ? p.extension(path) + : name != null + ? p.extension(name) + : ''; main = File(path); } bool isAudio() { - return audio ?? lookupMimeType(path)?.startsWith('audio/') ?? false; + return audio ?? + (lookupMimeType(path)?.startsWith('audio/') ?? false) || + (lookupMimeType(path)?.startsWith('video/') ?? false); } bool isImage() { diff --git a/lib/services/ai/ai_api_service.dart b/lib/services/ai/ai_api_service.dart index 28dc5ac..7b7cd40 100644 --- a/lib/services/ai/ai_api_service.dart +++ b/lib/services/ai/ai_api_service.dart @@ -4,11 +4,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:didvan/models/ai/files_model.dart'; -import 'package:didvan/services/app_initalizer.dart'; import 'package:didvan/services/storage/storage.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; +import 'package:mime/mime.dart'; class AiApiService { static const String baseUrl = 'https://api.didvan.app/ai'; @@ -48,8 +48,11 @@ class AiApiService { bytes = file.bytes!; } else { final Uri audioUri = Uri.parse(file.path.replaceAll('%3A', ':')); + final http.Response audioResponse = await http.get(audioUri); + bytes = audioResponse.bodyBytes; + // final f = File.fromUri(Uri.parse(file.path)); // bytes = await f.readAsBytes(); @@ -68,15 +71,15 @@ class AiApiService { // For other platforms bytes = await file.main.readAsBytes(); } - filename = file.basename; + if (file.isRecorded) { + filename = '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.mp4'; - mimeType = file.isAudio() - ? file.isRecorded - ? 'audio/${kIsWeb && AppInitializer.getOperatingSystem() == 'iOS' ? 'webm' : 'm4a'}' - : 'audio/${file.extname.replaceAll('.', '')}' - : file.isImage() - ? 'image/png' - : 'application/pdf'; + mimeType = lookupMimeType(filename, headerBytes: bytes) ?? 'audio/mp4'; + } else { + filename = file.basename; + mimeType = lookupMimeType(filename, headerBytes: bytes) ?? + 'application/octet-stream'; + } request.files.add(http.MultipartFile.fromBytes( 'file', diff --git a/lib/services/app_initalizer.dart b/lib/services/app_initalizer.dart index ca1a80b..5ee22c8 100644 --- a/lib/services/app_initalizer.dart +++ b/lib/services/app_initalizer.dart @@ -16,7 +16,6 @@ import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; -import 'dart:html' as html; class AppInitializer { static String? fcmToken; @@ -42,24 +41,6 @@ class AppInitializer { }); } - static String getOperatingSystem() { - final userAgent = html.window.navigator.userAgent.toLowerCase(); - - if (userAgent.contains('windows')) { - return 'Windows'; - } else if (userAgent.contains('mac os')) { - return 'MacOS'; - } else if (userAgent.contains('iphone') || userAgent.contains('ipad')) { - return 'iOS'; - } else if (userAgent.contains('android')) { - return 'Android'; - } else if (userAgent.contains('linux')) { - return 'Linux'; - } else { - return 'Unknown'; - } - } - static Future initilizeSettings() async { try { final brightness = await StorageService.getValue(key: 'brightness'); diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index bee7087..0d175d9 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -7,7 +7,6 @@ import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request_helper.dart'; import 'package:didvan/services/storage/storage.dart'; import 'package:didvan/utils/action_sheet.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; @@ -58,7 +57,7 @@ class MediaService { source = audioSource; } audioPlayer.setUrl( - kIsWeb ? source.replaceAll('%3A', ':') : source, + source, tag: isVoiceMessage ? null : { diff --git a/lib/services/media/voice.dart b/lib/services/media/voice.dart index d6a1bf4..4569718 100644 --- a/lib/services/media/voice.dart +++ b/lib/services/media/voice.dart @@ -1,40 +1,53 @@ + import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request_helper.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:http/http.dart' as http; -import 'package:universal_html/html.dart' as html; + +class MyJABytesSource extends StreamAudioSource { + final Uint8List _buffer; + + MyJABytesSource(this._buffer) : super(tag: 'MyAudioSource'); + + @override + Future request([int? start, int? end]) async { + // Returning the stream audio response with the parameters + return StreamAudioResponse( + sourceLength: _buffer.length, + contentLength: (end ?? _buffer.length) - (start ?? 0), + offset: start ?? 0, + stream: Stream.fromIterable([_buffer.sublist(start ?? 0, end)]), + contentType: 'audio/wav', + ); + } +} class VoiceService { static final audioPlayer = AudioPlayer(); static String? src; - static Future getDuration({ - required String src, - }) async { + static Future getDuration( + {required String src, Uint8List? bytes}) async { final ap = AudioPlayer(); Duration? duration; try { if (src.startsWith('/uploads')) { final source = '${RequestHelper.baseUrl + src}?accessToken=${RequestService.token}'; - if (kIsWeb) { - final response = - await http.get(Uri.parse(source.replaceAll('%3A', ':'))); - final bytes = response.bodyBytes; - final blob = html.Blob([bytes]); - final blobUrl = html.Url.createObjectUrlFromBlob(blob); - duration = await ap.setAudioSource( - AudioSource.uri(Uri.parse(blobUrl)), - ); - } else { - final lockCachingAudioSource = - LockCachingAudioSource(Uri.parse(source)); - - duration = await ap.setAudioSource(lockCachingAudioSource); - } - } else if (src.startsWith('blob:')) { - duration = await ap.setAudioSource(AudioSource.uri(Uri.parse(src))); + // if (kIsWeb) { + // final response = + // await http.get(Uri.parse(source.replaceAll('%3A', ':'))); + // final bytes = response.bodyBytes; + // final blob = html.Blob([bytes]); + // final blobUrl = html.Url.createObjectUrlFromBlob(blob); + // duration = await ap.setAudioSource( + // AudioSource.uri(Uri.parse(blobUrl)), + // ); + // } else { + duration = await ap.setUrl(source); + // } + } else if (src.startsWith('blob:') || src == '') { + duration = await ap.setUrl(src); } else { duration = await ap.setFilePath(src); } @@ -53,6 +66,7 @@ class VoiceService { static Future voiceHelper( {required String src, + final Uint8List? bytes, final Duration? duration, final Function()? startTimer, final Function()? stopTimer}) async { @@ -72,23 +86,20 @@ class VoiceService { if (src.startsWith('/uploads')) { final source = '${RequestHelper.baseUrl + src}?accessToken=${RequestService.token}'; - if (kIsWeb) { - final response = - await http.get(Uri.parse(source.replaceAll('%3A', ':'))); - final bytes = response.bodyBytes; - final blob = html.Blob([bytes]); - final blobUrl = html.Url.createObjectUrlFromBlob(blob); - await audioPlayer.setAudioSource( - AudioSource.uri(Uri.parse(blobUrl)), - ); - } else { - final lockCachingAudioSource = - LockCachingAudioSource(Uri.parse(source)); - - await audioPlayer.setAudioSource(lockCachingAudioSource); - } - } else if (src.startsWith('blob:')) { - await audioPlayer.setAudioSource(AudioSource.uri(Uri.parse(src))); + // if (kIsWeb) { + // final response = + // await http.get(Uri.parse(source.replaceAll('%3A', ':'))); + // final bytes = response.bodyBytes; + // final blob = html.Blob([bytes]); + // final blobUrl = html.Url.createObjectUrlFromBlob(blob); + // await audioPlayer.setAudioSource( + // AudioSource.uri(Uri.parse(blobUrl)), + // ); + // } else { + await audioPlayer.setUrl(source); + // } + } else if (src.startsWith('blob:') || src == '') { + await audioPlayer.setUrl(src); } else { await audioPlayer.setFilePath(src); } @@ -102,7 +113,6 @@ class VoiceService { static Future resetVoicePlayer() async { src = null; - await audioPlayer.stop(); } } diff --git a/lib/utils/action_sheet.dart b/lib/utils/action_sheet.dart index dcc4a39..1ef10bc 100644 --- a/lib/utils/action_sheet.dart +++ b/lib/utils/action_sheet.dart @@ -436,6 +436,37 @@ class ActionSheetUtils { ); } + 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, + ), + ], + ), + ); + } + void pop() { DesignConfig.updateSystemUiOverlayStyle(); Navigator.of(context).pop(); diff --git a/lib/views/ai/ai.dart b/lib/views/ai/ai.dart index 44491ba..86cc9b5 100644 --- a/lib/views/ai/ai.dart +++ b/lib/views/ai/ai.dart @@ -196,7 +196,7 @@ class _AiState extends State { child: Row( children: [ const MessageBarBtn( - enable: false, + enable: true, icon: DidvanIcons.mic_regular), const SizedBox( @@ -225,19 +225,19 @@ class _AiState extends State { ), ), ), + const SizedBox( + width: 8, + ), + const MessageBarBtn( + enable: false, + icon: Icons + .attach_file_rounded), ], )))) ], ), ), ), - const SizedBox( - width: 18, - ), - Icon( - Icons.attach_file_rounded, - color: Theme.of(context).colorScheme.focusedBorder, - ), ], )), ), diff --git a/lib/views/ai/ai_chat_page.dart b/lib/views/ai/ai_chat_page.dart index e30a419..f9e1fe6 100644 --- a/lib/views/ai/ai_chat_page.dart +++ b/lib/views/ai/ai_chat_page.dart @@ -21,7 +21,6 @@ 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/skeleton_image.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; @@ -269,8 +268,8 @@ class _AiChatPageState extends State { physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.only( bottom: state.file != null && - !state.file!.isRecorded - ? 150 + !(state.file!.isRecorded) + ? 180 : 100), itemBuilder: (context, mIndex) { final prompts = state.messages[mIndex].prompts; @@ -299,7 +298,7 @@ class _AiChatPageState extends State { children: [ AiMessageBar( bot: widget.args.bot, - focusNode: focusNode, + // focusNode: focusNode, ), ], )), @@ -509,6 +508,7 @@ class _AiChatPageState extends State { .remove(message); state.messages.last.prompts.add( message.copyWith(error: false)); + state.file = file; state.update(); await state .postMessage(widget.args.bot); @@ -559,6 +559,7 @@ class _AiChatPageState extends State { } else { state.messages[mIndex].prompts .removeAt(index); + state.update(); } }, child: Icon( @@ -624,18 +625,18 @@ class _AiChatPageState extends State { stop: const Duration(seconds: 3), ), ), - if (state.file != null && !kIsWeb) - 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 (state.file != null && !kIsWeb) + // 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, + // ); + // }) ], ), ) diff --git a/lib/views/ai/ai_chat_state.dart b/lib/views/ai/ai_chat_state.dart index a87041d..78594e3 100644 --- a/lib/views/ai/ai_chat_state.dart +++ b/lib/views/ai/ai_chat_state.dart @@ -134,6 +134,8 @@ class AiChatState extends CoreProvier { Future postMessage(BotsModel bot) async { onResponsing = true; + final uploadedFile = file; + file = null; update(); String message = messages.last.prompts.last.text!; @@ -157,7 +159,7 @@ class AiChatState extends CoreProvier { url: '/${bot.id}/${bot.name}'.toLowerCase(), message: message, chatId: chatId, - file: file, + file: uploadedFile, edite: isEdite); final res = await AiApiService().getResponse(req).catchError((e) { _onError(e); @@ -166,7 +168,6 @@ class AiChatState extends CoreProvier { String responseMessgae = ''; String dataMessgae = ''; - file = null; update(); final r = res.listen((value) async { diff --git a/lib/views/ai/widgets/ai_message_bar.dart b/lib/views/ai/widgets/ai_message_bar.dart index 5ed6476..d22f6bb 100644 --- a/lib/views/ai/widgets/ai_message_bar.dart +++ b/lib/views/ai/widgets/ai_message_bar.dart @@ -1,6 +1,9 @@ -import 'dart:async'; -import 'dart:io'; +// ignore_for_file: library_private_types_in_public_api, avoid_web_libraries_in_flutter +import 'dart:async'; +import 'dart:html' as html; +import 'dart:io'; +import 'package:audio_session/audio_session.dart'; import 'package:didvan/config/design_config.dart'; import 'package:didvan/config/theme_data.dart'; import 'package:didvan/constants/app_icons.dart'; @@ -8,9 +11,7 @@ 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/app_initalizer.dart'; import 'package:didvan/services/media/media.dart'; -import 'package:didvan/services/media/voice.dart'; import 'package:didvan/utils/action_sheet.dart'; import 'package:didvan/utils/date_time.dart'; import 'package:didvan/views/ai/ai_chat_state.dart'; @@ -24,735 +25,635 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_sound/flutter_sound.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:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.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; +typedef _Fn = void Function(); class AiMessageBar extends StatefulWidget { - final FocusNode? focusNode; final BotsModel bot; - const AiMessageBar({ - super.key, - this.focusNode, - required this.bot, - }); + const AiMessageBar({Key? key, required this.bot}) : super(key: key); @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, - ), - ], - ), - ); - } + _AiMessageBarState createState() => _AiMessageBarState(); } class _AiMessageBarState extends State { - final ValueNotifier messageText = ValueNotifier(''); + FlutterSoundPlayer? _mPlayer = FlutterSoundPlayer(); + FlutterSoundRecorder? _mRecorder = FlutterSoundRecorder(); + + Codec _codec = Codec.aacMP4; + String _mPath = '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.mp4'; + + bool _mPlayerIsInited = false; + bool _mRecorderIsInited = false; + bool _mplaybackReady = false; bool openAttach = false; - String? path; + + Timer? _timer; + final theSource = AudioSource.microphone; + final ValueNotifier _countTimer = ValueNotifier(Duration.zero); @override void initState() { - widget.focusNode?.addListener(() {}); + _mPlayer!.openPlayer().then((value) { + setState(() { + _mPlayerIsInited = true; + }); + }); + openTheRecorder().then((value) { + setState(() { + _mRecorderIsInited = true; + }); + }); super.initState(); } - final record = AudioRecorder(); - @override void dispose() { + _mPlayer!.closePlayer(); + _mPlayer = null; + + _mRecorder!.closeRecorder(); + _mRecorder = null; + _timer?.cancel(); super.dispose(); - record.dispose(); - try { - _timer.cancel(); - } catch (e) { - e.printError(); - } } - late Timer _timer; - final ValueNotifier _countTimer = ValueNotifier(0); + Future openTheRecorder() async { + if (!kIsWeb) { + var status = await Permission.microphone.request(); + if (status != PermissionStatus.granted) { + throw RecordingPermissionException('Microphone permission not granted'); + } + } + await _mRecorder!.openRecorder(); + if (!await _mRecorder!.isEncoderSupported(_codec) && kIsWeb) { + _codec = Codec.opusWebM; + _mPath = '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.webm'; + if (!await _mRecorder!.isEncoderSupported(_codec) && kIsWeb) { + _mRecorderIsInited = true; + return; + } + } + final session = await AudioSession.instance; + await session.configure(AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, + avAudioSessionCategoryOptions: + AVAudioSessionCategoryOptions.allowBluetooth | + AVAudioSessionCategoryOptions.defaultToSpeaker, + avAudioSessionMode: AVAudioSessionMode.spokenAudio, + avAudioSessionRouteSharingPolicy: + AVAudioSessionRouteSharingPolicy.defaultPolicy, + avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, + androidAudioAttributes: const AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + flags: AndroidAudioFlags.none, + usage: AndroidAudioUsage.voiceCommunication, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gain, + androidWillPauseWhenDucked: true, + )); + + _mRecorderIsInited = true; + } + void startTimer() { const oneSec = Duration(seconds: 1); _timer = Timer.periodic( oneSec, (Timer timer) { - _countTimer.value++; + _countTimer.value = Duration(seconds: _countTimer.value.inSeconds + 1); }, ); } + void record() async { + _countTimer.value = Duration.zero; + + await _mRecorder! + .startRecorder( + toFile: _mPath, + codec: _codec, + audioSource: theSource, + ) + .then((value) { + setState(() { + startTimer(); + }); + }); + } + + void stopRecorder() async { + await _mRecorder!.stopRecorder().then((value) { + setState(() { + var url = value; + final state = context.read(); + state.file = FilesModel(url!, + audio: true, isRecorded: true, duration: _countTimer.value); + _mplaybackReady = true; + _timer?.cancel(); + }); + }); + _mPlayer!.setSubscriptionDuration(_countTimer.value); + } + + void play() async { + assert(_mPlayerIsInited && + _mplaybackReady && + _mRecorder!.isStopped && + _mPlayer!.isStopped); + _mPlayer! + .startPlayer( + fromURI: _mPath, + // fromDataBuffer: Uint8List.fromList( + // recordedData.expand((element) => element).toList()), + //codec: kIsWeb ? Codec.opusWebM : Codec.aacADTS, + whenFinished: () { + setState(() {}); + }) + .then((value) { + setState(() {}); + }); + } + + void stopPlayer() { + _mPlayer!.stopPlayer().then((value) { + setState(() {}); + }); + } + + void pausePlayer() { + _mPlayer!.pausePlayer().then((value) { + setState(() {}); + }); + } + + void resumePlayer() { + _mPlayer!.resumePlayer().then((value) { + setState(() {}); + }); + } + + _Fn? getRecorderFn() { + if (!_mRecorderIsInited || !_mPlayer!.isStopped) { + return null; + } + return _mRecorder!.isStopped ? record : stopRecorder; + } + + _Fn? getPlaybackFn() { + if (!_mPlayerIsInited || !_mplaybackReady || !_mRecorder!.isStopped) { + return null; + } + return _mPlayer!.isPlaying + ? pausePlayer + : _mPlayer!.isPaused + ? resumePlayer + : _mPlayer!.isStopped + ? play + : stopPlayer; + } + @override Widget build(BuildContext context) { - return Consumer( - builder: (context, state, child) { - final historyState = context.read(); + return Consumer(builder: (context, state, child) { + final historyState = context.read(); - return IgnorePointer( - ignoring: state.onResponsing, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 24).copyWith( + return Container( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 12) + .copyWith( top: openAttach || (state.file != null && !state.file!.isRecorded) ? 0 : 24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background, - border: Border( - top: openAttach - ? BorderSide( - color: Theme.of(context).colorScheme.border) - : BorderSide.none)), - child: Column( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + border: Border( + top: openAttach + ? BorderSide(color: Theme.of(context).colorScheme.border) + : BorderSide.none)), + child: Stack( + children: [ + Column( children: [ - Stack( - children: [ - fileContainer(), - AnimatedVisibility( - isVisible: openAttach, - duration: DesignConfig.lowAnimationDuration, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (historyState.bot!.attachmentType! - .contains('pdf')) - attachBtn( - title: "PDF", - icon: CupertinoIcons.doc_fill, - color: Colors.redAccent, - click: () async { - MediaService.onLoadingPickFile(context); - FilePickerResult? result = - await MediaService.pickPdfFile(); - if (result != null) { - if (kIsWeb) { - Uint8List? bytes = result.files.first - .bytes; // Access the bytes property - String? name = result.files.first.name; - - // Store bytes and file name directly in your state or model - state.file = FilesModel( - '', // No need for a file path on web - name: name, - bytes: bytes, - audio: false, - image: false, - ); - } else { - state.file = FilesModel( - result.files.single.path!, - audio: false, - image: false); - } - - openAttach = false; - } - Future.delayed( - Duration.zero, - () => ActionSheetUtils(context).pop(), - ); - }, - ), - if (historyState.bot!.attachmentType! - .contains('image')) - attachBtn( - title: "تصویر", - icon: CupertinoIcons.photo, - color: Colors.deepOrangeAccent, - click: () async { - MediaService.onLoadingPickFile(context); - - 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) { - await Future.delayed( - Duration.zero, - () => ActionSheetUtils(context).pop(), - ); - - return; - } - } - if (pickedFile == null) { - await Future.delayed( - Duration.zero, - () => ActionSheetUtils(context).pop(), - ); - - return; - } - state.file = kIsWeb - ? FilesModel(pickedFile.path, - name: pickedFile.name, - image: true, - audio: false) - : FilesModel(file!.path, - image: true, audio: false); - openAttach = false; - await Future.delayed( - Duration.zero, - () => ActionSheetUtils(context).pop(), - ); - }, - ), - // if (!kIsWeb && !Platform.isIOS) - if (historyState.bot!.attachmentType! - .contains('audio')) - attachBtn( - title: "صوت", - icon: CupertinoIcons.music_note_2, - color: Colors.indigoAccent, - click: () async { - MediaService.onLoadingPickFile(context); - - FilePickerResult? result = - await MediaService.pickAudioFile(); - if (result != null) { - if (kIsWeb) { - Uint8List? bytes = result.files.first - .bytes; // Access the bytes property - String? name = result.files.first.name; - - state.file = FilesModel( - '', // No need for a file path on web - name: name, - bytes: bytes, - audio: true, - image: false, - ); - } else { - state.file = FilesModel( - result.files.single.path!, - audio: true, - image: false); - } - openAttach = false; - } - await Future.delayed( - Duration.zero, - () => ActionSheetUtils(context).pop(), - ); - }, - ) - ], - ), - )), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), + attachmentLayout(state, historyState, context), + Container( + decoration: BoxDecoration( + boxShadow: DesignConfig.defaultShadow, + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: Theme.of(context).colorScheme.border), + borderRadius: DesignConfig.highBorderRadius), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded( - child: Container( - // height: 50, - decoration: BoxDecoration( - boxShadow: DesignConfig.defaultShadow, - color: Theme.of(context).colorScheme.surface, - border: Border.all( - color: Theme.of(context).colorScheme.border), - borderRadius: DesignConfig.highBorderRadius), - child: Row( - children: [ - const SizedBox( - width: 8, + recorderAndSendButton(state, historyState), + if (!(_mRecorder!.isStopped)) + ValueListenableBuilder( + valueListenable: _countTimer, + builder: (context, value, child) => Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: SizedBox( + width: 46, + child: Center( + child: DidvanText( + DateTimeUtils.normalizeTimeDuration(value)), + ), ), - Expanded( - child: StreamBuilder( - stream: record.onStateChanged(), - builder: (context, snapshot) { - return ValueListenableBuilder( - valueListenable: messageText, - builder: (context, value, child) { - return Row( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - Padding( - padding: const EdgeInsets.only( - bottom: 8.0), - child: ( - // !kIsWeb && - snapshot.hasData && - snapshot.data! != - RecordState - .stop) - ? MessageBarBtn( - enable: true, - icon: DidvanIcons - .stop_circle_solid, - click: () async { - path = await record - .stop(); - - Duration? duration = - await VoiceService - .getDuration( - src: path ?? - ''); - - state.file = FilesModel( - path.toString(), - name: - '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.${kIsWeb && AppInitializer.getOperatingSystem() == 'iOS' ? 'webm' : 'm4a'}', - isRecorded: true, - audio: true, - image: false, - duration: - duration); - _timer.cancel(); - _countTimer.value = 0; - state.update(); - }, - ) - : - // (!kIsWeb && - // !Platform - // .isIOS) && - widget.bot.attachmentType! - .contains( - 'audio') && - value.isEmpty && - state.file == - null && - widget.bot - .attachment != - 0 - ? MessageBarBtn( - enable: false, - icon: DidvanIcons - .mic_regular, - click: () async { - if (await record - .hasPermission()) { - try { - String path = - '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.m4a'; -// - if (!kIsWeb) { - Directory? - downloadDir = - await getApplicationDocumentsDirectory(); - path = p.join( - downloadDir - .path, - '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.m4a'); - } - - record.start( - const RecordConfig(), - path: - path); - startTimer(); - } catch (e) { - if (kDebugMode) { - print( - 'Error starting recording: $e'); - } - } - } - }, - ) - : MessageBarBtn( - enable: (state.file != - null && - state.file! - .isRecorded) || - (widget.bot - .attachment == - 1) || - value - .isNotEmpty, - icon: DidvanIcons - .send_light, - click: () async { - if ((state.file == - null || - !state - .file! - .isRecorded) && - (widget.bot - .attachment != - 1) && - value - .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: state - .message - .text, - file: state - .file - ?.path, - fileName: state - .file - ?.basename, - fileLocal: - state - .file, - 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: state - .message - .text, - finished: - true, - file: state - .file - ?.path, - fileName: state - .file - ?.basename, - fileLocal: - state.file, - role: - 'user', - createdAt: DateTime.now() - .subtract(const Duration(minutes: 210)) - .toIso8601String(), - ) - ])); - } - state.message - .clear(); - messageText - .value = - state.message - .text; - await state - .postMessage( - widget - .bot); - }, - ), - ), - 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 - .spaceBetween, - children: [ - Directionality( - textDirection: - TextDirection - .ltr, - child: Row( - mainAxisAlignment: - MainAxisAlignment - .center, - children: List.generate( - 4, - (index) => snapshot.data! == RecordState.pause - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - 8, - (index) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 12), - child: Container( - width: 3, - height: 8, - decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary.withOpacity(0.4)), - ), - )), - ) - : SpinKitWave( - color: Theme.of(context).colorScheme.primary.withOpacity(0.4), - size: 32, - itemCount: 10, - ))), - ), - ValueListenableBuilder< - int>( - valueListenable: - _countTimer, - builder: (context, - value, - child) => - DidvanText(DateTimeUtils.normalizeTimeDuration(Duration( - seconds: - value))), - ), - ], - ), - ) - : state.file != null && - state.file! - .isRecorded - ? audioContainer() - : Form( - child: - TextFormField( - textInputAction: - TextInputAction - .newline, - style: Theme.of( - context) - .textTheme - .bodyMedium, - minLines: 1, - maxLines: - 6, // Set this - // expands: true, // - // keyboardType: TextInputType.text, - keyboardType: - TextInputType - .multiline, - controller: - state.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), - suffixIcon: state - .isEdite - ? InkWell( - onTap: - () { - state.isEdite = - false; - state - .update(); - }, - child: const Icon( - DidvanIcons.close_circle_solid), - ) - : const SizedBox(), - ), - - onChanged: - (value) { - messageText - .value = - value; - }, - )), - ), - ), - if (snapshot.hasData) - Padding( - padding: - const EdgeInsets.only( - bottom: 8.0), - child: 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, - ), - ], + ), + ), + Expanded( + child: Padding( + padding: + _mRecorder!.isPaused || _mRecorder!.isRecording + ? const EdgeInsets.fromLTRB(12, 8, 0, 8) + : EdgeInsets.zero, + child: recorderAndTextMessageHandler(context, state), ), - )), - const SizedBox( - width: 12, ), - ValueListenableBuilder( - valueListenable: messageText, - builder: (context, value, child) { - if (context - .read() - .bot! - .attachmentType! - .isNotEmpty && - (widget.bot.attachment != 0 && - (widget.bot.attachment == 1 && - value.isEmpty)) || - widget.bot.attachment == 2) { - return SizedBox( - width: 46, - height: 46, - child: Center( - child: InkWell( - onTap: () { - if (state.file != null) { - state.file = null; - state.update(); - return; - } - setState(() { - openAttach = !openAttach; - }); - }, - child: Icon( - state.file != null - ? DidvanIcons.close_solid - : Icons.attach_file_rounded, - color: Theme.of(context) - .colorScheme - .focusedBorder, - ), - ))); - } - return const SizedBox(); - }), ], ), ), ], ), - ), - ); - }, + if (state.onResponsing) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .focused + .withOpacity(0.5)), + child: Center( + child: SpinKitThreeBounce( + color: Theme.of(context).colorScheme.primary, + size: 32, + ), + ), + ), + ) + ], + ), + ); + }); + } + + Row recorderAndTextMessageHandler(BuildContext context, AiChatState state) { + return Row( + children: _mRecorder!.isPaused + ? [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + 32, + (index) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 1.0, vertical: 12), + child: Container( + width: 3, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.4)), + ), + )), + ), + ), + MessageBarBtn( + enable: true, + icon: DidvanIcons.play_regular, + click: () async { + await _mRecorder?.resumeRecorder(); + setState(() { + startTimer(); + }); + }, + ) + ] + : _mRecorder!.isRecording + ? [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + 5, + (index) => SpinKitWave( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.4), + size: 32, + itemCount: 10, + )))), + MessageBarBtn( + enable: true, + icon: DidvanIcons.pause_regular, + click: () async { + await _mRecorder?.pauseRecorder(); + + setState(() { + _timer!.cancel(); + }); + }, + ) + ] + : [ + Expanded( + child: state.file != null && state.file!.isRecorded + ? audioContainer() + : Form( + child: TextFormField( + textInputAction: TextInputAction.newline, + style: Theme.of(context).textTheme.bodyMedium, + minLines: 1, + maxLines: 6, // Set this + keyboardType: TextInputType.multiline, + controller: state.message, + 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), + suffixIcon: state.isEdite + ? InkWell( + onTap: () { + state.isEdite = false; + state.update(); + }, + child: const Icon( + DidvanIcons.close_circle_solid), + ) + : const SizedBox(), + ), + + onChanged: (value) { + state.message.text = value; + state.update(); + }, + )), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 0), + child: MessageBarBtn( + enable: false, + icon: openAttach || state.file != null + ? DidvanIcons.close_regular + : Icons.attach_file_outlined, + click: () { + if (_mPlayer!.isPlaying) { + stopPlayer(); + } + if (state.file != null) { + state.file = null; + } else { + openAttach = !openAttach; + } + state.update(); + }, + ), + ) + ], ); } + Padding recorderAndSendButton( + AiChatState state, HistoryAiChatState historyState) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: state.message.text.isEmpty && + historyState.bot!.attachmentType!.contains('audio') && + state.file == null && + widget.bot.attachment != 0 + ? MessageBarBtn( + enable: true, + icon: _mRecorder!.isRecording || _mRecorder!.isPaused + ? Icons.stop_rounded + : DidvanIcons.mic_regular, + click: getRecorderFn(), + ) + : MessageBarBtn( + enable: (state.file != null && state.file!.isRecorded) || + (widget.bot.attachment == 1) || + state.message.text.isNotEmpty, + icon: DidvanIcons.send_light, + click: () async { + if ((state.file == null || !state.file!.isRecorded) && + (widget.bot.attachment != 1) && + state.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: state.message.text, + file: state.file?.path, + fileName: state.file?.basename, + fileLocal: state.file, + 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: state.message.text, + finished: true, + file: state.file?.path, + fileName: state.file?.basename, + fileLocal: state.file, + role: 'user', + createdAt: DateTime.now() + .subtract(const Duration(minutes: 210)) + .toIso8601String(), + ) + ])); + } + state.message.clear(); + openAttach = false; + state.update(); + await state.postMessage(widget.bot); + }, + ), + ); + } + + AnimatedVisibility attachmentLayout(AiChatState state, + HistoryAiChatState historyState, BuildContext context) { + return AnimatedVisibility( + isVisible: openAttach, + duration: DesignConfig.lowAnimationDuration, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 12), + child: state.file != null && !(state.file!.isRecorded) + ? fileContainer() + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (historyState.bot!.attachmentType!.contains('pdf')) + attachBtn( + title: "PDF", + icon: CupertinoIcons.doc_fill, + color: Colors.redAccent, + click: () async { + MediaService.onLoadingPickFile(context); + FilePickerResult? result = + await MediaService.pickPdfFile(); + if (result != null) { + if (kIsWeb) { + Uint8List? bytes = result.files.first + .bytes; // Access the bytes property + String? name = result.files.first.name; + + // Store bytes and file name directly in your state or model + state.file = FilesModel( + '', // No need for a file path on web + name: name, + bytes: bytes, + audio: false, + image: false, + ); + } else { + state.file = FilesModel(result.files.single.path!, + audio: false, image: false); + } + } + Future.delayed( + Duration.zero, + () => ActionSheetUtils(context).pop(), + ); + }, + ), + if (historyState.bot!.attachmentType!.contains('image')) + attachBtn( + title: "تصویر", + icon: CupertinoIcons.photo, + color: Colors.deepOrangeAccent, + click: () async { + MediaService.onLoadingPickFile(context); + + 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) { + await Future.delayed( + Duration.zero, + () => ActionSheetUtils(context).pop(), + ); + + return; + } + } + if (pickedFile == null) { + await Future.delayed( + Duration.zero, + () => ActionSheetUtils(context).pop(), + ); + + return; + } + state.file = kIsWeb + ? FilesModel(pickedFile.path, + name: pickedFile.name, + image: true, + audio: false) + : FilesModel(file!.path, + image: true, audio: false); + await Future.delayed( + Duration.zero, + () => ActionSheetUtils(context).pop(), + ); + }, + ), + // if (!kIsWeb && !Platform.isIOS) + if (historyState.bot!.attachmentType!.contains('audio')) + attachBtn( + title: "صوت", + icon: CupertinoIcons.music_note_2, + color: Colors.indigoAccent, + click: () async { + MediaService.onLoadingPickFile(context); + + FilePickerResult? result = + await MediaService.pickAudioFile(); + if (result != null) { + if (kIsWeb) { + Uint8List? bytes = result.files.first + .bytes; // Access the bytes property + String? name = result.files.first.name; + + final blob = html.Blob([bytes]); + final blobUrl = + html.Url.createObjectUrlFromBlob(blob); + + state.file = FilesModel( + blobUrl, // No need for a file path on web + name: name, + bytes: bytes, + audio: true, + image: false, + ); + } else { + state.file = FilesModel(result.files.single.path!, + audio: true, image: false); + } + } + await Future.delayed( + Duration.zero, + () => ActionSheetUtils(context).pop(), + ); + }, + ) + ], + ), + )); + } + InkWell attachBtn( {required final String title, required final IconData icon, @@ -798,72 +699,68 @@ class _AiMessageBarState extends State { child: AudioWave( file: state.file!.path, loadingPaddingSize: 8.0, + totalDuration: _countTimer.value, ), ); } - AnimatedVisibility fileContainer() { + Widget fileContainer() { final state = context.watch(); - return AnimatedVisibility( - isVisible: state.file != null && !state.file!.isRecorded, - 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, left: 12, right: 12), - child: Row( - children: [ - state.file != null && state.file!.isImage() - ? SizedBox( - width: 32, - height: 42, - child: ClipRRect( - borderRadius: DesignConfig.lowBorderRadius, - child: state.file!.isNetwork() - ? Image.network( - state.file!.path, - fit: BoxFit.cover, - ) - : Image.file( - state.file!.main, - fit: BoxFit.cover, - ))) - : const Icon(Icons.file_copy), - const SizedBox( - width: 12, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: 24, - child: MarqueeText( - text: state.file != null ? state.file!.basename : '', - style: const TextStyle(fontSize: 14), - stop: const Duration(seconds: 3), - ), + return Container( + decoration: BoxDecoration( + borderRadius: DesignConfig.mediumBorderRadius, + color: Theme.of(context).colorScheme.border, + ), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Row( + children: [ + state.file != null && state.file!.isImage() + ? SizedBox( + width: 32, + height: 42, + child: ClipRRect( + borderRadius: DesignConfig.lowBorderRadius, + child: state.file!.isNetwork() + ? Image.network( + state.file!.path, + fit: BoxFit.cover, + ) + : Image.file( + state.file!.main, + fit: BoxFit.cover, + ))) + : const Icon(Icons.file_copy), + const SizedBox( + width: 12, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 24, + child: MarqueeText( + text: state.file != null ? state.file!.basename : '', + style: const TextStyle(fontSize: 14), + stop: const Duration(seconds: 3), ), - if (state.file != null && !kIsWeb) - 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 (state.file != null && !kIsWeb) + 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, + ); + }) + ], + ), + ) + ], ), ); } diff --git a/lib/views/ai/widgets/audio_wave.dart b/lib/views/ai/widgets/audio_wave.dart index 702eb2e..6045b3a 100644 --- a/lib/views/ai/widgets/audio_wave.dart +++ b/lib/views/ai/widgets/audio_wave.dart @@ -15,13 +15,15 @@ import 'package:just_audio/just_audio.dart'; class AudioWave extends StatefulWidget { final String file; + final Uint8List? bytes; final double loadingPaddingSize; final Duration? totalDuration; const AudioWave( {Key? key, required this.file, this.loadingPaddingSize = 0, - this.totalDuration}) + this.totalDuration, + this.bytes}) : super(key: key); @override @@ -60,6 +62,7 @@ class _AudioWaveState extends State { if (event.processingState == ProcessingState.completed) { await VoiceService.audioPlayer.pause(); await VoiceService.audioPlayer.seek(Duration.zero); + VoiceService.resetVoicePlayer(); } }); } @@ -107,7 +110,8 @@ class _AudioWaveState extends State { ? DidvanIcons.pause_solid : DidvanIcons.play_solid, click: () async { - await VoiceService.voiceHelper(src: widget.file); + await VoiceService.voiceHelper( + src: widget.file, bytes: widget.bytes); }, ); }), diff --git a/lib/views/direct/direct.dart b/lib/views/direct/direct.dart index 94b6d2c..b5d611a 100644 --- a/lib/views/direct/direct.dart +++ b/lib/views/direct/direct.dart @@ -30,6 +30,14 @@ class _DirectState extends State { @override void initState() { final state = context.read(); + state.mPlayer!.openPlayer().then((value) { + state.mPlayerIsInited = true; + }); + + state.openTheRecorder().then((value) { + state.mRecorderIsInited = true; + }); + state.replyNews = widget.pageData['newsAttachment']; state.replyRadar = widget.pageData['radarAttachment']; final typeId = ServerDataProvider.labelToTypeId(widget.pageData['type']); diff --git a/lib/views/direct/direct_state.dart b/lib/views/direct/direct_state.dart index 4bd5f43..96e83bb 100644 --- a/lib/views/direct/direct_state.dart +++ b/lib/views/direct/direct_state.dart @@ -1,22 +1,26 @@ +import 'dart:async'; import 'dart:io'; +import 'package:audio_session/audio_session.dart'; import 'package:didvan/models/enums.dart'; import 'package:didvan/models/message_data/message_data.dart'; import 'package:didvan/models/message_data/news_attachment.dart'; import 'package:didvan/models/message_data/radar_attachment.dart'; import 'package:didvan/providers/core.dart'; -import 'package:didvan/services/media/media.dart'; import 'package:didvan/services/media/voice.dart'; import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request_helper.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_sound/public/flutter_sound_player.dart'; +import 'package:flutter_sound/public/flutter_sound_recorder.dart'; +import 'package:flutter_sound_platform_interface/flutter_sound_platform_interface.dart'; +import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart'; import 'package:flutter_vibrate/flutter_vibrate.dart'; import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; -import 'package:record/record.dart'; +import 'package:permission_handler/permission_handler.dart'; class DirectState extends CoreProvier { - final _recorder = AudioRecorder(); + // final _recorder = AudioRecorder(); final List messages = []; late final int typeId; final Map> dailyMessages = {}; @@ -25,12 +29,60 @@ class DirectState extends CoreProvier { String? text; NewsAttachment? replyNews; RadarAttachment? replyRadar; - File? recordedFile; - Uint8List? recordedFileBytes; int? audioDuration; String? path; - bool isRecording = false; + FlutterSoundPlayer? mPlayer = FlutterSoundPlayer(); + FlutterSoundRecorder? mRecorder = FlutterSoundRecorder(); + + Codec _codec = Codec.aacMP4; + String _mPath = '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.mp4'; + + bool mPlayerIsInited = false; + bool mRecorderIsInited = false; + bool mplaybackReady = false; + + Timer? _timer; + final theSource = AudioSource.microphone; + final ValueNotifier countTimer = ValueNotifier(Duration.zero); + + Future openTheRecorder() async { + if (!kIsWeb) { + var status = await Permission.microphone.request(); + if (status != PermissionStatus.granted) { + throw RecordingPermissionException('Microphone permission not granted'); + } + } + await mRecorder!.openRecorder(); + if (!await mRecorder!.isEncoderSupported(_codec) && kIsWeb) { + _codec = Codec.opusWebM; + _mPath = '${DateTime.now().millisecondsSinceEpoch ~/ 1000}.webm'; + if (!await mRecorder!.isEncoderSupported(_codec) && kIsWeb) { + mRecorderIsInited = true; + return; + } + } + final session = await AudioSession.instance; + await session.configure(AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, + avAudioSessionCategoryOptions: + AVAudioSessionCategoryOptions.allowBluetooth | + AVAudioSessionCategoryOptions.defaultToSpeaker, + avAudioSessionMode: AVAudioSessionMode.spokenAudio, + avAudioSessionRouteSharingPolicy: + AVAudioSessionRouteSharingPolicy.defaultPolicy, + avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, + androidAudioAttributes: const AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + flags: AndroidAudioFlags.none, + usage: AndroidAudioUsage.voiceCommunication, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gain, + androidWillPauseWhenDucked: true, + )); + + mRecorderIsInited = true; + } Future getMessages() async { appState = AppState.busy; @@ -48,48 +100,108 @@ class DirectState extends CoreProvier { appState = AppState.idle; return; } + appState = AppState.failed; } + @override + void dispose() { + super.dispose(); + mPlayer!.closePlayer(); + mPlayer = null; + + mRecorder!.closeRecorder(); + mRecorder = null; + _timer?.cancel(); + } + + void startTimer() { + const oneSec = Duration(seconds: 1); + _timer = Timer.periodic( + oneSec, + (Timer timer) { + countTimer.value = Duration(seconds: countTimer.value.inSeconds + 1); + }, + ); + } + + void record() async { + countTimer.value = Duration.zero; + + await mRecorder! + .startRecorder( + toFile: _mPath, + codec: _codec, + audioSource: theSource, + ) + .then((value) { + startTimer(); + update(); + }); + } + + void play() async { + assert(mPlayerIsInited && + mplaybackReady && + mRecorder!.isStopped && + mPlayer!.isStopped); + mPlayer! + .startPlayer( + fromURI: _mPath, + // fromDataBuffer: Uint8List.fromList( + // recordedData.expand((element) => element).toList()), + //codec: kIsWeb ? Codec.opusWebM : Codec.aacADTS, + whenFinished: () {}) + .then((value) {}); + } + + void stopPlayer() { + mPlayer!.stopPlayer().then((value) {}); + } + + void pausePlayer() { + mPlayer!.pausePlayer().then((value) {}); + } + + void resumePlayer() { + mPlayer!.resumePlayer().then((value) {}); + } + void deleteRecordedFile() { - recordedFile!.delete(); - recordedFile = null; + path = null; notifyListeners(); update(); } Future startRecording() async { text = null; - await _recorder.hasPermission(); + // await _recorder.hasPermission(); if (!kIsWeb) { Vibrate.feedback(FeedbackType.medium); } - isRecording = true; - Directory? tempDir; - if (!kIsWeb) { - tempDir = await getApplicationDocumentsDirectory(); - } - _recorder.start(const RecordConfig(), - path: kIsWeb - ? '' - : '${tempDir!.path}/${DateTime.now().millisecondsSinceEpoch ~/ 1000}.m4a'); + record(); + notifyListeners(); } Future stopRecording({required bool sendImidiately}) async { - path = await _recorder.stop(); - isRecording = false; + path = await mRecorder!.stopRecorder(); + mplaybackReady = true; + _timer?.cancel(); + mPlayer!.setSubscriptionDuration(countTimer.value); + update(); if (path == null) { notifyListeners(); return; } - if (kIsWeb) { - final Uri audioUri = Uri.parse(path!); - final http.Response audioResponse = await http.get(audioUri); - recordedFileBytes = audioResponse.bodyBytes; - } else { - recordedFile = File(path!); - } + + // if (kIsWeb) { + // final Uri audioUri = Uri.parse(path!); + // final http.Response audioResponse = await http.get(audioUri); + // recordedFileBytes = audioResponse.bodyBytes; + // } else { + // recordedFile = File(path!); + // } if (sendImidiately) { await sendMessage(); } else { @@ -121,9 +233,8 @@ class DirectState extends CoreProvier { } Future sendMessage() async { - if ((text == null || text!.isEmpty) && - (recordedFile == null && recordedFileBytes == null)) return; - MediaService.audioPlayer.stop(); + if ((text == null || text!.isEmpty) && path == null) return; + VoiceService.audioPlayer.stop(); final body = {}; @@ -144,26 +255,18 @@ class DirectState extends CoreProvier { } if (path != null) { - final duration = await VoiceService.getDuration(src: path!); - if (duration != null) { - body.addAll({'duration': duration.inSeconds.toString()}); - } + body.addAll({'duration': countTimer.value.inSeconds.toString()}); } - final uploadFile = recordedFile; - final uploadFileBytes = recordedFileBytes; text = null; - recordedFile = null; - recordedFileBytes = null; replyRadar = null; replyNews = null; - path = null; notifyListeners(); final service = RequestService(RequestHelper.sendDirectMessage(typeId), body: body); - if (uploadFile == null && uploadFileBytes == null) { + if (path == null) { await service.post(); if (service.isSuccess) { @@ -177,57 +280,33 @@ class DirectState extends CoreProvier { } } } else { - messages.insert( - 0, - MessageData( - id: 0, - writedByAdmin: false, - readed: false, - createdAt: - DateTime.now().subtract(const Duration(minutes: 210)).toString(), - text: text, - // audio: path, - // audioFile: uploadFile, - radar: replyRadar, - news: replyNews, - audioDuration: audioDuration, - ), + final Uint8List uploadFile = kIsWeb + ? (await http.get(Uri.parse(path!))).bodyBytes + : await File(path!).readAsBytes(); + + path = null; + + await service.multipartBytes( + file: uploadFile, + method: 'POST', + fieldName: 'audio', + fileName: 'voice-message', + mediaExtension: 'm4a', + mediaFormat: 'audio', ); - dailyMessages.clear(); - - for (var i = 0; i < messages.length; i++) { - _addToDailyGrouped(messages[i]); - } - - if (kIsWeb) { - await service.multipartBytes( - file: uploadFileBytes!, - method: 'POST', - fieldName: 'audio', - fileName: 'voice-message', - mediaExtension: 'm4a', - mediaFormat: 'audio', - ); - } else { - await service.multipart( - file: Platform.isIOS - ? File(uploadFile!.path.replaceAll('file://', '')) - : uploadFile, - method: 'POST', - fieldName: 'audio', - fileName: 'voice-message', - mediaExtension: 'm4a', - mediaFormat: 'audio', - ); - } - if (service.isSuccess) { final message = service.result['message']; - final m = MessageData.fromJson(message); - messages[0] = m; - update(); + messages.insert(0, MessageData.fromJson(message)); + + dailyMessages.clear(); + + for (var i = 0; i < messages.length; i++) { + _addToDailyGrouped(messages[i]); + } + // update(); } } + notifyListeners(); } } diff --git a/lib/views/direct/widgets/message_box.dart b/lib/views/direct/widgets/message_box.dart index c0906f5..6e6cf19 100644 --- a/lib/views/direct/widgets/message_box.dart +++ b/lib/views/direct/widgets/message_box.dart @@ -62,11 +62,10 @@ class MessageBox extends StatelessWidget { isMessage: true, child: Consumer( builder: (context, state, child) { - if (state.isRecording) { + if (state.mRecorder!.isRecording) { return const _Recording(); - } else if (!state.isRecording && - (state.recordedFile != null || - state.recordedFileBytes != null)) { + } else if (!(state.mRecorder!.isRecording) && + state.path != null) { return const _RecordChecking(); } return const _Typing(); @@ -227,6 +226,7 @@ class _RecordChecking extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8), child: AudioWave( file: state.path!, + totalDuration: state.countTimer.value, // deleteClidk: () => state.deleteRecordedFile, ), ), diff --git a/lib/views/home/home.dart b/lib/views/home/home.dart index de1bd7a..3e6e124 100644 --- a/lib/views/home/home.dart +++ b/lib/views/home/home.dart @@ -19,7 +19,6 @@ import 'package:didvan/services/notification/notification_service.dart'; import 'package:didvan/utils/action_sheet.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'; @@ -618,13 +617,13 @@ class _HomeState extends State }, itemBuilder: (BuildContext context) { return [ - AiMessageBar.popUpBtns( + ActionSheetUtils.popUpBtns( value: 'حذف پیام', icon: DidvanIcons.trash_regular, color: Theme.of(context).colorScheme.error, height: 24, size: 12), - AiMessageBar.popUpBtns( + ActionSheetUtils.popUpBtns( value: 'آرشیو', icon: Icons.folder_copy, height: 24, diff --git a/lib/views/home/main/widgets/podcast_item.dart b/lib/views/home/main/widgets/podcast_item.dart index 597a9fd..ee829fe 100644 --- a/lib/views/home/main/widgets/podcast_item.dart +++ b/lib/views/home/main/widgets/podcast_item.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_web_libraries_in_flutter + import 'package:didvan/config/theme_data.dart'; import 'package:didvan/constants/app_icons.dart'; import 'package:didvan/models/home_page_content/content.dart'; @@ -10,9 +12,11 @@ import 'package:didvan/views/widgets/didvan/card.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; import 'package:didvan/views/widgets/skeleton_image.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart'; import 'package:persian_number_utility/persian_number_utility.dart'; import 'package:provider/provider.dart'; import 'package:didvan/providers/user.dart'; +import 'dart:html' as html; class MainPagePodcastItem extends StatefulWidget { final MainPageContentType content; @@ -24,6 +28,7 @@ class MainPagePodcastItem extends StatefulWidget { } class _MainPagePodcastItemState extends State { + bool loading = false; void _onMarkChange() { UserProvider.changeItemMark( widget.type, @@ -43,8 +48,18 @@ class _MainPagePodcastItemState extends State { args: const StudioRequestArgs(page: 0, type: 'podcast'), ); MediaService.currentPodcast = state.studio; + loading = true; + state.update(); + final response = + await get(Uri.parse(widget.content.link.replaceAll('%3A', ':'))); + final bytes = response.bodyBytes; + final blob = html.Blob([bytes]); + final blobUrl = html.Url.createObjectUrlFromBlob(blob); + await Future.delayed(const Duration(seconds: 3)); + loading = false; + state.update(); MediaService.handleAudioPlayback( - audioSource: widget.content.link, + audioSource: blobUrl, id: widget.content.id, isNetworkAudio: true, isVoiceMessage: false, @@ -97,42 +112,44 @@ class _MainPagePodcastItemState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DidvanText( - widget.content.title, - style: - Theme.of(context).textTheme.bodyLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Padding( - padding: - const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Icon( - DidvanIcons.calendar_day_light, - size: 16, - ), - const SizedBox(width: 4), - DidvanText( - DateTime.parse( - widget.content.subtitles[0]) - .toPersianDateStr(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodySmall, - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DidvanText( + widget.content.title, + style: + Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ], + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Icon( + DidvanIcons.calendar_day_light, + size: 16, + ), + const SizedBox(width: 4), + DidvanText( + DateTime.parse( + widget.content.subtitles[0]) + .toPersianDateStr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall, + ), + ], + ), + ), + ], + ), ), GestureDetector( onTap: _onMarkChange, diff --git a/lib/views/profile/profile.dart b/lib/views/profile/profile.dart index 58a24e5..3932a46 100644 --- a/lib/views/profile/profile.dart +++ b/lib/views/profile/profile.dart @@ -348,7 +348,7 @@ class _ProfilePageState extends State { ), const SizedBox(height: 16), DidvanText( - 'نسخه نرم‌افزار: 3.3.3', + 'نسخه نرم‌افزار: 3.3.4', style: Theme.of(context).textTheme.bodySmall, ), ], diff --git a/pubspec.lock b/pubspec.lock index 9144e01..0db76d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "2.11.0" audio_session: - dependency: transitive + dependency: "direct main" description: name: audio_session sha256: "343e83bc7809fbda2591a49e525d6b63213ade10c76f15813be9aed6657b3261" @@ -345,6 +345,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.12" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" fl_chart: dependency: "direct main" description: @@ -507,6 +515,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + flutter_sound: + dependency: "direct main" + description: + name: flutter_sound + sha256: "1f40b26b92907a433afe877c927cd48f5a2e4d0f7188e5d39eb5756008aa51ab" + url: "https://pub.dev" + source: hosted + version: "9.6.0" + flutter_sound_platform_interface: + dependency: "direct main" + description: + name: flutter_sound_platform_interface + sha256: "2e218521d8187b9a4c65063ad9c79bfe88119531ff68047a2eaa6b027cb276bb" + url: "https://pub.dev" + source: hosted + version: "9.6.0" + flutter_sound_web: + dependency: transitive + description: + name: flutter_sound_web + sha256: "5013a15e4e69a4bdc8badd729130eef43c3305e30ba8e6933f863436ce969932" + url: "https://pub.dev" + source: hosted + version: "9.6.0" flutter_spinkit: dependency: "direct main" description: @@ -741,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + logger: + dependency: transitive + description: + name: logger + sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" + url: "https://pub.dev" + source: hosted + version: "2.4.0" markdown: dependency: transitive description: @@ -989,62 +1029,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.2" - record: - dependency: "direct main" - description: - name: record - sha256: "4a5cf4d083d1ee49e0878823c4397d073f8eb0a775f31215d388e2bc47a9e867" - url: "https://pub.dev" - source: hosted - version: "5.1.2" - record_android: + recase: dependency: transitive description: - name: record_android - sha256: d7af0b3119725a0f561817c72b5f5eca4d7a76d441deef519ae04e4824c0734c + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 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: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" - url: "https://pub.dev" - source: hosted - version: "0.7.2" - record_platform_interface: - dependency: transitive - description: - name: record_platform_interface - sha256: "11f8b03ea8a0e279b0e306571dbe0db0202c0b8e866495c9fa1ad2281d5e4c15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - record_web: - dependency: transitive - description: - name: record_web - sha256: "0ef370d1e6553ad33c39dd03103b374e7861f3518b0533e64c94d73f988a5ffa" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - record_windows: - dependency: transitive - description: - name: record_windows - sha256: e653555aa3fda168aded7c34e11bd82baf0c6ac84e7624553def3c77ffefd36f - url: "https://pub.dev" - source: hosted - version: "1.0.3" + version: "4.1.0" rive: dependency: "direct main" description: @@ -1090,6 +1082,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: @@ -1270,10 +1270,10 @@ packages: dependency: transitive description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.5.1" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6ca54d9..b2f62bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.3.3+3330 +version: 3.3.4+3340 environment: sdk: ">=2.19.0 <3.0.0" @@ -51,7 +51,8 @@ dependencies: carousel_slider: ^4.0.0 flutter_vibrate: ^1.3.0 universal_html: ^2.0.8 - record: ^5.1.2 + # record: ^5.1.2 + persian_datetime_picker: ^2.6.0 persian_number_utility: ^1.1.1 bot_toast: ^4.0.1 @@ -82,7 +83,7 @@ dependencies: android_intent_plus: ^5.0.0 get: ^4.6.6 # firebase_auth: ^4.19.6 - just_audio: ^0.9.39 + just_audio: ^0.9.11 video_player: ^2.8.7 chewie: ^1.8.3 typewritertext: ^3.0.8 @@ -94,9 +95,14 @@ dependencies: flutter_cache_manager: any flutter_local_notifications: ^17.2.2 flutter_background_service: ^5.0.10 - js: ^0.6.7 + # js: ^0.6.7 + flutter_sound: ^9.6.0 + audio_session: ^0.1.21 + # url_launcher: ^6.3.0 + js: any + flutter_sound_platform_interface: any dev_dependencies: flutter_test: sdk: flutter diff --git a/web/index.html b/web/index.html index 4642943..bf5f328 100644 --- a/web/index.html +++ b/web/index.html @@ -40,7 +40,6 @@ Didvan -