didvan-app/lib/views/ai/widgets/ai_message_bar.dart

766 lines
27 KiB
Dart

// ignore_for_file: library_private_types_in_public_api, avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:async';
import 'package:didvan/utils/extension.dart';
import 'package:record/record.dart';
import 'package:universal_html/html.dart' 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';
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/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/widgets/audio_wave.dart';
import 'package:didvan/views/ai/widgets/message_bar_btn.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/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:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:persian_number_utility/persian_number_utility.dart';
import 'package:provider/provider.dart';
typedef _Fn = void Function();
class AiMessageBar extends StatefulWidget {
final BotsModel bot;
final String? assistantsName;
final bool? attch;
const AiMessageBar(
{Key? key, required this.bot, this.attch, this.assistantsName})
: super(key: key);
@override
_AiMessageBarState createState() => _AiMessageBarState();
}
class _AiMessageBarState extends State<AiMessageBar> {
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;
late bool openAttach = widget.attch ?? false;
Timer? _timer;
final theSource = AudioSource.microphone;
final ValueNotifier<Duration> _countTimer = ValueNotifier(Duration.zero);
@override
void initState() {
_mPlayer!.openPlayer().then((value) {
setState(() {
_mPlayerIsInited = true;
});
});
openTheRecorder().then((value) {
setState(() {
_mRecorderIsInited = true;
});
});
super.initState();
}
@override
void dispose() {
_mPlayer!.closePlayer();
_mPlayer = null;
_mRecorder!.closeRecorder();
_mRecorder = null;
_timer?.cancel();
super.dispose();
}
Future<void> openTheRecorder() async {
if (!kIsWeb) {
var status = await Permission.microphone.status;
await AudioRecorder().hasPermission();
if (status != PermissionStatus.granted) {
if (!Platform.isIOS) {
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 = 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<AiChatState>();
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,
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<AiChatState>(builder: (context, state, child) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 24, 12, 0).copyWith(
top: (state.file != null && !state.file!.isRecorded) ? 0 : 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
),
child: Column(
children: [
if (state.file != null && !(state.file!.isRecorded))
fileContainer(),
Stack(
children: [
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: [
recorderAndSendButton(state),
if (!(_mRecorder!.isStopped))
ValueListenableBuilder(
valueListenable: _countTimer,
builder: (context, value, child) => Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: SizedBox(
width: 50,
child: Center(
child: DidvanText(
DateTimeUtils.normalizeTimeDuration(value)),
),
),
),
),
Expanded(
child: Padding(
padding:
_mRecorder!.isPaused || _mRecorder!.isRecording
? const EdgeInsets.fromLTRB(12, 8, 0, 8)
: EdgeInsets.zero,
child: recorderAndTextMessageHandler(context, state),
),
),
],
),
),
if (state.onResponsing)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.focused
.withOpacity(0.5)),
),
)
],
),
MediaQuery.of(context).viewInsets.bottom == 0
? const Padding(
padding: EdgeInsets.fromLTRB(8, 8, 8, 4),
child: DidvanText(
'مدل‌های هوش مصنوعی می‌توانند اشتباه کنند، صحت اطلاعات مهم را بررسی کنید.',
fontSize: 12,
),
)
: const SizedBox(
height: 12,
)
],
),
);
});
}
Widget recorderAndTextMessageHandler(
BuildContext context, AiChatState state) {
return _mRecorder!.isPaused
? Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
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
? Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
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();
});
},
)
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: state.file != null && state.file!.isRecorded
? audioContainer()
: ValueListenableBuilder(
valueListenable: state.message,
builder: (context, message, child) {
return Directionality(
textDirection:
message.text.toString().startsWithEnglish()
? TextDirection.ltr
: TextDirection.rtl,
child: edittext(context, state),
);
}),
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(children: [
attachmentLayout(state, context),
if (widget.bot.attachmentType!.isNotEmpty)
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();
},
),
)
]),
)
],
);
}
TextFormField edittext(BuildContext context, AiChatState state) {
return 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(
contentPadding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
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),
)
: null,
),
);
}
recorderAndSendButton(AiChatState state) {
return ValueListenableBuilder(
valueListenable: state.message,
builder: (context, message, child) => Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: message.text.isEmpty &&
widget.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) ||
message.text.isNotEmpty,
icon: DidvanIcons.send_light,
click: () async {
if ((state.file == null || !state.file!.isRecorded) &&
(widget.bot.attachment != 1) &&
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.replaceAll(r"\n", r" \n \n "),
// 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: message.text.replaceAll(r"\n", r" \n \n "),
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, widget.assistantsName != null);
},
),
),
);
}
AnimatedVisibility attachmentLayout(AiChatState state, BuildContext context) {
return AnimatedVisibility(
isVisible: openAttach,
fadeMode: FadeMode.horizontal,
duration: DesignConfig.lowAnimationDuration,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.bot.attachmentType!.contains('pdf'))
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MessageBarBtn(
enable: true,
icon: CupertinoIcons.doc_fill,
click: () async {
MediaService.onLoadingPickFile(context);
FilePickerResult? result = await MediaService.pickPdfFile();
if (result != null) {
String? name = result.files.single.name;
if (kIsWeb) {
Uint8List? bytes = result
.files.first.bytes; // Access the bytes property
// 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, name: name);
}
}
Future.delayed(
Duration.zero,
() => ActionSheetUtils(context).pop(),
);
openAttach = !openAttach;
state.update();
},
),
),
if (widget.bot.attachmentType!.contains('image'))
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MessageBarBtn(
enable: true,
icon: CupertinoIcons.photo,
click: () async {
MediaService.onLoadingPickFile(context);
final pickedFile = await MediaService.pickImage(
source: ImageSource.gallery);
CroppedFile? file;
if (pickedFile != null && !kIsWeb) {
file = await ImageCropper().cropImage(
sourcePath: pickedFile.path,
uiSettings: [
AndroidUiSettings(toolbarTitle: 'برش تصویر'),
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,
name: pickedFile.name, image: true, audio: false);
await Future.delayed(
Duration.zero,
() => ActionSheetUtils(context).pop(),
);
openAttach = !openAttach;
state.update();
},
),
),
// if (!kIsWeb && !Platform.isIOS)
if (widget.bot.attachmentType!.contains('audio'))
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MessageBarBtn(
enable: true,
icon: CupertinoIcons.music_note_2,
click: () async {
MediaService.onLoadingPickFile(context);
FilePickerResult? result =
await MediaService.pickAudioFile();
if (result != null) {
String? name = result.files.first.name;
if (kIsWeb) {
Uint8List? bytes = result
.files.first.bytes; // Access the bytes property
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!,
name: name, audio: true, image: false);
}
}
await Future.delayed(
Duration.zero,
() => ActionSheetUtils(context).pop(),
);
openAttach = !openAttach;
state.update();
},
),
)
],
));
}
Widget audioContainer() {
final state = context.watch<AiChatState>();
return SizedBox(
width: MediaQuery.sizeOf(context).width,
child: AudioWave(
file: state.file!.path,
loadingPaddingSize: 8.0,
totalDuration: _countTimer.value,
),
);
}
Widget fileContainer() {
final state = context.watch<AiChatState>();
return Container(
decoration: BoxDecoration(
borderRadius: DesignConfig.mediumBorderRadius,
color: Theme.of(context).colorScheme.border,
),
margin: const EdgeInsets.fromLTRB(4, 4, 4, 8),
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!.name ?? '' : '',
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,
);
})
],
),
)
],
),
);
}
}