From 5ec13a7a525559539425ca171582f74c758a9924 Mon Sep 17 00:00:00 2001 From: OkaykOrhmn Date: Tue, 27 Aug 2024 10:23:29 +0330 Subject: [PATCH] ai chat version 1 --- lib/models/ai/bots_model.dart | 16 + lib/models/ai/chats_model.dart | 16 + lib/models/ai/messages_model.dart | 8 + lib/services/ai/ai_api_service.dart | 40 +- lib/services/media/media.dart | 10 + lib/views/ai/ai.dart | 17 + lib/views/ai/ai_chat_page.dart | 505 ++++++++++++++++----- lib/views/ai/ai_chat_state.dart | 62 ++- lib/views/widgets/marquee_text.dart | 49 ++ lib/views/widgets/overflow_proof_text.dart | 28 ++ pubspec.lock | 24 + pubspec.yaml | 2 + 12 files changed, 636 insertions(+), 141 deletions(-) create mode 100644 lib/models/ai/messages_model.dart create mode 100644 lib/views/ai/ai.dart create mode 100644 lib/views/widgets/marquee_text.dart create mode 100644 lib/views/widgets/overflow_proof_text.dart diff --git a/lib/models/ai/bots_model.dart b/lib/models/ai/bots_model.dart index 6a243ab..58026d5 100644 --- a/lib/models/ai/bots_model.dart +++ b/lib/models/ai/bots_model.dart @@ -2,6 +2,9 @@ class BotsModel { int? id; String? name; String? image; + String? description; + List? attachmentType; + int? attachment; BotsModel({this.id, this.name, this.image}); @@ -9,6 +12,14 @@ class BotsModel { id = json['id']; name = json['name']; image = json['image']; + description = json['description']; + if (json['attachmentType'] != null) { + attachmentType = []; + json['attachmentType'].forEach((v) { + attachmentType!.add(v); + }); + } + attachment = json['attachment']; } Map toJson() { @@ -16,6 +27,11 @@ class BotsModel { data['id'] = id; data['name'] = name; data['image'] = image; + data['description'] = description; + if (attachmentType != null) { + data['attachmentType'] = attachmentType!.map((v) => v).toList(); + } + data['attachment'] = attachment; return data; } } diff --git a/lib/models/ai/chats_model.dart b/lib/models/ai/chats_model.dart index 20282f4..02e71a1 100644 --- a/lib/models/ai/chats_model.dart +++ b/lib/models/ai/chats_model.dart @@ -5,6 +5,7 @@ class ChatsModel { int? userId; int? botId; String? title; + String? placeholder; String? createdAt; String? updatedAt; BotsModel? bot; @@ -15,6 +16,7 @@ class ChatsModel { this.userId, this.botId, this.title, + this.placeholder, this.createdAt, this.updatedAt, this.bot, @@ -25,6 +27,7 @@ class ChatsModel { userId = json['userId']; botId = json['botId']; title = json['title']; + placeholder = json['placeholder']; createdAt = json['createdAt']; updatedAt = json['updatedAt']; bot = json['bot'] != null ? BotsModel.fromJson(json['bot']) : null; @@ -42,6 +45,7 @@ class ChatsModel { data['userId'] = userId; data['botId'] = botId; data['title'] = title; + data['placeholder'] = placeholder; data['createdAt'] = createdAt; data['updatedAt'] = updatedAt; if (bot != null) { @@ -58,6 +62,8 @@ class Prompts { int? id; int? chatId; String? text; + String? file; + String? fileName; String? role; String? createdAt; bool? finished; @@ -66,6 +72,8 @@ class Prompts { {this.id, this.chatId, this.text, + this.file, + this.fileName, this.role, this.createdAt, this.finished}); @@ -74,6 +82,8 @@ class Prompts { id = json['id']; chatId = json['chatId']; text = json['text']; + file = json['file']; + fileName = json['fileName']; role = json['role']; createdAt = json['createdAt']; } @@ -83,6 +93,8 @@ class Prompts { data['id'] = id; data['chatId'] = chatId; data['text'] = text; + data['file'] = file; + data['fileName'] = fileName; data['role'] = role; data['createdAt'] = createdAt; return data; @@ -92,6 +104,8 @@ class Prompts { int? id, int? chatId, String? text, + String? file, + String? fileName, String? role, String? createdAt, bool? finished, @@ -100,6 +114,8 @@ class Prompts { id: id ?? this.id, chatId: chatId ?? this.chatId, text: text ?? this.text, + file: file ?? this.file, + fileName: fileName ?? this.fileName, role: role ?? this.role, createdAt: createdAt ?? this.createdAt, finished: finished ?? this.finished, diff --git a/lib/models/ai/messages_model.dart b/lib/models/ai/messages_model.dart new file mode 100644 index 0000000..27d658a --- /dev/null +++ b/lib/models/ai/messages_model.dart @@ -0,0 +1,8 @@ +import 'package:didvan/models/ai/chats_model.dart'; + +class MessageModel { + final String dateTime; + final List prompts; + + MessageModel({required this.dateTime, required this.prompts}); +} diff --git a/lib/services/ai/ai_api_service.dart b/lib/services/ai/ai_api_service.dart index ede12f2..47508a8 100644 --- a/lib/services/ai/ai_api_service.dart +++ b/lib/services/ai/ai_api_service.dart @@ -1,37 +1,55 @@ +// ignore_for_file: depend_on_referenced_packages + import 'dart:async'; -import 'dart:convert'; +import 'dart:io'; import 'package:didvan/services/storage/storage.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; +import 'package:path/path.dart' as p; +import 'package:http_parser/http_parser.dart' as parser; class AiApiService { static const String baseUrl = 'https://api.didvan.app/ai'; static final _client = http.Client(); - static Future initial( + static Future initial( {required final String url, required final String message, - final int? chatId}) async { + final int? chatId, + final File? file}) async { final headers = { - "Content-Type": "application/json", - "Authorization": "Bearer ${await StorageService.getValue(key: 'token')}" + "Authorization": "Bearer ${await StorageService.getValue(key: 'token')}", + 'Content-Type': 'multipart/form-data' }; - var request = http.Request('POST', Uri.parse(baseUrl + url)) + var request = http.MultipartRequest('POST', Uri.parse(baseUrl + url)) ..headers.addAll(headers); - var body = {'prompt': message}; + request.fields['prompt'] = message; if (chatId != null) { - body.addAll({'chatId': chatId.toString()}); + request.fields['chatId'] = chatId.toString(); + } + if (file != null) { + final length = await file.length(); + String basename = p.basename(file.path); + String mediaExtension = p.extension(file.path).replaceAll('.', ''); + String mediaFormat = 'image'; + if (mediaExtension.contains('pdf')) { + mediaFormat = 'application'; + } + request.files.add( + http.MultipartFile('file', file.readAsBytes().asStream(), length, + filename: basename, + contentType: parser.MediaType(mediaFormat, mediaExtension)), + ); } - request.body = jsonEncode(body); return request; } - static Future getResponse(Request request) async { + static Future getResponse(http.MultipartRequest request) async { final res = _client.send(request).timeout( - const Duration(seconds: 15), + const Duration(seconds: 30), ); final http.StreamedResponse response = await res; return response.stream; diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index b143cb4..fcb9f96 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -7,6 +7,7 @@ import 'package:didvan/services/storage/storage.dart'; import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:file_picker/file_picker.dart'; class MediaService { static final audioPlayer = AudioPlayer(); @@ -99,4 +100,13 @@ class MediaService { final XFile? pickedFile = await imagePicker.pickImage(source: source); return pickedFile; } + + static Future pickPdfFile() async { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf'], + allowMultiple: false, + ); + return result; + } } diff --git a/lib/views/ai/ai.dart b/lib/views/ai/ai.dart new file mode 100644 index 0000000..8b017c1 --- /dev/null +++ b/lib/views/ai/ai.dart @@ -0,0 +1,17 @@ +// ignore_for_file: library_private_types_in_public_api + +import 'package:flutter/material.dart'; + +class Ai extends StatefulWidget { + const Ai({Key? key}) : super(key: key); + + @override + _AiState createState() => _AiState(); +} + +class _AiState extends State { + @override + Widget build(BuildContext context) { + return Container(); + } +} diff --git a/lib/views/ai/ai_chat_page.dart b/lib/views/ai/ai_chat_page.dart index 8814568..aa38c53 100644 --- a/lib/views/ai/ai_chat_page.dart +++ b/lib/views/ai/ai_chat_page.dart @@ -1,4 +1,6 @@ -// ignore_for_file: library_private_types_in_public_api, deprecated_member_use +// ignore_for_file: library_private_types_in_public_api, deprecated_member_use, depend_on_referenced_packages + +import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:didvan/config/design_config.dart'; @@ -8,20 +10,30 @@ import 'package:didvan/constants/assets.dart'; import 'package:didvan/main.dart'; import 'package:didvan/models/ai/ai_chat_args.dart'; import 'package:didvan/models/ai/chats_model.dart'; +import 'package:didvan/models/ai/messages_model.dart'; import 'package:didvan/models/enums.dart'; import 'package:didvan/models/view/alert_data.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/widgets/animated_visibility.dart'; 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/state_handlers/empty_state.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:persian_number_utility/persian_number_utility.dart'; import 'package:provider/provider.dart'; +import 'package:path/path.dart' as p; class AiChatPage extends StatefulWidget { final AiChatArgs args; @@ -81,10 +93,8 @@ class _AiChatPageState extends State { ], ), body: Consumer( - builder: (BuildContext context, AiChatState state, Widget? child) { - return ValueListenableBuilder( - valueListenable: state.loading, - builder: (context, value, child) => value + builder: (BuildContext context, AiChatState state, Widget? child) => + state.loading ? Center( child: Image.asset( Assets.loadingAnimation, @@ -101,114 +111,350 @@ class _AiChatPageState extends State { ) : SingleChildScrollView( reverse: true, + controller: state.scrollController, child: ListView.builder( - itemCount: state.messages.length, - controller: state.scrollController, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 12, bottom: 90), - itemBuilder: (context, index) { - final message = state.messages[index]; - return messageBubble( - message, context, state, index); - }, + itemCount: state.messages.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 90), + itemBuilder: (context, mIndex) { + final prompts = state.messages[mIndex].prompts; + final time = state.messages[mIndex].dateTime; + return Column( + children: [ + timeLabel(context, time), + ListView.builder( + itemCount: prompts.length, + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final message = prompts[index]; + return messageBubble(message, context, + state, index, mIndex); + }, + ), + ], + ); + }), + ), + ), + bottomSheet: Consumer( + builder: (BuildContext context, AiChatState state, Widget? child) { + return Container( + width: MediaQuery.sizeOf(context).width, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.cardBorder, + ), + ), + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.args.bot.attachment! == 2) fileContainer(context), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox( + width: 12, + ), + SizedBox( + width: 46, + height: 46, + child: Center( + child: state.onResponsing + ? Center( + child: SpinKitThreeBounce( + size: 18, + color: Theme.of(context) + .colorScheme + .focusedBorder, + ), + ) + : DidvanIconButton( + icon: DidvanIcons.send_solid, + size: 32, + color: Theme.of(context) + .colorScheme + .focusedBorder, + onPressed: () async { + if (state.file == null && + 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( + text: message.text, + file: p.basename(state.file!.path), + fileName: 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( + text: message.text, + finished: true, + file: + p.basename(state.file!.path), + fileName: + p.basename(state.file!.path), + role: 'user', + createdAt: DateTime.now() + .subtract(const Duration( + minutes: 210)) + .toIso8601String(), + ) + ])); + } + + await state.postMessage(widget.args.bot); + message.clear(); + state.file = null; + }, + ), + ), + ), + const SizedBox( + width: 12, + ), + Expanded( + child: Form( + child: widget.args.bot.attachment! != 1 + ? TextFormField( + textInputAction: TextInputAction.newline, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 6, + minLines: 1, + // keyboardType: TextInputType.text, + controller: message, + enabled: !state.onResponsing, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'بنویسید...', + hintStyle: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .disabledText), + ), + onChanged: (value) {}, + ) + : fileContainer(context), + ), + ), + const SizedBox( + width: 12, + ), + if (widget.args.bot.attachment! != 0) + SizedBox( + width: 46, + height: 46, + child: Center( + child: PopupMenuButton( + onSelected: (value) async { + switch (value) { + case 'Pdf': + FilePickerResult? result = + await MediaService.pickPdfFile(); + if (result != null) { + final File file = + File(result.files.single.path!); + state.file = file; + // 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 ? File(pickedFile.path) : file; + + break; + default: + } + + state.update(); + }, + itemBuilder: (BuildContext context) => + [ + popUpBtns(value: 'Pdf'), + popUpBtns(value: 'Image'), + ], + offset: const Offset(0, -140), + position: PopupMenuPosition.over, + child: Icon( + Icons.attach_file_rounded, + color: + Theme.of(context).colorScheme.focusedBorder, + ), + ), ), ), - ); - }, - ), - bottomSheet: Container( - width: MediaQuery.sizeOf(context).width, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.cardBorder, - ), - ), - color: Theme.of(context).colorScheme.surface, - ), - child: ValueListenableBuilder( - valueListenable: context.read().onResponsing, - builder: (context, value, child) => Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox( - width: 12, - ), - SizedBox( - width: 46, - height: 46, - child: Center( - child: value - ? Center( - child: SpinKitThreeBounce( - size: 18, - color: Theme.of(context).colorScheme.primary, - ), - ) - : DidvanIconButton( - icon: DidvanIcons.send_solid, - onPressed: () async { - if (message.text.isEmpty) { - return; - } + ], + ), + ], + )); + }), + ), + ); + } - final state = context.read(); - state.messages.add(Prompts( - text: message.text, - finished: true, - role: 'user', - createdAt: DateTime.now() - .subtract(const Duration(minutes: 210)) - .toIso8601String(), - )); - message.clear(); - await state.postMessage(widget.args.bot); - }, - size: 32, - color: Theme.of(context).colorScheme.focusedBorder, - ), - ), - ), + AnimatedVisibility fileContainer(BuildContext context) { + final state = context.read(); + String basename = ''; + if (state.file != null) { + basename = p.basename(state.file!.path); + } + return AnimatedVisibility( + isVisible: state.file != null, + 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: widget.args.bot.attachment! != 1 + ? const EdgeInsets.symmetric(horizontal: 12) + : EdgeInsets.zero, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.file_copy), const SizedBox( width: 12, ), - Expanded( - flex: 15, - child: Form( - child: TextFormField( - textInputAction: TextInputAction.newline, - style: Theme.of(context).textTheme.bodyMedium, - maxLines: 6, - minLines: 1, - // keyboardType: TextInputType.text, - controller: message, - enabled: !value, - decoration: InputDecoration( - border: InputBorder.none, - hintText: 'بنویسید...', - hintStyle: Theme.of(context) - .textTheme - .bodySmall! - .copyWith( - color: - Theme.of(context).colorScheme.disabledText), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 160, + height: 24, + child: MarqueeText( + text: basename, + style: const TextStyle(fontSize: 14), + stop: const Duration(seconds: 3), ), - onChanged: (value) {}, ), - ), - ), + if (state.file != null) + FutureBuilder( + future: state.file!.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)) + ], ), ), ); } - Padding messageBubble( - Prompts message, BuildContext context, AiChatState state, int index) { + PopupMenuItem popUpBtns({required final String value}) { + return PopupMenuItem( + value: value, + height: 46, + child: Row( + children: [ + const Icon(Icons.picture_as_pdf_rounded), + const SizedBox( + width: 12, + ), + DidvanText( + value, + ), + ], + ), + ); + } + + Center timeLabel(BuildContext context, String time) { + return Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.splash, + borderRadius: DesignConfig.lowBorderRadius, + ), + child: DidvanText( + DateTime.parse(time).toPersianDateStr(), + style: Theme.of(context).textTheme.labelSmall, + color: DesignConfig.isDark + ? Theme.of(context).colorScheme.white + : Theme.of(context).colorScheme.black, + ), + ), + ); + } + + Padding messageBubble(Prompts message, BuildContext context, + AiChatState state, int index, int mIndex) { MarkdownStyleSheet defaultMarkdownStyleSheet = MarkdownStyleSheet( code: TextStyle( backgroundColor: Theme.of(context).colorScheme.black, @@ -255,8 +501,7 @@ class _AiChatPageState extends State { child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width / 1.5), - child: state.messages[index].finished != null && - !state.messages[index].finished! + child: message.finished != null && !message.finished! ? StreamBuilder( stream: state.messageOnstream, builder: (context, snapshot) { @@ -273,13 +518,58 @@ class _AiChatPageState extends State { ) : Column( children: [ - Markdown( - data: state.messages[index].text.toString(), - selectable: true, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - styleSheet: defaultMarkdownStyleSheet, - ), + if (message.role.toString().contains('user') && + message.file != null) + Container( + decoration: BoxDecoration( + borderRadius: DesignConfig.mediumBorderRadius, + color: Theme.of(context).colorScheme.border, + ), + padding: + const EdgeInsets.fromLTRB(12, 8, 12, 8), + margin: widget.args.bot.attachment! != 1 + ? const EdgeInsets.symmetric(horizontal: 12) + : EdgeInsets.zero, + child: Row( + children: [ + const Icon(Icons.file_copy), + const SizedBox( + width: 12, + ), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: DidvanText( + (message.fileName.toString())), + ), + if (state.file != null) + FutureBuilder( + future: state.file!.length(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return DidvanText( + 'File Size ${(snapshot.data! / 1000).round()} KB', + fontSize: 12, + ); + }) + ], + ) + ], + ), + ), + if (message.text != null) + Markdown( + data: message.text.toString(), + selectable: true, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + styleSheet: defaultMarkdownStyleSheet, + ), if (!message.role.toString().contains('user')) Row( mainAxisAlignment: MainAxisAlignment.end, @@ -289,7 +579,8 @@ class _AiChatPageState extends State { child: InkWell( onTap: () async { await Clipboard.setData(ClipboardData( - text: state.messages[index].text + text: state.messages[mIndex] + .prompts[index].text .toString())); ActionSheetUtils.showAlert(AlertData( message: "متن با موفقیت کپی شد", diff --git a/lib/views/ai/ai_chat_state.dart b/lib/views/ai/ai_chat_state.dart index 34726d5..73b2fb5 100644 --- a/lib/views/ai/ai_chat_state.dart +++ b/lib/views/ai/ai_chat_state.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:io'; import 'package:didvan/models/ai/bots_model.dart'; import 'package:didvan/models/ai/chats_model.dart'; +import 'package:didvan/models/ai/messages_model.dart'; import 'package:didvan/models/enums.dart'; import 'package:didvan/models/view/alert_data.dart'; import 'package:didvan/providers/core.dart'; @@ -11,14 +13,16 @@ import 'package:didvan/services/network/request_helper.dart'; import 'package:didvan/utils/action_sheet.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:persian_number_utility/persian_number_utility.dart'; class AiChatState extends CoreProvier { Stream messageOnstream = const Stream.empty(); - List messages = []; - ValueNotifier onResponsing = ValueNotifier(false); - ValueNotifier loading = ValueNotifier(false); + List messages = []; + bool onResponsing = false; + bool loading = false; final ScrollController scrollController = ScrollController(); int? chatId; + File? file; Future _scrolledEnd() async { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -31,9 +35,9 @@ class AiChatState extends CoreProvier { } Future _onError(e) async { - onResponsing.value = false; - messages.removeLast(); - messages.removeLast(); + onResponsing = false; + messages.last.prompts.removeLast(); + messages.last.prompts.removeLast(); messageOnstream = const Stream.empty(); await ActionSheetUtils.showAlert(AlertData( @@ -55,22 +59,33 @@ class AiChatState extends CoreProvier { } Future getAllMessages(int chatId) async { - loading.value = true; - onResponsing.value = true; + loading = true; + onResponsing = true; final service = RequestService( RequestHelper.aiAChat(chatId), ); await service.httpGet(); if (service.isSuccess) { messages.clear(); - final allMessages = service.result['prompts']; - for (var i = 0; i < allMessages.length; i++) { - messages.add(Prompts.fromJson(allMessages[i])); + final ChatsModel allMessages = + ChatsModel.fromJson(service.result['chat']); + for (var i = 0; i < allMessages.prompts!.length; i++) { + final prompt = allMessages.prompts![i]; + if (messages.isNotEmpty && + DateTime.parse(messages.last.prompts.last.createdAt.toString()) + .toPersianDateStr() + .contains(DateTime.parse(prompt.createdAt.toString()) + .toPersianDateStr())) { + messages.last.prompts.add(prompt); + } else { + messages.add(MessageModel( + dateTime: prompt.createdAt.toString(), prompts: [prompt])); + } // chats.add("chats: $chats"); } appState = AppState.idle; - loading.value = false; - onResponsing.value = false; + loading = false; + onResponsing = false; // Add this code to scroll to maxScrollExtent after the ListView is built update(); @@ -78,30 +93,32 @@ class AiChatState extends CoreProvier { return; } appState = AppState.failed; - loading.value = false; - onResponsing.value = false; + loading = false; + onResponsing = false; update(); } Future postMessage(BotsModel bot) async { - onResponsing.value = true; + onResponsing = true; update(); - await _scrolledEnd(); - String message = messages.last.text!; + String message = messages.last.prompts.last.text!; - messages.add(Prompts( + messages.last.prompts.add(Prompts( finished: false, text: '...', role: 'bot', createdAt: DateTime.now() .subtract(const Duration(minutes: 210)) .toIso8601String())); + update(); + await _scrolledEnd(); final req = await AiApiService.initial( url: '/${bot.id}/${bot.name}'.toLowerCase(), message: message, - chatId: chatId); + chatId: chatId, + file: file); final res = await AiApiService.getResponse(req).catchError((e) { _onError(e); throw e; @@ -113,7 +130,6 @@ class AiChatState extends CoreProvier { messageOnstream = Stream.value(responseMessgae); update(); - _scrolledEnd(); }); r.onDone(() async { @@ -124,8 +140,8 @@ class AiChatState extends CoreProvier { return; } } - onResponsing.value = false; - messages.last = messages.last.copyWith( + onResponsing = false; + messages.last.prompts.last = messages.last.prompts.last.copyWith( finished: true, text: responseMessgae, ); diff --git a/lib/views/widgets/marquee_text.dart b/lib/views/widgets/marquee_text.dart new file mode 100644 index 0000000..6817e1b --- /dev/null +++ b/lib/views/widgets/marquee_text.dart @@ -0,0 +1,49 @@ +import 'package:didvan/views/widgets/overflow_proof_text.dart'; +import 'package:flutter/widgets.dart'; +import 'package:marquee/marquee.dart'; + +class MarqueeText extends StatelessWidget { + final String text; + final TextStyle style; + final String? marqueeText; + final TextStyle? marqueeStyle; + final Duration stop; + final TextDirection textDirection; + const MarqueeText( + {super.key, + required this.text, + required this.style, + this.marqueeText, + this.marqueeStyle, + this.stop = Duration.zero, + this.textDirection = TextDirection.ltr}); + + @override + Widget build(BuildContext context) { + return OverflowProofText( + text: Text( + text, + style: style, + maxLines: 1, + ), + fallback: SizedBox( + height: (marqueeStyle == null + ? style.fontSize! + : marqueeStyle!.fontSize!) * + DefaultTextStyle.of(context).style.height!, + child: Center( + child: Marquee( + text: marqueeText ?? text, + scrollAxis: Axis.horizontal, + textDirection: textDirection, + crossAxisAlignment: CrossAxisAlignment.center, + accelerationCurve: Curves.easeOut, + velocity: 30, + blankSpace: 34.0, + accelerationDuration: stop, + style: marqueeStyle ?? style, + ), + ), + )); + } +} diff --git a/lib/views/widgets/overflow_proof_text.dart b/lib/views/widgets/overflow_proof_text.dart new file mode 100644 index 0000000..544606f --- /dev/null +++ b/lib/views/widgets/overflow_proof_text.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; + +class OverflowProofText extends StatelessWidget { + const OverflowProofText( + {super.key, required this.text, required this.fallback}); + + final Text text; + final Widget fallback; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: + LayoutBuilder(builder: (BuildContext context, BoxConstraints size) { + final TextPainter painter = TextPainter( + maxLines: 1, + textAlign: TextAlign.left, + textDirection: TextDirection.ltr, + text: TextSpan(text: text.data), + ); + + painter.layout(maxWidth: size.maxWidth); + + return painter.didExceedMaxLines ? fallback : text; + })); + } +} diff --git a/pubspec.lock b/pubspec.lock index d51cec8..259678e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1+1" + fading_edge_scrollview: + dependency: transitive + description: + name: fading_edge_scrollview + sha256: c25c2231652ce774cc31824d0112f11f653881f43d7f5302c05af11942052031 + url: "https://pub.dev" + source: hosted + version: "3.0.0" fake_async: dependency: transitive description: @@ -249,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "2ca051989f69d1b2ca012b2cf3ccf78c70d40144f0861ff2c063493f7c8c3d45" + url: "https://pub.dev" + source: hosted + version: "8.0.5" file_selector_linux: dependency: transitive description: @@ -677,6 +693,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.2" + marquee: + dependency: "direct main" + description: + name: marquee + sha256: "4b5243d2804373bdc25fc93d42c3b402d6ec1f4ee8d0bb72276edd04ae7addb8" + url: "https://pub.dev" + source: hosted + version: "2.2.3" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5b0594a..08e41ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -88,6 +88,8 @@ dependencies: chewie: ^1.8.3 typewritertext: ^3.0.8 flutter_markdown: ^0.7.3+1 + file_picker: ^8.0.5 + marquee: ^2.2.3 # onesignal_flutter: ^3.5.0 dev_dependencies: