From 8cc76265b1dad347d432d98c0a4bcd9e85df2553 Mon Sep 17 00:00:00 2001 From: WaseemAbbasi22 <50428976+WaseemAbbasi22@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:19:56 +0300 Subject: [PATCH] copilot added --- lib/controllers/api_routes/api_manager.dart | 43 ++ lib/controllers/api_routes/urls.dart | 15 +- lib/main.dart | 2 + .../copilot/controller/copilot_provider.dart | 257 ++++++++++++ .../copilot/model/copilot_message_model.dart | 137 +++++++ .../copilot/screens/copilot_chat_page.dart | 367 ++++++++++++++++++ .../copilot/screens/copilot_history_page.dart | 129 ++++++ .../widgets/copilot_message_bubble.dart | 229 +++++++++++ lib/new_views/common_widgets/app_drawer.dart | 154 ++++---- pubspec.lock | 16 + pubspec.yaml | 1 + 11 files changed, 1275 insertions(+), 75 deletions(-) create mode 100644 lib/modules/copilot/controller/copilot_provider.dart create mode 100644 lib/modules/copilot/model/copilot_message_model.dart create mode 100644 lib/modules/copilot/screens/copilot_chat_page.dart create mode 100644 lib/modules/copilot/screens/copilot_history_page.dart create mode 100644 lib/modules/copilot/widgets/copilot_message_bubble.dart diff --git a/lib/controllers/api_routes/api_manager.dart b/lib/controllers/api_routes/api_manager.dart index f0c7ab42..cea9ea3a 100644 --- a/lib/controllers/api_routes/api_manager.dart +++ b/lib/controllers/api_routes/api_manager.dart @@ -232,6 +232,49 @@ class ApiManager { return streamedResponse; } + /// POST multipart/form-data request WITH a file attachment that returns a StreamedResponse. + /// Additive-only method (does not change any existing method): needed for Copilot's + /// QueryStream(isAudio=true), since [postFormDataStreaming] only supports text fields, + /// not file attachments. + Future postFormDataStreamingWithFile( + String url, { + Map? headers, + Map fields = const {}, + required String fileFieldName, + required File file, + String? filename, + }) async { + Map formHeaders = { + 'X-Timezone-Offset': DateTime.now().timeZoneOffset.toString().split(".").first, + if (user != null) 'Authorization': 'Bearer ${user!.token}', + if (assetGroup != null) 'AssetGroup': assetGroup!.id.toString(), + }; + + headers ??= {}; + headers.addAll(formHeaders); + + Uri url0 = Uri.parse(url); + if (!kReleaseMode) { + print("URL:$url0"); + print("Headers:$headers"); + print("FormData Fields:$fields, File:${file.path}"); + } + + var request = http.MultipartRequest('POST', url0); + request.fields.addAll(fields); + request.headers.addAll(headers); + request.files.add(await http.MultipartFile.fromPath(fileFieldName, file.path, filename: filename)); + + http.StreamedResponse streamedResponse = await _httpClient.send(request); + + // Handle 401 immediately + if (streamedResponse.statusCode == 401) { + showLoginDialog(); + } + + return streamedResponse; + } + Future delete(String url, {Map? headers, required Map body}) async { headers ??= {}; diff --git a/lib/controllers/api_routes/urls.dart b/lib/controllers/api_routes/urls.dart index f45e772e..58a70336 100644 --- a/lib/controllers/api_routes/urls.dart +++ b/lib/controllers/api_routes/urls.dart @@ -3,13 +3,13 @@ class URLs { static const String appReleaseBuildNumber = "53"; - static const host1 = "https://atomsm.hmg.com"; // production url + // static const host1 = "https://atomsm.hmg.com"; // production url // static const host1 = "https://atomsmdev.hmg.com"; // local DEV url - // static const host1 = "https://atomsmuat.hmg.com"; // local UAT url + static const host1 = "https://atomsmuat.hmg.com"; // local UAT url // static final String _baseUrl = "$_host/mobile"; // host local UAT - // static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis - static final String _baseUrl = "$_host/v3/mobile"; // v3 for production CM,PM,TM + static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis + // static final String _baseUrl = "$_host/v3/mobile"; // v3 for production CM,PM,TM // static final String _baseUrl = "$_host/v4/mobile"; // v4 for Demo module // static final String _baseUrl = "$_host/v5/mobile"; // v5 for data segregation // static final String _baseUrl = "$_host/v6/mobile"; // for asset delivery module @@ -621,4 +621,11 @@ class URLs { static get submitApplicationDemoRequest => "$_baseUrl/DemoUser/CreateDemoUser"; // post submit application demo request// get application demo request history static get sendDemoUserOtp => "$_baseUrl/DemoUser/EmailVerification"; // post send OTP to user static get verifyDemoUserOtp => "$_baseUrl/DemoUser/VerifyOtp"; // post verify OTP + + + // Copilot APIs + static get copilotQueryStream => "$_baseUrl/Automation/QueryStream"; + static get copilotGetQueryHistory => "$_baseUrl/Automation/GetQueryHistory"; + static copilotGetConversationByThreadId(String threadId) => "$_baseUrl/Automation/GetConversationByThreadId/$threadId"; + static copilotDeleteQueryHistory(String threadId) => "$_baseUrl/Automation/DeleteQueryHistory/$threadId"; } diff --git a/lib/main.dart b/lib/main.dart index 90efc744..9f90baf4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,7 @@ import 'package:test_sa/modules/asset_inventory_module/provider/asset_inventory_ import 'package:test_sa/modules/cm_module/cm_detail_provider.dart'; import 'package:test_sa/modules/cm_module/create_cm_request.dart'; import 'package:test_sa/modules/cx_module/chat/chat_provider.dart'; +import 'package:test_sa/modules/copilot/controller/copilot_provider.dart'; import 'package:test_sa/modules/demo_module/create_demo_request_page.dart'; import 'package:test_sa/modules/demo_module/provider/demo_period_lookup_provider.dart'; import 'package:test_sa/modules/demo_module/provider/demo_provider.dart'; @@ -242,6 +243,7 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => DepartmentsProvider()), ChangeNotifierProvider(create: (_) => NullableLoadingProvider()), ChangeNotifierProvider(create: (_) => ChatProvider()), + ChangeNotifierProvider(create: (_) => CopilotProvider(), lazy: true), // ============================================================ // LAZY LOADED PROVIDERS (107) - Created only when accessed diff --git a/lib/modules/copilot/controller/copilot_provider.dart b/lib/modules/copilot/controller/copilot_provider.dart new file mode 100644 index 00000000..4936c2cd --- /dev/null +++ b/lib/modules/copilot/controller/copilot_provider.dart @@ -0,0 +1,257 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:test_sa/controllers/api_routes/api_manager.dart'; +import 'package:test_sa/controllers/api_routes/urls.dart'; + +import '../model/copilot_message_model.dart'; + +class CopilotProvider extends ChangeNotifier { + List history = []; + bool historyLoading = false; + List messages = []; + bool conversationLoading = false; + bool isStreaming = false; + String? sessionId; + String? errorMessage; + StreamSubscription? _streamSub; + Future loadHistory() async { + historyLoading = true; + errorMessage = null; + notifyListeners(); + try { + final res = await ApiManager.instance.get( + URLs.copilotGetQueryHistory, + enableToastMessage: false, // "message" is just an informational success string here + ); + final decoded = jsonDecode(res.body); + if (decoded is Map && decoded['isSuccess'] == true) { + final List threads = decoded['data']?['threads'] ?? []; + history = threads.map((e) => CopilotHistoryItem.fromJson(e)).toList(); + } else { + errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to load history"; + } + } catch (e) { + errorMessage = "Failed to load history"; + } finally { + historyLoading = false; + notifyListeners(); + } + } + Future deleteHistory(String threadId) async { + final prevList = List.from(history); + history.removeWhere((h) => h.threadId == threadId); + notifyListeners(); + + try { + final res = await ApiManager.instance.delete( + URLs.copilotDeleteQueryHistory(threadId), + body: const {}, // API ignores the body for this route-bound id endpoint + ); + final decoded = jsonDecode(res.body); + final success = decoded is Map && decoded['isSuccess'] == true && decoded['data'] == true; + if (!success) { + history = prevList; // rollback on failure + errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to delete"; + notifyListeners(); + } + } catch (e) { + history = prevList; + errorMessage = "Failed to delete"; + notifyListeners(); + } + } + + Future loadConversation(String threadId) async { + conversationLoading = true; + errorMessage = null; + messages = []; + sessionId = threadId; + notifyListeners(); + try { + final res = await ApiManager.instance.get( + URLs.copilotGetConversationByThreadId(threadId), + enableToastMessage: false, + ); + final decoded = jsonDecode(res.body); + if (decoded is Map && decoded['isSuccess'] == true) { + final List rawMessages = decoded['data']?['messages'] ?? []; + messages = rawMessages.map((e) => CopilotMessage.fromHistoryJson(e)).toList(); + } else { + errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to load conversation"; + } + } catch (e) { + errorMessage = "Failed to load conversation"; + } finally { + conversationLoading = false; + notifyListeners(); + } + } + void startNewConversation() { + _streamSub?.cancel(); + sessionId = null; + messages = []; + errorMessage = null; + isStreaming = false; + notifyListeners(); + } + + Future sendText(String query) async { + if (isStreaming || query.trim().isEmpty) return; // prevent invalid duplicate sends + sessionId ??= _generateSessionId(); + + messages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, content: query, timestamp: DateTime.now())); + final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now()); + messages.add(assistantMsg); + isStreaming = true; + errorMessage = null; + notifyListeners(); + + try { + final streamedResponse = await ApiManager.instance.postFormDataStreaming( + URLs.copilotQueryStream, + body: { + "sessionId": sessionId!, + "isAudio": "false", + "query": query, + }, + ); + await _consumeStream(streamedResponse, assistantMsg); + } catch (e) { + _onStreamError(assistantMsg, e); + } + } + Future sendAudio(File audioFile) async { + if (isStreaming) return; + sessionId ??= _generateSessionId(); + + messages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now())); + final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now()); + messages.add(assistantMsg); + isStreaming = true; + errorMessage = null; + notifyListeners(); + + try { + final streamedResponse = await ApiManager.instance.postFormDataStreamingWithFile( + URLs.copilotQueryStream, + fields: { + "sessionId": sessionId!, + "isAudio": "true", + }, + fileFieldName: "AudioFile", + file: audioFile, + filename: audioFile.path.split('/').last, + ); + await _consumeStream(streamedResponse, assistantMsg); + } catch (e) { + _onStreamError(assistantMsg, e); + } + } + + String _generateSessionId() { + final rand = Random(); + final bytes = List.generate(16, (_) => rand.nextInt(256)); + bytes[6] = (bytes[6] & 0x0F) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 10xx + String byteHex(int i) => bytes[i].toRadixString(16).padLeft(2, '0'); + final hex = List.generate(16, byteHex).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}'; + } + + Future _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg) async { + if (streamedResponse.statusCode < 200 || streamedResponse.statusCode >= 300) { + _onStreamError(assistantMsg, "Server error ${streamedResponse.statusCode}"); + return; + } + + String buffer = ''; + _streamSub = streamedResponse.stream.transform(utf8.decoder).listen( + (rawChunk) { + buffer += rawChunk; + while (buffer.contains('\n\n')) { + final idx = buffer.indexOf('\n\n'); + final rawEvent = buffer.substring(0, idx); + buffer = buffer.substring(idx + 2); + _handleSseEvent(rawEvent, assistantMsg); + } + }, + onDone: () { + if (buffer.trim().isNotEmpty) { + _handleSseEvent(buffer, assistantMsg); + } + assistantMsg.isStreaming = false; + isStreaming = false; + notifyListeners(); + }, + onError: (e) => _onStreamError(assistantMsg, e), + cancelOnError: true, + ); + } + + void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg) { + final dataLines = rawEvent.split('\n').where((line) => line.startsWith('data:')).map((line) => line.substring(5).trim()).toList(); + if (dataLines.isEmpty) return; + + final payload = dataLines.join('\n'); + + if (payload == '[DONE]') { + assistantMsg.isStreaming = false; + isStreaming = false; + notifyListeners(); + return; + } + + try { + final json = jsonDecode(payload); + final event = CopilotStreamEvent.fromJson(json); + if (event.type == 'response' && event.content != null) { + assistantMsg.content = event.content!.answer ?? ''; + assistantMsg.documentAnswer = event.content!.documentAnswer; + assistantMsg.websiteAnswer = event.content!.websiteAnswer; + assistantMsg.images = event.content!.images; + } else if (event.type == 'error') { + _onStreamError(assistantMsg, event.content?.answer ?? 'Something went wrong'); + return; + } + notifyListeners(); + } catch (_) { + } + } + + void _onStreamError(CopilotMessage assistantMsg, Object e) { + assistantMsg.isStreaming = false; + assistantMsg.isError = true; + if (assistantMsg.content.isEmpty) { + assistantMsg.content = "Something went wrong. Please try again."; + } + isStreaming = false; + errorMessage = "Failed to get a response"; + notifyListeners(); + } + + void cancelStreaming() { + _streamSub?.cancel(); + isStreaming = false; + if (messages.isNotEmpty && messages.last.role == CopilotRole.assistant) { + messages.last.isStreaming = false; + } + } + + @override + void dispose() { + _streamSub?.cancel(); + super.dispose(); + } +} + + + + + + + + diff --git a/lib/modules/copilot/model/copilot_message_model.dart b/lib/modules/copilot/model/copilot_message_model.dart new file mode 100644 index 00000000..5c046447 --- /dev/null +++ b/lib/modules/copilot/model/copilot_message_model.dart @@ -0,0 +1,137 @@ +import 'package:flutter/foundation.dart'; + +/// Role of a message inside a Copilot conversation. +enum CopilotRole { user, assistant } + +/// A single reference image returned alongside an assistant answer +/// (e.g. a scanned page from a referenced document). +class CopilotImage { + final String base64; + final String source; + final int page; + final String? fileLink; + final double? similarityScore; + + CopilotImage({ + required this.base64, + required this.source, + required this.page, + this.fileLink, + this.similarityScore, + }); + + factory CopilotImage.fromJson(Map json) => CopilotImage( + base64: json['image_base64'] ?? '', + source: json['source'] ?? '', + page: json['page'] is int ? json['page'] : int.tryParse('${json['page']}') ?? 0, + fileLink: json['file_link'], + similarityScore: (json['similarity_score'] as num?)?.toDouble(), + ); +} + +/// A single message (human query or assistant answer) inside a Copilot conversation. +/// +/// This is intentionally a NEW, small model (not a reuse of chat's SingleUserChatModel), +/// since the Copilot Automation API response shape (answer/document_answer/website_answer/images) +/// is completely different from the multi-user chat history model. +class CopilotMessage { + final String id; + final CopilotRole role; + String content; // assistant answer text OR the human query text + String? documentAnswer; + String? websiteAnswer; + List images; + DateTime? timestamp; + bool isStreaming; + bool isError; + // Local path of a recorded audio file for a user's voice query (never a remote + // download URL - the backend never sends audio back). Used to let the user + // replay their own sent voice message, same as Chat's audio playback. + String? localAudioPath; + + CopilotMessage({ + required this.id, + required this.role, + this.content = '', + this.documentAnswer, + this.websiteAnswer, + this.images = const [], + this.timestamp, + this.isStreaming = false, + this.isError = false, + this.localAudioPath, + }); + + /// Builds a message from GetConversationByThreadId's flat message shape: + /// { "role": "human"|"ai", "content": "...", "document_answer": "...", "website_answer": "...", "images": [...] } + /// NOTE: the API currently always returns `timestamp: null`, so we fall back to + /// "now" purely so the UI has a value to render (matches Chat's bubble layout, + /// which always shows a time under each message). + factory CopilotMessage.fromHistoryJson(Map json) => CopilotMessage( + id: UniqueKey().toString(), + role: json['role'] == 'ai' ? CopilotRole.assistant : CopilotRole.user, + content: json['content'] ?? '', + documentAnswer: json['document_answer'], + websiteAnswer: json['website_answer'], + images: (json['images'] as List? ?? []).map((e) => CopilotImage.fromJson(e)).toList(), + timestamp: json['timestamp'] != null ? (DateTime.tryParse(json['timestamp'].toString()) ?? DateTime.now()) : DateTime.now(), + ); +} + +/// A single event decoded from a QueryStream SSE `data:` line +/// (payload shape: {"type":"response","content":{...}}, or the literal string "[DONE]"). +class CopilotStreamEvent { + final String type; + final CopilotStreamContent? content; + + CopilotStreamEvent({required this.type, this.content}); + + factory CopilotStreamEvent.fromJson(Map json) => CopilotStreamEvent( + type: json['type'] ?? '', + content: json['content'] is Map ? CopilotStreamContent.fromJson(json['content']) : null, + ); +} + +class CopilotStreamContent { + final String? answer; + final String? documentAnswer; + final String? websiteAnswer; + final int? chunksUsed; + final List images; + + CopilotStreamContent({ + this.answer, + this.documentAnswer, + this.websiteAnswer, + this.chunksUsed, + this.images = const [], + }); + + factory CopilotStreamContent.fromJson(Map json) => CopilotStreamContent( + answer: json['answer'], + documentAnswer: json['document_answer'], + websiteAnswer: json['website_answer'], + chunksUsed: json['chunks_used'], + images: (json['images'] as List? ?? []).map((e) => CopilotImage.fromJson(e)).toList(), + ); +} + +/// A single row in the Copilot query history list (GetQueryHistory). +class CopilotHistoryItem { + final String threadId; + final String firstHumanMessage; + final DateTime updatedAt; + + CopilotHistoryItem({ + required this.threadId, + required this.firstHumanMessage, + required this.updatedAt, + }); + + factory CopilotHistoryItem.fromJson(Map json) => CopilotHistoryItem( + threadId: json['threadId'] ?? '', + firstHumanMessage: json['firstHumanMessage'] ?? '', + updatedAt: DateTime.tryParse(json['updatedAt'] ?? '') ?? DateTime.now(), + ); +} + diff --git a/lib/modules/copilot/screens/copilot_chat_page.dart b/lib/modules/copilot/screens/copilot_chat_page.dart new file mode 100644 index 00000000..108edf63 --- /dev/null +++ b/lib/modules/copilot/screens/copilot_chat_page.dart @@ -0,0 +1,367 @@ +import 'dart:io'; +import 'package:audio_waveforms/audio_waveforms.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:test_sa/extensions/context_extension.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/string_extensions.dart'; +import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/modules/copilot/screens/copilot_history_page.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; +import '../controller/copilot_provider.dart'; +import '../widgets/copilot_message_bubble.dart'; +enum _InputState { idle, recording, recorded } +class CopilotChatPage extends StatefulWidget { + final bool isNewConversation; + + const CopilotChatPage({Key? key, this.isNewConversation = true}) : super(key: key); + + @override + State createState() => _CopilotChatPageState(); +} + +class _CopilotChatPageState extends State { + final TextEditingController _textController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final RecorderController _recorderController = RecorderController(); + final PlayerController _recordedPlayerController = PlayerController(); + + _InputState _inputState = _InputState.idle; + String? _recordedFilePath; + + CopilotProvider? _copilotProvider; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _copilotProvider = context.read(); + } + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + _recorderController.dispose(); + _recordedPlayerController.dispose(); + _copilotProvider?.cancelStreaming(); + super.dispose(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + + void _sendText() { + final text = _textController.text.trim(); + if (text.isEmpty) return; + _textController.clear(); + context.read().sendText(text); + _scrollToBottom(); + } + + Future _startRecording() async { + await _recorderController.checkPermission(); + if (!_recorderController.hasPermission) { + "Audio permission denied. Please enable from settings".showToast; + return; + } + setState(() => _inputState = _InputState.recording); + _recorderController.record(); + } + + Future _stopRecording() async { + await _recorderController.pause(); + _recordedFilePath = await _recorderController.stop(); + setState(() => _inputState = _InputState.recorded); + } + + void _cancelRecording() async { + await _recordedPlayerController.stopPlayer(); + _recorderController.reset(); + _recordedFilePath = null; + setState(() => _inputState = _InputState.idle); + } + + void _sendRecording() async { + if (_recordedFilePath == null) return; + final file = File(_recordedFilePath!); + context.read().sendAudio(file); + await _recordedPlayerController.stopPlayer(); + _recorderController.reset(); + _recordedFilePath = null; + setState(() => _inputState = _InputState.idle); + _scrollToBottom(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: DefaultAppBar(title: "Copilot Assistant", actions: [ + 'history_icon'.toSvgAsset(width: 24, height: 24).onPress((){ + Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotHistoryPage())).then((_) => context.read().loadHistory()); + }).paddingOnly(end: 12), + ],), + body: Column( + children: [ + Expanded( + child: Consumer( + builder: (context, provider, _) { + if (provider.conversationLoading) { + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + _ShimmerBubble(isSender: false), + _ShimmerBubble(isSender: false), + _ShimmerBubble(isSender: true), + _ShimmerBubble(isSender: true), + ], + ); + } + + if (provider.messages.isEmpty) { + return Text( + "Send a message to start conversation", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: AppTextStyles.heading6.copyWith(color: AppColor.lightTextColor(context), fontWeight: FontWeight.w500), + ).center; + } + _scrollToBottom(); + final messages = provider.messages; + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final currentMessage = messages[index]; + final bool showHeader = index == 0 || currentMessage.role != messages[index - 1].role; + bool showDateHeader = index == 0; + if (!showDateHeader) { + final prevDate = DateUtils.dateOnly(messages[index - 1].timestamp ?? DateTime.now()); + final currDate = DateUtils.dateOnly(currentMessage.timestamp ?? DateTime.now()); + showDateHeader = !prevDate.isAtSameMomentAs(currDate); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showDateHeader) _dateCard(currentMessage.timestamp?.toIso8601String().chatMsgDateWithYear ?? ""), + CopilotMessageBubble(message: currentMessage, showHeader: showHeader), + ], + ); + }, + ); + }, + ), + ), + Consumer( + builder: (context, provider, _) { + if (provider.errorMessage == null) return const SizedBox(); + return Container( + width: double.infinity, + color: Colors.red.withValues(alpha: 0.1), + padding: const EdgeInsets.all(8), + child: Text(provider.errorMessage!, style: const TextStyle(color: Colors.red)), + ); + }, + ), + const Divider(height: 1).defaultStyle(context), + SafeArea( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Row( + children: [ + if (_inputState == _InputState.idle) ...[ + TextFormField( + controller: _textController, + cursorColor: AppColor.loadingColor(context), + style: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 14, color: AppColor.textStyleColor(context)), + minLines: 1, + maxLines: 3, + textInputAction: TextInputAction.none, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + border: InputBorder.none, + errorBorder: InputBorder.none, + contentPadding: const EdgeInsets.only(left: 16, top: 8, bottom: 8), + alignLabelWithHint: true, + constraints: const BoxConstraints(), + suffixIconConstraints: const BoxConstraints(), + hintText: "Ask Copilot...", + hintStyle: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 12, color: AppColor.lightTextColor(context)), + ), + ).expanded, + IconButton( + onPressed: _startRecording, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + icon: "chat_mic".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)), + constraints: const BoxConstraints(), + ), + ] else if (_inputState == _InputState.recording) ...[ + AudioWaveforms( + size: Size(MediaQuery.of(context).size.width, 56.0), + waveStyle: const WaveStyle(waveColor: AppColor.neutral50, extendWaveform: true, showMiddleLine: false), + padding: const EdgeInsets.only(left: 16), + recorderController: _recorderController, + ).expanded, + IconButton( + onPressed: _stopRecording, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + icon: const Icon(Icons.stop_circle_rounded), + constraints: const BoxConstraints(), + ), + ] else ...[ + if (_recordedPlayerController.playerState == PlayerState.playing) + IconButton( + onPressed: () async { + await _recordedPlayerController.pausePlayer(); + await _recordedPlayerController.stopPlayer(); + setState(() {}); + }, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + icon: const Icon(Icons.stop_circle_outlined, size: 20), + constraints: const BoxConstraints(), + ) + else + IconButton( + onPressed: () async { + await _recordedPlayerController.preparePlayer(path: _recordedFilePath!); + await _recordedPlayerController.startPlayer(); + setState(() {}); + }, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + icon: const Icon(Icons.play_circle_fill_rounded, size: 20), + constraints: const BoxConstraints(), + ), + AudioFileWaveforms( + playerController: _recordedPlayerController, + waveformData: _recorderController.waveData, + enableSeekGesture: false, + continuousWaveform: false, + waveformType: WaveformType.long, + playerWaveStyle: const PlayerWaveStyle( + fixedWaveColor: AppColor.neutral50, + liveWaveColor: AppColor.primary10, + showSeekLine: true, + ), + size: Size(MediaQuery.of(context).size.width, 56.0), + ).expanded, + IconButton( + onPressed: _cancelRecording, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + icon: "delete_icon".toSvgAsset(width: 24, height: 24), + constraints: const BoxConstraints(), + ), + ], + if (_inputState != _InputState.recording) + IconButton( + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, + onPressed: () { + if (_inputState == _InputState.recorded) { + _sendRecording(); + } else { + _sendText(); + } + }, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + icon: Consumer( + builder: (_, provider, __) => provider.isStreaming + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(color: AppColor.primary10, strokeWidth: 2), + ) + : "chat_msg_send".toSvgAsset(width: 24, height: 24), + ), + constraints: const BoxConstraints(), + ), + 8.width, + ], + ), + ), + ), + ], + ), + ); + } + + Widget _dateCard(String date) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + margin: const EdgeInsets.only(top: 16, bottom: 8), + decoration: BoxDecoration( + color: AppColor.neutral50, + borderRadius: BorderRadius.circular(6), + ), + child: Text(date, style: AppTextStyles.bodyText2.copyWith(color: AppColor.white10))) + .center; + } +} + +class _ShimmerBubble extends StatelessWidget { + final bool isSender; + + const _ShimmerBubble({required this.isSender}); + + @override + Widget build(BuildContext context) { + final avatar = Container( + height: 26, + width: 26, + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.grey), + ).toShimmer(context: context, isShow: true); + + final name = Text( + isSender ? "Me" : "Copilot",//check the api if they send the user name + style: AppTextStyles.bodyText.copyWith(color: AppColor.neutral50, fontWeight: FontWeight.w600), + ).toShimmer(context: context, isShow: true); + + final header = Row( + mainAxisSize: MainAxisSize.min, + children: isSender ? [name, 8.width, avatar] : [avatar, 8.width, name], + ); + + return Align( + alignment: isSender ? Alignment.centerRight : Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: isSender ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + header, + 4.height, + Container( + padding: const EdgeInsets.all(8), + margin: EdgeInsets.only(left: isSender ? 26 + 8 : 0, right: isSender ? 0 : 26 + 8), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.6), + child: Text( + "Please let me know what is the issue?", + style: AppTextStyles.bodyText2.copyWith(color: AppColor.neutral120), + ).toShimmer(context: context, isShow: true), + ), + ], + ), + ), + ); + } +} + + + + + diff --git a/lib/modules/copilot/screens/copilot_history_page.dart b/lib/modules/copilot/screens/copilot_history_page.dart new file mode 100644 index 00000000..67afcfd2 --- /dev/null +++ b/lib/modules/copilot/screens/copilot_history_page.dart @@ -0,0 +1,129 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/modules/copilot/model/copilot_message_model.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/new_views/common_widgets/app_filled_button.dart'; +import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; +import 'package:test_sa/views/widgets/item_views/info_header_widget.dart'; +import 'package:test_sa/views/widgets/item_views/info_text_widget.dart'; + +import '../controller/copilot_provider.dart'; +import 'copilot_chat_page.dart' show CopilotChatPage; +class CopilotHistoryPage extends StatefulWidget { + static const String routeName = "/copilot-history"; + const CopilotHistoryPage({Key? key}) : super(key: key); + + @override + State createState() => _CopilotHistoryPageState(); +} +class _CopilotHistoryPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadHistory(); + }); + } + + void _openConversation(String threadId) { + Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: false))).then((_) => context.read().loadHistory()); + context.read().loadConversation(threadId); + } + + void _startNewConversation() { + context.read().startNewConversation(); + Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read().loadHistory()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: DefaultAppBar( + title: "Copilot History", + actions: [ + IconButton( + icon: const Icon(Icons.chat_bubble), + onPressed: _startNewConversation, + ), + ], + ), + body: Consumer( + builder: (context, provider, _) { + if (provider.historyLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.history.isEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.chat_bubble_outline, size: 56, color: AppColor.neutral120), + 16.height, + Text( + "No conversations yet", + style: AppTextStyles.heading6.copyWith(color: AppColor.neutral120), + ), + 24.height, + AppFilledButton( + label: "Start a new conversation", + maxWidth: true, + buttonColor: AppColor.primary10, + onPressed: _startNewConversation, + ).paddingOnly(start: 48, end: 48), + ], + ).center; + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: provider.history.length, + separatorBuilder: (czt, index) => 12.height, + itemBuilder: (context, index) { + final item = provider.history[index]; + return messageHistoryCard(item: item, provider: provider); + }, + ); + }, + ), + ); + } + + Widget messageHistoryCard({required CopilotHistoryItem item, required CopilotProvider provider}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon( + Icons.chat_bubble, + color: AppColor.icon2Color(context), + ), + 16.width, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InfoHeaderWidget(item.firstHumanMessage), + 4.height, + InfoTextLabelWidget(label: _formatDate(item.updatedAt)), + ], + ), + ], + ), + "delete_icon".toSvgAsset(height: 20, width: 20).onPress(() => provider.deleteHistory(item.threadId)), + ], + ).toShadowContainer(context, showShadow: false).onPress(() { + _openConversation(item.threadId); + }); + } + + String _formatDate(DateTime date) { + return "${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year} " + "${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}"; + } +} diff --git a/lib/modules/copilot/widgets/copilot_message_bubble.dart b/lib/modules/copilot/widgets/copilot_message_bubble.dart new file mode 100644 index 00000000..6ebd46c0 --- /dev/null +++ b/lib/modules/copilot/widgets/copilot_message_bubble.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/string_extensions.dart'; +import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/views/widgets/sound/sound_player.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../model/copilot_message_model.dart'; + +class CopilotMessageBubble extends StatelessWidget { + final CopilotMessage message; + final bool showHeader; + + const CopilotMessageBubble({Key? key, required this.message, this.showHeader = true}) : super(key: key); + + @override + Widget build(BuildContext context) { + final isUser = message.role == CopilotRole.user; + return isUser ? _senderCard(context) : _recipientCard(context); + } + + static const Widget _avatar = SizedBox( + height: 26, + width: 26, + child: DecoratedBox(decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey)), + ); + + String get _timeText => (message.timestamp?.toIso8601String() ?? DateTime.now().toIso8601String()).chatMsgTime; + + Widget _senderCard(BuildContext context) { + final senderHeader = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Me", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: AppTextStyles.bodyText.copyWith(color: AppColor.neutral50, fontWeight: FontWeight.w600), + ), + 8.width, + _avatar, + ], + ); + + return Align( + alignment: Alignment.centerRight, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (showHeader) ...[senderHeader, 4.height] else 8.height, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + fit: FlexFit.loose, + child: Container( + padding: const EdgeInsets.all(8), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + decoration: BoxDecoration( + color: AppColor.white50, + borderRadius: BorderRadius.circular(6), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (message.localAudioPath != null) + ASoundPlayer(audio: message.localAudioPath!) + else + Text( + message.content, + style: AppTextStyles.bodyText2.copyWith(color: AppColor.textColor(context)), + ), + Text( + _timeText, + style: AppTextStyles.bodyText2.copyWith(color: AppColor.textColor(context)), + ), + ], + ), + ), + ), + (26 + 8).width, + ], + ), + ], + ), + ); + } + + Widget _recipientCard(BuildContext context) { + final showLoadingDots = message.isStreaming && message.content.isEmpty; + final recipientHeader = Row( + mainAxisSize: MainAxisSize.min, + children: [ + _avatar, + 8.width, + Text( + "Copilot", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: AppTextStyles.bodyText.copyWith(color: AppColor.neutral50, fontWeight: FontWeight.w600), + ), + ], + ); + return Align( + alignment: Alignment.centerLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showHeader) ...[recipientHeader, 4.height] else 8.height, + Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(left: 26 + 8, right: 26 + 8), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + decoration: BoxDecoration( + color: AppColor.background(context), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (showLoadingDots) + const Align( + alignment: Alignment.centerLeft, + child: SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColor.primary10), + ), + ) + else + _buildMessageContent(context), + // if (message.content.isNotEmpty && message.images.isNotEmpty) 8.height, + // if (message.content.isNotEmpty) + // Text( + // message.content, + // style: AppTextStyles.bodyText2.copyWith( + // color: message.isError ? Colors.red : AppColor.white10, + // ), + // ), + if (message.images.isNotEmpty) ...[ + 8.height, + SizedBox( + height: 90, + width: double.infinity, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: message.images.length, + separatorBuilder: (_, __) => 8.width, + itemBuilder: (context, index) { + final img = message.images[index]; + return Image.memory( + base64Decode(img.base64), + width: 100, + height: 100, + fit: BoxFit.contain, + ); + }, + ), + ), + ], + Align( + alignment: Alignment.centerRight, + widthFactor: 1, + child: Text( + _timeText, + style: AppTextStyles.textFieldLabelStyle.copyWith(color: AppColor.textColor(context)), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMessageContent(BuildContext context) { + var content = message.content; + + content = content.replaceAllMapped( + RegExp(r'(\*\*[^*\n]+:\*\*)'), + (match) => '${match.group(1)} \n', + ); + content = content.replaceAllMapped( + RegExp(r'(Link:)\s*', caseSensitive: false), + (match) => '${match.group(1)} \n', + ); + + return MarkdownBody( + data: content, + selectable: true, + onTapLink: (text, href, title) async { + if (href == null || href.isEmpty) return; + + final uri = Uri.tryParse(href); + + if (uri == null) return; + + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + }, + styleSheet: MarkdownStyleSheet( + p: AppTextStyles.bodyText2.copyWith( + color: message.isError ? Colors.red : AppColor.textStyleColor(context), + ), + strong: AppTextStyles.bodyText2.copyWith( + color: message.isError ? Colors.red : AppColor.textStyleColor(context), + fontWeight: FontWeight.bold, + ), + listBullet: AppTextStyles.bodyText2.copyWith( + color: message.isError ? Colors.red : AppColor.textStyleColor(context), + ), + a: AppTextStyles.bodyText2.copyWith( + color: AppColor.primary10, + decoration: TextDecoration.underline, + ), + ), + ); + } +} diff --git a/lib/new_views/common_widgets/app_drawer.dart b/lib/new_views/common_widgets/app_drawer.dart index 0f6c0a9f..f9758ff3 100644 --- a/lib/new_views/common_widgets/app_drawer.dart +++ b/lib/new_views/common_widgets/app_drawer.dart @@ -5,7 +5,10 @@ import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/string_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/modules/copilot/controller/copilot_provider.dart'; +import 'package:test_sa/modules/copilot/screens/copilot_chat_page.dart'; import 'package:test_sa/modules/notification_setting/view/notification_setting_page.dart'; +import 'package:test_sa/modules/copilot/screens/copilot_history_page.dart'; import 'package:test_sa/modules/site_request_module/screens/site_request_history_screen.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; import 'package:test_sa/new_views/pages/login_page.dart'; @@ -27,15 +30,18 @@ class AppDrawer extends StatelessWidget { final settingProvider = Provider.of(context, listen: false); final userProvider = Provider.of(context, listen: false); return Drawer( - backgroundColor: AppColor.background(context), - width: MediaQuery.of(context).size.width * .8, - child: SafeArea( - top: false, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + backgroundColor: AppColor.background(context), + width: MediaQuery + .of(context) + .size + .width * .8, + child: SafeArea( + top: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -49,8 +55,8 @@ class AppDrawer extends StatelessWidget { child: snapshot.profileImage != null ? Image.file(snapshot.profileImage!) : (snapshot.user?.profilePhotoName?.isNotEmpty ?? false) - ? Image.network(snapshot.user!.profilePhotoName!) - : Icon(Icons.person, size: 50, color: AppColor.icon2Color(context)), + ? Image.network(snapshot.user!.profilePhotoName!) + : Icon(Icons.person, size: 50, color: AppColor.icon2Color(context)), ), ), ); @@ -60,66 +66,72 @@ class AppDrawer extends StatelessWidget { ), 8.height, (userProvider.user?.username ?? "").heading4(context).custom(color: AppColor.labelTextStyleColor(context)), - if ((userProvider.user?.email ?? "").isNotEmpty) (userProvider.user!.email!).heading6(context).custom(color: AppColor.lightTextColor(context)), - 16.height, - const Divider().defaultStyle(context), - ListView( - padding: const EdgeInsets.only(top: 24), - children: [ - // drawerItem("drawer_notification", context.translation.notifications, context).onPress(() => Navigator.of(context).pushNamed(NotificationsPage.id)), - // 18.height, - // drawerItem("notification_setting", 'Notification Settings'.addTranslation, context).onPress(() => Navigator.of(context).pushNamed(NotificationSettingPage.id)), - // 18.height, - // drawerItem("help_center", context.translation.helpCenter, context) /*.onPress(() => Navigator.of(context).pushNamed(HelpCenterPage.id))*/, - // 18.height, - // drawerItem("rate_us", context.translation.rateUs, context), - // 18.height, - drawerItem("setting", context.translation.settings, context).onPress(() => Navigator.of(context).pushNamed(SettingsPage.id)), - 18.height, - drawerItem("site_request_icon", "Site Request", context).onPress(() => Navigator.of(context).pushNamed(SiteRequestHistoryScreen.routeName)), - 18.height, - if (userProvider.user != null && settingProvider.isUserFlowMedical == false && userProvider.user?.employeeIsHMG == false) - drawerItem("swipe", "Swipe History", context).onPress(() => Navigator.of(context).pushNamed(SwipeHistoryView.routeName)), - // 18.height, - // drawerItem("report", context.translation.reportBg, context) /*.onPress(() => Navigator.of(context).pushNamed(ReportBugPage.id))*/, - // 18.height, - // drawerItem("whats_new", context.translation.whatsNew, context), - ], - ).expanded, - drawerItem("logout", context.translation.logout, context, color: AppColor.red30).onPress(() async { - bool result = await showDialog( - context: context, - builder: (_) => AAlertDialog(title: context.translation.signOut, content: context.translation.logoutAlert), - ); - if (result) { - bool isSuccess = await userProvider.logout(context); - if (isSuccess) { - settingProvider.resetSettings(); - userProvider.reset(); - Navigator.of(context).pop(); - Navigator.of(context).pop(); - Navigator.of(context).pushNamedAndRemoveUntil(LoginPage.routeName, (routes) => true); - } - } - }), - 16.height, - const Divider().defaultStyle(context), - 16.height, - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Powered By Cloud Solutions", - style: AppTextStyles.heading3.copyWith(fontWeight: FontWeight.w500, color: context.isDark ? AppColor.neutral30 : AppColor.neutral50, fontSize: 12), - textScaleFactor: AppStyle.getScaleFactor(context), - ), - 6.width, - Image.asset("assets/images/cloud_logo.png", width: 32, height: 32) - ], - ) - ], - ).paddingOnly(top: 66, start: 16, end: 24, bottom: 18), - ), + if ((userProvider.user?.email ?? "").isNotEmpty)(userProvider.user!.email!).heading6(context).custom(color: AppColor.lightTextColor(context)), + 16.height, + const Divider().defaultStyle(context), + ListView( + padding: const EdgeInsets.only(top: 24), + children: [ + // drawerItem("drawer_notification", context.translation.notifications, context).onPress(() => Navigator.of(context).pushNamed(NotificationsPage.id)), + // 18.height, + // drawerItem("notification_setting", 'Notification Settings'.addTranslation, context).onPress(() => Navigator.of(context).pushNamed(NotificationSettingPage.id)), + // 18.height, + // drawerItem("help_center", context.translation.helpCenter, context) /*.onPress(() => Navigator.of(context).pushNamed(HelpCenterPage.id))*/, + // 18.height, + // drawerItem("rate_us", context.translation.rateUs, context), + // 18.height, + drawerItem("setting", context.translation.settings, context).onPress(() => Navigator.of(context).pushNamed(SettingsPage.id)), + 18.height, + drawerItem("setting", "Copilot", context).onPress(() { + context.read().startNewConversation(); + Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read().loadHistory()); + }), + + 18.height, + drawerItem("site_request_icon", "Site Request", context).onPress(() => Navigator.of(context).pushNamed(SiteRequestHistoryScreen.routeName)), + 18.height, + if (userProvider.user != null && settingProvider.isUserFlowMedical == false && userProvider.user?.employeeIsHMG == false) + drawerItem("swipe", "Swipe History", context).onPress(() => Navigator.of(context).pushNamed(SwipeHistoryView.routeName)), + // 18.height, + // drawerItem("report", context.translation.reportBg, context) /*.onPress(() => Navigator.of(context).pushNamed(ReportBugPage.id))*/, + // 18.height, + // drawerItem("whats_new", context.translation.whatsNew, context), + ], + ).expanded, + drawerItem("logout", context.translation.logout, context, color: AppColor.red30).onPress(() async { + bool result = await showDialog( + context: context, + builder: (_) => AAlertDialog(title: context.translation.signOut, content: context.translation.logoutAlert), + ); + if (result) { + bool isSuccess = await userProvider.logout(context); + if (isSuccess) { + settingProvider.resetSettings(); + userProvider.reset(); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + Navigator.of(context).pushNamedAndRemoveUntil(LoginPage.routeName, (routes) => true); + } + } + }), + 16.height, + const Divider().defaultStyle(context), + 16.height, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Powered By Cloud Solutions", + style: AppTextStyles.heading3.copyWith(fontWeight: FontWeight.w500, color: context.isDark ? AppColor.neutral30 : AppColor.neutral50, fontSize: 12), + textScaleFactor: AppStyle.getScaleFactor(context), + ), + 6.width, + Image.asset("assets/images/cloud_logo.png", width: 32, height: 32) + ], + ) + ], + ).paddingOnly(top: 66, start: 16, end: 24, bottom: 18), + ), ); } diff --git a/pubspec.lock b/pubspec.lock index 8fa3ed1c..8e4f8b1c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -524,6 +524,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 + url: "https://pub.dev" + source: hosted + version: "1.0.12" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -998,6 +1006,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.2" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ccf2f535..70940db2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,6 +109,7 @@ dependencies: local_auth_darwin: ^1.4.0 haptic_feedback: ^0.6.4+3 sn_progress_dialog: ^1.2.0 + flutter_markdown_plus: ^1.0.12 dev_dependencies: