781 lines
42 KiB
Dart
781 lines
42 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/utils/action_sheet.dart';
|
|
import 'package:didvan/utils/date_time.dart';
|
|
import 'package:didvan/views/ai/ai_chat_state.dart';
|
|
import 'package:didvan/views/ai/history_ai_chat_state.dart';
|
|
import 'package:didvan/views/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_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:persian_number_utility/persian_number_utility.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:record/record.dart';
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
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('');
|
|
bool openAttach = false;
|
|
|
|
@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: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 24).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(
|
|
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) {
|
|
state.file =
|
|
FilesModel(result.files.single.path!);
|
|
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)
|
|
: FilesModel(file!.path);
|
|
openAttach = false;
|
|
await Future.delayed(
|
|
Duration.zero,
|
|
() => ActionSheetUtils(context).pop(),
|
|
);
|
|
},
|
|
),
|
|
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) {
|
|
state.file =
|
|
FilesModel(result.files.single.path!);
|
|
openAttach = false;
|
|
}
|
|
await Future.delayed(
|
|
Duration.zero,
|
|
() => ActionSheetUtils(context).pop(),
|
|
);
|
|
},
|
|
)
|
|
],
|
|
),
|
|
)),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
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,
|
|
),
|
|
Expanded(
|
|
child: StreamBuilder<RecordState>(
|
|
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: (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(),
|
|
isRecorded: true);
|
|
_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 getApplicationDocumentsDirectory();
|
|
|
|
record.start(
|
|
const RecordConfig(),
|
|
path:
|
|
'${downloadDir.path}/${DateTime.now().millisecondsSinceEpoch ~/ 1000}.m4a');
|
|
startTimer();
|
|
}
|
|
},
|
|
)
|
|
: 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 ==
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
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(
|
|
3,
|
|
(index) =>
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
)),
|
|
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: 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();
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
InkWell attachBtn(
|
|
{required final String title,
|
|
required final IconData icon,
|
|
final Color? color,
|
|
final Function()? click}) {
|
|
final state = context.read<AiChatState>();
|
|
return InkWell(
|
|
onTap: () async {
|
|
await click?.call();
|
|
|
|
state.update();
|
|
},
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
|
padding: const EdgeInsets.all(0.8),
|
|
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Icon(
|
|
icon,
|
|
size: 20,
|
|
color: Theme.of(context).colorScheme.white,
|
|
)),
|
|
const SizedBox(
|
|
height: 4,
|
|
),
|
|
DidvanText(
|
|
title,
|
|
fontSize: 12,
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget audioContainer() {
|
|
final state = context.watch<AiChatState>();
|
|
|
|
return SizedBox(
|
|
width: MediaQuery.sizeOf(context).width,
|
|
child: AudioWave(
|
|
file: state.file!.path,
|
|
loadingPaddingSize: 8.0,
|
|
),
|
|
);
|
|
}
|
|
|
|
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 && !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: 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: 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,
|
|
);
|
|
})
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|