copilot added
parent
0863447e17
commit
8cc76265b1
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue