Add generation status and cancel control

This commit is contained in:
Hamza Ayed
2026-10-01 13:18:49 +03:00
parent 7a042d036c
commit 6b271ad499
7 changed files with 601 additions and 203 deletions
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_repository.dart';
@@ -10,6 +12,43 @@ class ChatCubit extends Cubit<ChatState> {
}
final ApiRepository _api;
Timer? _elapsedTimer;
Stopwatch? _requestStopwatch;
int _requestGeneration = 0;
List<ChatMessage> _messagesBeforeRequest = const [];
bool _requestAppendedUserMessage = true;
bool _requestHasProducedText = false;
void _startElapsedTimer(int generation) {
_elapsedTimer?.cancel();
_requestStopwatch = Stopwatch()..start();
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!isClosed && generation == _requestGeneration && state.isSending) {
emit(state.copyWith(elapsed: _requestStopwatch!.elapsed));
}
});
}
Duration _stopElapsedTimer() {
_elapsedTimer?.cancel();
_elapsedTimer = null;
final duration = _requestStopwatch?.elapsed ?? Duration.zero;
_requestStopwatch?.stop();
return duration;
}
String _readableError(Object error) => error.toString().replaceFirst(
RegExp(r'^(Exception|ClientException):\s*'),
'',
);
@override
Future<void> close() {
_requestGeneration++;
_elapsedTimer?.cancel();
_api.cancelActiveRequest();
return super.close();
}
Future<void> _loadModelName() async {
try {
@@ -19,6 +58,7 @@ class ChatCubit extends Cubit<ChatState> {
emit(
state.copyWith(
modelName: modelName,
modelStatus: ModelConnectionStatus.connected,
availableModels: models,
selectedModel: state.selectedModel ?? modelName,
),
@@ -29,6 +69,7 @@ class ChatCubit extends Cubit<ChatState> {
emit(
state.copyWith(
modelName: 'تعذر الاتصال بالخادم',
modelStatus: ModelConnectionStatus.unavailable,
availableModels: const [],
),
);
@@ -143,7 +184,9 @@ class ChatCubit extends Cubit<ChatState> {
final messages = List<ChatMessage>.from(state.messages);
final message = messages[messageIndex];
final versions = message.answerVersions;
if (message.fromUser || versionIndex < 0 || versionIndex >= versions.length) {
if (message.fromUser ||
versionIndex < 0 ||
versionIndex >= versions.length) {
return;
}
messages[messageIndex] = ChatMessage(
@@ -187,22 +230,36 @@ class ChatCubit extends Cubit<ChatState> {
? [...history, ChatMessage(prompt, fromUser: true)]
: List<ChatMessage>.from(history);
final originalMessages = state.messages;
_messagesBeforeRequest = originalMessages;
_requestAppendedUserMessage = appendUserMessage;
_requestHasProducedText = false;
var answer = '';
emit(state.copyWith(messages: messages, isSending: true, clearError: true));
final generation = ++_requestGeneration;
emit(
state.copyWith(
messages: messages,
isSending: true,
requestStatus: RequestStatus.generating,
elapsed: Duration.zero,
clearLastResponseDuration: true,
clearError: true,
),
);
try {
if (appendUserMessage) await _saveCurrent(messages);
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
emit(
state.copyWith(
messages: [...messages, const ChatMessage('', fromUser: false)],
),
);
_startElapsedTimer(generation);
if (state.isWorkspaceMode) {
answer = await _api.askWorkspaceAgent(
prompt,
model: state.selectedModel,
);
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
if (answer.trim().isEmpty) {
throw Exception('لم يصل نص في إجابة الوكيل.');
}
@@ -216,7 +273,16 @@ class ChatCubit extends Cubit<ChatState> {
selectedVersion: answerVersions.length - 1,
),
];
emit(state.copyWith(messages: updated, isSending: false));
final duration = _stopElapsedTimer();
emit(
state.copyWith(
messages: updated,
isSending: false,
requestStatus: RequestStatus.completed,
elapsed: duration,
lastResponseDuration: duration,
),
);
await _saveCurrent(updated);
return;
}
@@ -224,8 +290,9 @@ class ChatCubit extends Cubit<ChatState> {
messages,
model: state.selectedModel,
)) {
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
answer += delta;
if (delta.isNotEmpty) _requestHasProducedText = true;
final answerVersions = [...previousAnswers, answer];
emit(
state.copyWith(
@@ -241,7 +308,7 @@ class ChatCubit extends Cubit<ChatState> {
),
);
}
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
if (answer.trim().isEmpty) throw Exception('لم يصل نص في الإجابة.');
final answerVersions = [...previousAnswers, answer];
final updated = [
@@ -253,10 +320,20 @@ class ChatCubit extends Cubit<ChatState> {
selectedVersion: answerVersions.length - 1,
),
];
emit(state.copyWith(messages: updated, isSending: false));
final duration = _stopElapsedTimer();
emit(
state.copyWith(
messages: updated,
isSending: false,
requestStatus: RequestStatus.completed,
elapsed: duration,
lastResponseDuration: duration,
),
);
await _saveCurrent(updated);
} catch (error) {
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
final duration = _stopElapsedTimer();
final partial =
!appendUserMessage
? originalMessages
@@ -275,7 +352,10 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith(
messages: partial,
isSending: false,
error: error.toString(),
requestStatus: RequestStatus.failed,
elapsed: duration,
lastResponseDuration: duration,
error: _readableError(error),
),
);
if (appendUserMessage && answer.isNotEmpty) {
@@ -288,6 +368,49 @@ class ChatCubit extends Cubit<ChatState> {
}
}
Future<void> cancelGeneration() async {
if (!state.isSending) return;
_requestGeneration++;
_api.cancelActiveRequest();
final duration = _stopElapsedTimer();
final messages =
!_requestAppendedUserMessage && !_requestHasProducedText
? List<ChatMessage>.from(_messagesBeforeRequest)
: List<ChatMessage>.from(state.messages);
if (messages.isNotEmpty &&
!messages.last.fromUser &&
messages.last.text.isEmpty) {
messages.removeLast();
}
emit(
state.copyWith(
messages: messages,
isSending: false,
requestStatus: RequestStatus.cancelled,
elapsed: duration,
lastResponseDuration: duration,
clearError: true,
),
);
if (messages.isNotEmpty) {
try {
await _saveCurrent(messages);
if (!isClosed) {
emit(
state.copyWith(
isSending: false,
requestStatus: RequestStatus.cancelled,
elapsed: duration,
lastResponseDuration: duration,
),
);
}
} catch (_) {
// The visible partial reply stays available when local storage fails.
}
}
}
Future<void> openConversation(SavedConversation conversation) async {
if (state.isSending) return;
try {
@@ -296,6 +419,8 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith(
messages: fullConversation.messages,
activeConversationId: fullConversation.id,
requestStatus: RequestStatus.idle,
clearLastResponseDuration: true,
clearError: true,
),
);
@@ -310,6 +435,9 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith(
messages: const [],
clearActiveConversation: true,
requestStatus: RequestStatus.idle,
elapsed: Duration.zero,
clearLastResponseDuration: true,
clearError: true,
),
);
@@ -346,6 +474,7 @@ class ChatCubit extends Cubit<ChatState> {
emit(
state.copyWith(
apiBaseUrl: _api.baseUrl,
modelStatus: ModelConnectionStatus.checking,
availableModels: const [],
clearSelectedModel: true,
clearError: true,
@@ -1,3 +1,7 @@
enum ModelConnectionStatus { checking, connected, unavailable }
enum RequestStatus { idle, generating, completed, cancelled, failed }
class ChatMessage {
const ChatMessage(
this.text, {
@@ -18,14 +22,13 @@ class ChatMessage {
'selectedVersion': selectedVersion,
};
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
ChatMessage(
json['text'] as String,
fromUser: json['fromUser'] as bool,
answerVersions:
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
selectedVersion: json['selectedVersion'] as int? ?? 0,
);
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
json['text'] as String,
fromUser: json['fromUser'] as bool,
answerVersions:
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
selectedVersion: json['selectedVersion'] as int? ?? 0,
);
}
class SavedConversation {
@@ -80,6 +83,10 @@ class ChatState {
this.isWorkspaceMode = false,
this.error,
this.modelName = 'جارٍ اكتشاف النموذج…',
this.modelStatus = ModelConnectionStatus.checking,
this.requestStatus = RequestStatus.idle,
this.elapsed = Duration.zero,
this.lastResponseDuration,
this.availableModels = const [],
this.selectedModel,
required this.apiBaseUrl,
@@ -93,6 +100,10 @@ class ChatState {
final bool isWorkspaceMode;
final String? error;
final String modelName;
final ModelConnectionStatus modelStatus;
final RequestStatus requestStatus;
final Duration elapsed;
final Duration? lastResponseDuration;
final List<String> availableModels;
final String? selectedModel;
final String apiBaseUrl;
@@ -108,6 +119,11 @@ class ChatState {
String? error,
bool clearError = false,
String? modelName,
ModelConnectionStatus? modelStatus,
RequestStatus? requestStatus,
Duration? elapsed,
Duration? lastResponseDuration,
bool clearLastResponseDuration = false,
List<String>? availableModels,
String? selectedModel,
bool clearSelectedModel = false,
@@ -124,6 +140,13 @@ class ChatState {
isWorkspaceMode: isWorkspaceMode ?? this.isWorkspaceMode,
error: clearError ? null : (error ?? this.error),
modelName: modelName ?? this.modelName,
modelStatus: modelStatus ?? this.modelStatus,
requestStatus: requestStatus ?? this.requestStatus,
elapsed: elapsed ?? this.elapsed,
lastResponseDuration:
clearLastResponseDuration
? null
: (lastResponseDuration ?? this.lastResponseDuration),
availableModels: availableModels ?? this.availableModels,
selectedModel:
clearSelectedModel ? null : (selectedModel ?? this.selectedModel),
@@ -30,7 +30,8 @@ class _ChatPageState extends State<ChatPage> {
@override
void initState() {
super.initState();
_notificationsEnabled = !kIsWeb &&
_notificationsEnabled =
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS);
@@ -134,6 +135,13 @@ class _ChatPageState extends State<ChatPage> {
await Share.share(text);
}
String _formatDuration(Duration duration) {
if (duration.inMinutes > 0) {
return '${duration.inMinutes} د ${duration.inSeconds.remainder(60)} ث';
}
return '${(duration.inMilliseconds / 1000).toStringAsFixed(1)} ث';
}
Future<void> _enableNotifications() async {
final allowed =
await CompletionNotificationService.instance.requestPermission();
@@ -145,8 +153,8 @@ class _ChatPageState extends State<ChatPage> {
kIsWeb
? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.'
: allowed
? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
),
),
);
@@ -203,17 +211,22 @@ class _ChatPageState extends State<ChatPage> {
listener: (context, state) => _followStreamIfNearby(),
),
BlocListener<ChatCubit, ChatState>(
listenWhen: (previous, current) =>
previous.isSending && !current.isSending,
listenWhen:
(previous, current) => previous.isSending && !current.isSending,
listener: (context, state) {
final succeeded = state.error == null;
final succeeded = state.requestStatus == RequestStatus.completed;
final message = switch (state.requestStatus) {
RequestStatus.completed => 'اكتملت الإجابة.',
RequestStatus.cancelled => 'تم إيقاف التوليد.',
RequestStatus.failed =>
'فشل الطلب: ${state.error ?? 'سبب غير معروف'}',
_ => 'انتهى الطلب.',
};
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
succeeded ? 'اكتملت الإجابة.' : 'انتهى الطلب مع خطأ.',
),
content: Text(message),
duration: const Duration(seconds: 3),
),
);
@@ -385,15 +398,36 @@ class _ChatPageState extends State<ChatPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
Row(
children: [
Icon(Icons.circle, size: 9, color: _green),
SizedBox(width: 7),
Text(
'نموذج محلي',
style: TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
Icon(
Icons.circle,
size: 9,
color:
chat.modelStatus == ModelConnectionStatus.connected
? _green
: chat.modelStatus ==
ModelConnectionStatus.checking
? Colors.orange
: Colors.red,
),
const SizedBox(width: 7),
Flexible(
child: Text(
chat.isSending
? 'النموذج يولّد الآن'
: chat.modelStatus ==
ModelConnectionStatus.connected
? 'النموذج متصل'
: chat.modelStatus == ModelConnectionStatus.checking
? 'جارٍ الاتصال'
: 'النموذج غير متصل',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
],
@@ -453,143 +487,169 @@ class _ChatPageState extends State<ChatPage> {
Widget _topBar() => LayoutBuilder(
builder: (context, constraints) {
final chat = context.watch<ChatCubit>().state;
final compact = constraints.maxWidth < 640;
return Container(
height: 70,
padding: EdgeInsets.symmetric(horizontal: compact ? 12 : 26),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مساعدك المحلي',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
),
height: 70,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 26),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مساعدك المحلي',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
if (!compact) ...[
const SizedBox(height: 3),
Text(
chat.isSending
? 'جارٍ التوليد · ${_formatDuration(chat.elapsed)} · ${chat.modelName}'
: chat.modelStatus == ModelConnectionStatus.connected
? 'متصل · ${chat.modelName} · تفريغ الصوت عبر Groq'
: chat.modelStatus == ModelConnectionStatus.checking
? 'جارٍ الاتصال بالخادم…'
: 'غير متصل · ${chat.apiBaseUrl}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
chat.modelStatus == ModelConnectionStatus.connected
? Color(0xFF51806F)
: Color(0xFF9C3827),
fontSize: 11,
),
),
],
],
),
if (!compact) ...[
const SizedBox(height: 3),
const Text(
'المحادثة بالنموذج المحلي · تفريغ الصوت عبر Groq',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Color(0xFF7A867F), fontSize: 11),
),
],
],
),
),
SizedBox(width: compact ? 4 : 12),
IconButton(
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
tooltip:
context.watch<ChatCubit>().state.isWorkspaceMode
? 'إيقاف وضع الوكيل (قراءة فقط)'
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
color:
context.watch<ChatCubit>().state.isWorkspaceMode ? _green : _ink,
icon: Icon(
context.watch<ChatCubit>().state.isWorkspaceMode
? Icons.folder_open_rounded
: Icons.folder_outlined,
),
),
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 112 : 190),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value:
context.watch<ChatCubit>().state.availableModels.contains(
context.watch<ChatCubit>().state.selectedModel,
)
? context.watch<ChatCubit>().state.selectedModel
: context
),
SizedBox(width: compact ? 2 : 12),
IconButton(
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
tooltip:
context.watch<ChatCubit>().state.isWorkspaceMode
? 'إيقاف وضع الوكيل (قراءة فقط)'
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
color:
context.watch<ChatCubit>().state.isWorkspaceMode
? _green
: _ink,
icon: Icon(
context.watch<ChatCubit>().state.isWorkspaceMode
? Icons.folder_open_rounded
: Icons.folder_outlined,
),
),
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 100 : 190),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value:
context
.watch<ChatCubit>()
.state
.availableModels
.contains(
context
.watch<ChatCubit>()
.state
.selectedModel,
)
? context.watch<ChatCubit>().state.selectedModel
: context
.watch<ChatCubit>()
.state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>()
.state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>()
.state
.availableModels
.map(
(model) => DropdownMenuItem(
value: model,
child: Text(
model,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: const TextStyle(fontSize: 11),
),
),
)
.toList(),
onChanged: (model) {
if (model != null) {
context.read<ChatCubit>().selectModel(model);
}
},
),
),
),
SizedBox(width: compact ? 2 : 10),
Container(
padding: EdgeInsets.symmetric(horizontal: compact ? 7 : 11, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFE6F2EC),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.circle, size: 7, color: _green),
SizedBox(width: 7),
Text(
'محلي',
style: TextStyle(
color: _green,
fontSize: 11,
fontWeight: FontWeight.w600,
.map(
(model) => DropdownMenuItem(
value: model,
child: Text(
model,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: const TextStyle(fontSize: 11),
),
),
)
.toList(),
onChanged: (model) {
if (model != null) {
context.read<ChatCubit>().selectModel(model);
}
},
),
),
),
],
),
SizedBox(width: compact ? 2 : 10),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 11,
vertical: 7,
),
decoration: BoxDecoration(
color: const Color(0xFFE6F2EC),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.circle, size: 7, color: _green),
SizedBox(width: 7),
Text(
'محلي',
style: TextStyle(
color: _green,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
icon: const Icon(Icons.tune_rounded, color: _ink),
),
IconButton(
onPressed: _enableNotifications,
tooltip:
_notificationsEnabled
? 'إشعارات اكتمال المهام مفعلة'
: 'تفعيل إشعارات اكتمال المهام',
color: _notificationsEnabled ? _green : _ink,
icon: Icon(
_notificationsEnabled
? Icons.notifications_active_outlined
: Icons.notifications_none_outlined,
),
),
],
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
icon: const Icon(Icons.tune_rounded, color: _ink),
),
IconButton(
onPressed: _enableNotifications,
tooltip: _notificationsEnabled
? 'إشعارات اكتمال المهام مفعلة'
: 'تفعيل إشعارات اكتمال المهام',
color: _notificationsEnabled ? _green : _ink,
icon: Icon(
_notificationsEnabled
? Icons.notifications_active_outlined
: Icons.notifications_none_outlined,
),
),
],
),
);
);
},
);
@@ -675,7 +735,7 @@ class _ChatPageState extends State<ChatPage> {
padding: const EdgeInsets.fromLTRB(22, 28, 22, 20),
itemCount: chat.messages.length + (showTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == chat.messages.length) return _typing();
if (index == chat.messages.length) return _typing(chat);
final message = chat.messages[index];
return Align(
alignment:
@@ -710,6 +770,21 @@ class _ChatPageState extends State<ChatPage> {
)
else
_AssistantMarkdown(text: message.text),
if (!message.fromUser &&
!chat.isSending &&
index == chat.messages.length - 1 &&
chat.lastResponseDuration != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'${chat.modelName} · ${_formatDuration(chat.lastResponseDuration!)}',
textDirection: TextDirection.ltr,
style: const TextStyle(
color: Color(0xFF89948D),
fontSize: 10,
),
),
),
if (!message.fromUser && message.answerVersions.length > 1)
Align(
alignment: AlignmentDirectional.centerStart,
@@ -810,7 +885,7 @@ class _ChatPageState extends State<ChatPage> {
);
}
Widget _typing() => Align(
Widget _typing(ChatState chat) => Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 14),
@@ -820,18 +895,18 @@ class _ChatPageState extends State<ChatPage> {
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8EAE5)),
),
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
const SizedBox(width: 10),
Text(
'النموذج يفكر…',
style: TextStyle(color: Color(0xFF718078), fontSize: 12),
'النموذج يولّد · ${_formatDuration(chat.elapsed)}',
style: const TextStyle(color: Color(0xFF718078), fontSize: 12),
),
],
),
@@ -915,14 +990,20 @@ class _ChatPageState extends State<ChatPage> {
Padding(
padding: const EdgeInsets.all(7),
child: IconButton.filled(
onPressed: chat.isSending ? null : () => _send(),
onPressed:
chat.isSending
? () => context.read<ChatCubit>().cancelGeneration()
: () => _send(),
style: IconButton.styleFrom(
backgroundColor: _green,
backgroundColor: chat.isSending ? Colors.red : _green,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFAABDB5),
),
icon: const Icon(Icons.arrow_upward_rounded),
tooltip: 'إرسال',
icon: Icon(
chat.isSending
? Icons.stop_rounded
: Icons.arrow_upward_rounded,
),
tooltip: chat.isSending ? 'إيقاف التوليد' : 'إرسال',
),
),
],