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

748 lines
40 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/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 {
FilePickerResult? result =
await MediaService.pickPdfFile();
if (result != null) {
state.file =
FilesModel(result.files.single.path!);
// Do something with the selected PDF file
}
},
),
if (historyState.bot!.attachmentType!
.contains('image'))
attachBtn(
title: "تصویر",
icon: CupertinoIcons.photo,
color: Colors.deepOrangeAccent,
click: () async {
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);
},
),
if (historyState.bot!.attachmentType!
.contains('audio'))
attachBtn(
title: "صوت",
icon: CupertinoIcons.music_note_2,
color: Colors.indigoAccent,
click: () async {
FilePickerResult? result =
await MediaService.pickAudioFile();
if (result != null) {
state.file =
FilesModel(result.files.single.path!);
}
},
)
],
),
)),
],
),
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 getDownloadsDirectory();
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();
setState(() {
openAttach = false;
});
},
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,
);
})
],
),
)
],
),
),
);
}
}