683 lines
36 KiB
Dart
683 lines
36 KiB
Dart
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/network/request.dart';
|
|
import 'package:didvan/services/network/request_helper.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<AiMessageBar> createState() => _AiMessageBarState();
|
|
|
|
static PopupMenuItem<dynamic> 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<AiMessageBar> {
|
|
final ValueNotifier<String> messageText = ValueNotifier('');
|
|
|
|
@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<int> _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<AiChatState>(
|
|
builder: (context, state, child) {
|
|
final historyState = context.read<HistoryAiChatState>();
|
|
|
|
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.surface,
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.border),
|
|
borderRadius: BorderRadius.circular(360)),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 8,
|
|
),
|
|
Expanded(
|
|
child: StreamBuilder<RecordState>(
|
|
stream: record.onStateChanged(),
|
|
builder: (context, snapshot) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: messageText,
|
|
builder: (context, value, child) {
|
|
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') &&
|
|
value.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:
|
|
value.isNotEmpty,
|
|
icon: DidvanIcons
|
|
.send_light,
|
|
click: () async {
|
|
if (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 ==
|
|
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: state
|
|
.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(),
|
|
)
|
|
]));
|
|
}
|
|
state.message
|
|
.clear();
|
|
messageText.value =
|
|
state.message
|
|
.text;
|
|
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:
|
|
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)
|
|
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,
|
|
),
|
|
ValueListenableBuilder(
|
|
valueListenable: messageText,
|
|
builder: (context, value, child) {
|
|
if (context
|
|
.read<HistoryAiChatState>()
|
|
.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: PopupMenuButton(
|
|
onOpened: () {},
|
|
surfaceTintColor: Colors.transparent,
|
|
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(-20,
|
|
widget.focusNode!.hasFocus ? -999 : 999),
|
|
position: PopupMenuPosition.over,
|
|
useRootNavigator: true,
|
|
child: Icon(
|
|
Icons.attach_file_rounded,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.focusedBorder,
|
|
),
|
|
),
|
|
));
|
|
}
|
|
return const SizedBox();
|
|
}),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
AnimatedVisibility audioContainer() {
|
|
final state = context.watch<AiChatState>();
|
|
|
|
return AnimatedVisibility(
|
|
isVisible: state.file != null &&
|
|
lookupMimeType(state.file!.path)!.startsWith('audio/'),
|
|
duration: DesignConfig.lowAnimationDuration,
|
|
child: 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.surface,
|
|
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')
|
|
? '${RequestHelper.baseUrl + state.file!.path}?accessToken=${RequestService.token}'
|
|
: 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,
|
|
trashClick: () async {
|
|
state.file = null;
|
|
state.update();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
AnimatedVisibility fileContainer() {
|
|
final state = context.watch<AiChatState>();
|
|
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))
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|