copilot added

main_production_copilot
WaseemAbbasi22 3 weeks ago
parent 0863447e17
commit 8cc76265b1

@ -232,6 +232,49 @@ class ApiManager {
return streamedResponse; 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<http.StreamedResponse> postFormDataStreamingWithFile(
String url, {
Map<String, String>? headers,
Map<String, String> fields = const {},
required String fileFieldName,
required File file,
String? filename,
}) async {
Map<String, String> 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<http.Response> delete(String url, {Map<String, String>? headers, required Map<String, dynamic> body}) async { Future<http.Response> delete(String url, {Map<String, String>? headers, required Map<String, dynamic> body}) async {
headers ??= {}; headers ??= {};

@ -3,13 +3,13 @@ class URLs {
static const String appReleaseBuildNumber = "53"; 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://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/mobile"; // host local UAT
// static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis 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/v3/mobile"; // v3 for production CM,PM,TM
// static final String _baseUrl = "$_host/v4/mobile"; // v4 for Demo module // 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/v5/mobile"; // v5 for data segregation
// static final String _baseUrl = "$_host/v6/mobile"; // for asset delivery module // 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 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 sendDemoUserOtp => "$_baseUrl/DemoUser/EmailVerification"; // post send OTP to user
static get verifyDemoUserOtp => "$_baseUrl/DemoUser/VerifyOtp"; // post verify OTP 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";
} }

@ -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/cm_detail_provider.dart';
import 'package:test_sa/modules/cm_module/create_cm_request.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/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/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_period_lookup_provider.dart';
import 'package:test_sa/modules/demo_module/provider/demo_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: (_) => DepartmentsProvider()),
ChangeNotifierProvider(create: (_) => NullableLoadingProvider()), ChangeNotifierProvider(create: (_) => NullableLoadingProvider()),
ChangeNotifierProvider(create: (_) => ChatProvider()), ChangeNotifierProvider(create: (_) => ChatProvider()),
ChangeNotifierProvider(create: (_) => CopilotProvider(), lazy: true),
// ============================================================ // ============================================================
// LAZY LOADED PROVIDERS (107) - Created only when accessed // LAZY LOADED PROVIDERS (107) - Created only when accessed

@ -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<CopilotHistoryItem> history = [];
bool historyLoading = false;
List<CopilotMessage> messages = [];
bool conversationLoading = false;
bool isStreaming = false;
String? sessionId;
String? errorMessage;
StreamSubscription? _streamSub;
Future<void> 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<void> deleteHistory(String threadId) async {
final prevList = List<CopilotHistoryItem>.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<void> 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<CopilotMessage>((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<void> 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<void> 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<int>.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<void> _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();
}
}

@ -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<String, dynamic> 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<CopilotImage> 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<String, dynamic> 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<String, dynamic> json) => CopilotStreamEvent(
type: json['type'] ?? '',
content: json['content'] is Map<String, dynamic> ? CopilotStreamContent.fromJson(json['content']) : null,
);
}
class CopilotStreamContent {
final String? answer;
final String? documentAnswer;
final String? websiteAnswer;
final int? chunksUsed;
final List<CopilotImage> images;
CopilotStreamContent({
this.answer,
this.documentAnswer,
this.websiteAnswer,
this.chunksUsed,
this.images = const [],
});
factory CopilotStreamContent.fromJson(Map<String, dynamic> 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<String, dynamic> json) => CopilotHistoryItem(
threadId: json['threadId'] ?? '',
firstHumanMessage: json['firstHumanMessage'] ?? '',
updatedAt: DateTime.tryParse(json['updatedAt'] ?? '') ?? DateTime.now(),
);
}

@ -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<CopilotChatPage> createState() => _CopilotChatPageState();
}
class _CopilotChatPageState extends State<CopilotChatPage> {
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<CopilotProvider>();
}
@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<CopilotProvider>().sendText(text);
_scrollToBottom();
}
Future<void> _startRecording() async {
await _recorderController.checkPermission();
if (!_recorderController.hasPermission) {
"Audio permission denied. Please enable from settings".showToast;
return;
}
setState(() => _inputState = _InputState.recording);
_recorderController.record();
}
Future<void> _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<CopilotProvider>().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<CopilotProvider>().loadHistory());
}).paddingOnly(end: 12),
],),
body: Column(
children: [
Expanded(
child: Consumer<CopilotProvider>(
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<CopilotProvider>(
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<CopilotProvider>(
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),
),
],
),
),
);
}
}

@ -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<CopilotHistoryPage> createState() => _CopilotHistoryPageState();
}
class _CopilotHistoryPageState extends State<CopilotHistoryPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<CopilotProvider>().loadHistory();
});
}
void _openConversation(String threadId) {
Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: false))).then((_) => context.read<CopilotProvider>().loadHistory());
context.read<CopilotProvider>().loadConversation(threadId);
}
void _startNewConversation() {
context.read<CopilotProvider>().startNewConversation();
Navigator.push(context, CupertinoPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read<CopilotProvider>().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<CopilotProvider>(
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')}";
}
}

@ -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,
),
),
);
}
}

@ -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/string_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_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/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/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/app_style/app_color.dart';
import 'package:test_sa/new_views/pages/login_page.dart'; import 'package:test_sa/new_views/pages/login_page.dart';
@ -27,15 +30,18 @@ class AppDrawer extends StatelessWidget {
final settingProvider = Provider.of<SettingProvider>(context, listen: false); final settingProvider = Provider.of<SettingProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context, listen: false); final userProvider = Provider.of<UserProvider>(context, listen: false);
return Drawer( return Drawer(
backgroundColor: AppColor.background(context), backgroundColor: AppColor.background(context),
width: MediaQuery.of(context).size.width * .8, width: MediaQuery
child: SafeArea( .of(context)
top: false, .size
child: Column( .width * .8,
mainAxisAlignment: MainAxisAlignment.start, child: SafeArea(
crossAxisAlignment: CrossAxisAlignment.start, top: false,
children: [ child: Column(
Row( mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -49,8 +55,8 @@ class AppDrawer extends StatelessWidget {
child: snapshot.profileImage != null child: snapshot.profileImage != null
? Image.file(snapshot.profileImage!) ? Image.file(snapshot.profileImage!)
: (snapshot.user?.profilePhotoName?.isNotEmpty ?? false) : (snapshot.user?.profilePhotoName?.isNotEmpty ?? false)
? Image.network(snapshot.user!.profilePhotoName!) ? Image.network(snapshot.user!.profilePhotoName!)
: Icon(Icons.person, size: 50, color: AppColor.icon2Color(context)), : Icon(Icons.person, size: 50, color: AppColor.icon2Color(context)),
), ),
), ),
); );
@ -60,66 +66,72 @@ class AppDrawer extends StatelessWidget {
), ),
8.height, 8.height,
(userProvider.user?.username ?? "").heading4(context).custom(color: AppColor.labelTextStyleColor(context)), (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)), if ((userProvider.user?.email ?? "").isNotEmpty)(userProvider.user!.email!).heading6(context).custom(color: AppColor.lightTextColor(context)),
16.height, 16.height,
const Divider().defaultStyle(context), const Divider().defaultStyle(context),
ListView( ListView(
padding: const EdgeInsets.only(top: 24), padding: const EdgeInsets.only(top: 24),
children: [ children: [
// drawerItem("drawer_notification", context.translation.notifications, context).onPress(() => Navigator.of(context).pushNamed(NotificationsPage.id)), // drawerItem("drawer_notification", context.translation.notifications, context).onPress(() => Navigator.of(context).pushNamed(NotificationsPage.id)),
// 18.height, // 18.height,
// drawerItem("notification_setting", 'Notification Settings'.addTranslation, context).onPress(() => Navigator.of(context).pushNamed(NotificationSettingPage.id)), // drawerItem("notification_setting", 'Notification Settings'.addTranslation, context).onPress(() => Navigator.of(context).pushNamed(NotificationSettingPage.id)),
// 18.height, // 18.height,
// drawerItem("help_center", context.translation.helpCenter, context) /*.onPress(() => Navigator.of(context).pushNamed(HelpCenterPage.id))*/, // drawerItem("help_center", context.translation.helpCenter, context) /*.onPress(() => Navigator.of(context).pushNamed(HelpCenterPage.id))*/,
// 18.height, // 18.height,
// drawerItem("rate_us", context.translation.rateUs, context), // drawerItem("rate_us", context.translation.rateUs, context),
// 18.height, // 18.height,
drawerItem("setting", context.translation.settings, context).onPress(() => Navigator.of(context).pushNamed(SettingsPage.id)), drawerItem("setting", context.translation.settings, context).onPress(() => Navigator.of(context).pushNamed(SettingsPage.id)),
18.height, 18.height,
drawerItem("site_request_icon", "Site Request", context).onPress(() => Navigator.of(context).pushNamed(SiteRequestHistoryScreen.routeName)), drawerItem("setting", "Copilot", context).onPress(() {
18.height, context.read<CopilotProvider>().startNewConversation();
if (userProvider.user != null && settingProvider.isUserFlowMedical == false && userProvider.user?.employeeIsHMG == false) Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotChatPage(isNewConversation: true))).then((_) => context.read<CopilotProvider>().loadHistory());
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,
// 18.height, drawerItem("site_request_icon", "Site Request", context).onPress(() => Navigator.of(context).pushNamed(SiteRequestHistoryScreen.routeName)),
// drawerItem("whats_new", context.translation.whatsNew, context), 18.height,
], if (userProvider.user != null && settingProvider.isUserFlowMedical == false && userProvider.user?.employeeIsHMG == false)
).expanded, drawerItem("swipe", "Swipe History", context).onPress(() => Navigator.of(context).pushNamed(SwipeHistoryView.routeName)),
drawerItem("logout", context.translation.logout, context, color: AppColor.red30).onPress(() async { // 18.height,
bool result = await showDialog( // drawerItem("report", context.translation.reportBg, context) /*.onPress(() => Navigator.of(context).pushNamed(ReportBugPage.id))*/,
context: context, // 18.height,
builder: (_) => AAlertDialog(title: context.translation.signOut, content: context.translation.logoutAlert), // drawerItem("whats_new", context.translation.whatsNew, context),
); ],
if (result) { ).expanded,
bool isSuccess = await userProvider.logout(context); drawerItem("logout", context.translation.logout, context, color: AppColor.red30).onPress(() async {
if (isSuccess) { bool result = await showDialog(
settingProvider.resetSettings(); context: context,
userProvider.reset(); builder: (_) => AAlertDialog(title: context.translation.signOut, content: context.translation.logoutAlert),
Navigator.of(context).pop(); );
Navigator.of(context).pop(); if (result) {
Navigator.of(context).pushNamedAndRemoveUntil(LoginPage.routeName, (routes) => true); bool isSuccess = await userProvider.logout(context);
} if (isSuccess) {
} settingProvider.resetSettings();
}), userProvider.reset();
16.height, Navigator.of(context).pop();
const Divider().defaultStyle(context), Navigator.of(context).pop();
16.height, Navigator.of(context).pushNamedAndRemoveUntil(LoginPage.routeName, (routes) => true);
Row( }
mainAxisAlignment: MainAxisAlignment.center, }
children: [ }),
Text( 16.height,
"Powered By Cloud Solutions", const Divider().defaultStyle(context),
style: AppTextStyles.heading3.copyWith(fontWeight: FontWeight.w500, color: context.isDark ? AppColor.neutral30 : AppColor.neutral50, fontSize: 12), 16.height,
textScaleFactor: AppStyle.getScaleFactor(context), Row(
), mainAxisAlignment: MainAxisAlignment.center,
6.width, children: [
Image.asset("assets/images/cloud_logo.png", width: 32, height: 32) 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),
).paddingOnly(top: 66, start: 16, end: 24, bottom: 18), ),
), 6.width,
Image.asset("assets/images/cloud_logo.png", width: 32, height: 32)
],
)
],
).paddingOnly(top: 66, start: 16, end: 24, bottom: 18),
),
); );
} }

@ -524,6 +524,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -998,6 +1006,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.2" version: "3.3.2"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

@ -109,6 +109,7 @@ dependencies:
local_auth_darwin: ^1.4.0 local_auth_darwin: ^1.4.0
haptic_feedback: ^0.6.4+3 haptic_feedback: ^0.6.4+3
sn_progress_dialog: ^1.2.0 sn_progress_dialog: ^1.2.0
flutter_markdown_plus: ^1.0.12
dev_dependencies: dev_dependencies:

Loading…
Cancel
Save