godcrm/god_frame/lib/features/chat/ui/conversation_screen.dart
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

2036 lines
75 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme.dart';
import '../../../shared/utils/api_client.dart';
import '../data/models.dart';
import '../providers/conversations_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/call_provider.dart';
import '../widgets/message_bubble.dart';
import '../widgets/scheduled_messages_bar.dart';
import '../widgets/schedule_date_picker.dart';
import '../providers/scheduled_messages_provider.dart';
import '../../crm/ui/link_row_sheet.dart';
import 'chat_input.dart';
import 'call_screen.dart';
/// Conversation screen — message thread with AI chat.
/// Shows real-time streaming of reasoning chains and tool steps.
/// Thinking messages appear live as the agent processes.
class ConversationScreen extends ConsumerStatefulWidget {
final int conversationId;
const ConversationScreen({super.key, required this.conversationId});
@override
ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
}
class _ConversationScreenState extends ConsumerState<ConversationScreen> {
final _scrollController = ScrollController();
bool _sending = false;
String? _sendError;
bool _isNearBottom = true;
bool _initialScrollDone = false;
final Map<int, List<String>> _reactions = {}; // messageId -> list of emojis
bool _searchActive = false;
String _searchQuery = '';
final _searchController = TextEditingController();
bool _summaryLoading = false;
bool _showSchedulePicker = false;
final _chatInputKey = GlobalKey<ChatInputState>();
// Forward mode state
Message? _forwardingMessage;
Conversation? _forwardTargetConversation;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// CRITICAL FIX: await loadMessages BEFORE starting background poll.
// Previously both ran concurrently — the poll's immediate microtask would
// see empty state (AsyncValue.loading), capture currentMessages = [],
// and fetch ALL messages. Then loadMessages would overwrite state,
// causing race conditions that could lose or duplicate messages.
Future.microtask(() async {
final notifier = ref.read(messagesProvider.notifier);
// 1. Load messages first — await ensures state is populated
await notifier.loadMessages(widget.conversationId);
if (!mounted) return;
ref.read(activeConversationIdProvider.notifier).state = widget.conversationId;
// Mark conversation as read
ref.read(conversationsProvider.notifier).markAsRead(widget.conversationId);
// 2. Start background polling AFTER messages are loaded
// This way the poll sees correct lastId from loaded messages
notifier.startBackgroundPoll(widget.conversationId);
});
}
@override
void dispose() {
// Stop background polling when leaving conversation
// Use try-catch in case provider is already disposed
try {
ref.read(messagesProvider.notifier).stopBackgroundPoll();
} catch (_) {}
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
/// Track scroll position: near bottom → auto-scroll on new messages.
/// Near top → load older messages.
void _onScroll() {
if (!_scrollController.hasClients) return;
final pos = _scrollController.position;
// Consider "near bottom" if within 150px of the end
_isNearBottom = pos.pixels >= pos.maxScrollExtent - 150;
// Load older messages when scrolled near the top
if (pos.pixels <= 100) {
final notifier = ref.read(messagesProvider.notifier);
if (notifier.hasMore && !notifier.isLoadingMore) {
notifier.loadOlderMessages(widget.conversationId);
}
}
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
}
/// ADR-116: Wrap bare @mentions into structured <<@slug>> invocation tokens
/// before sending, matching web AI Chat Panel behavior.
String _wrapMentions(String text) {
final users = ref.read(mentionableUsersProvider).valueOrNull ?? [];
if (users.isEmpty) return text;
final slugSet = <String>{};
for (final u in users) {
if (u.isAgent) slugSet.add(u.slug);
}
// Replace bare @slug (not already wrapped in <<@>>) with <<@slug>>
return text.replaceAllMapped(
RegExp(r'(?<!<)@([a-z0-9_-]+)', caseSensitive: false),
(m) {
final slug = m.group(1)!.toLowerCase();
return slugSet.contains(slug) ? '<<@$slug>>' : m.group(0)!;
},
);
}
Future<void> _handleSend(String content, List<PendingAttachment> attachments) async {
if (content.trim().isEmpty && attachments.isEmpty) return;
if (_sending) return;
// ADR-116: Validate and wrap bare @mentions into <<@slug>> tokens
final wrappedContent = _wrapMentions(content.trim());
setState(() {
_sending = true;
_sendError = null;
});
final success = await ref
.read(messagesProvider.notifier)
.sendMessage(
widget.conversationId,
wrappedContent,
attachments: attachments.isNotEmpty ? attachments : null,
);
if (mounted) {
setState(() {
_sending = false;
if (!success) {
_sendError = ref.read(messagesProvider.notifier).lastError ??
'Failed to send message';
}
});
}
// User just sent a message — always scroll to bottom and track position
_isNearBottom = true;
_scrollToBottom();
if (!success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_sendError ?? 'Failed to send message'),
backgroundColor: GodTheme.error,
duration: const Duration(seconds: 5),
action: SnackBarAction(
label: 'Retry',
textColor: Colors.white,
onPressed: () => _handleSend(content, attachments),
),
),
);
}
}
/// Handle emoji reaction on a message.
void _handleReact(Message message, String emoji) {
setState(() {
final list = _reactions[message.id] ?? [];
// Toggle: if same emoji exists, remove it; otherwise add
if (list.contains(emoji)) {
list.remove(emoji);
} else {
list.add(emoji);
}
_reactions[message.id] = list;
});
HapticFeedback.lightImpact();
}
/// Handle forwarding a message — enter forward mode with card above input.
void _handleForward(Message message) {
setState(() {
_forwardingMessage = message;
_forwardTargetConversation = null; // user must pick target
});
}
/// Cancel forward mode.
void _cancelForward() {
setState(() {
_forwardingMessage = null;
_forwardTargetConversation = null;
});
}
/// Pick target conversation for forwarding.
Future<void> _pickForwardTarget() async {
final conversations = ref.read(conversationsProvider).valueOrNull ?? [];
if (conversations.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Нет доступных чатов')),
);
return;
}
final selected = await showModalBottomSheet<Conversation>(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.8,
expand: false,
builder: (ctx, scrollCtrl) => Column(
children: [
Container(
width: 36, height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(
color: GodTheme.textMuted.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.forward, color: GodTheme.textPrimary, size: 20),
SizedBox(width: 8),
Text('Переслать в...', style: TextStyle(
color: GodTheme.textPrimary, fontSize: 16, fontWeight: FontWeight.w600,
)),
],
),
),
const Divider(color: GodTheme.border),
Expanded(
child: ListView.builder(
controller: scrollCtrl,
itemCount: conversations.length,
itemBuilder: (ctx, i) {
final conv = conversations[i];
if (conv.id == widget.conversationId) return const SizedBox.shrink();
return ListTile(
key: ValueKey('fwd_conv_${conv.id}'),
leading: CircleAvatar(
radius: 18,
backgroundColor: GodTheme.primary.withOpacity(0.15),
child: const Icon(Icons.chat, size: 16, color: GodTheme.primary),
),
title: Text(conv.title,
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 14),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
subtitle: conv.lastMessage != null
? Text(conv.lastMessage!,
style: const TextStyle(color: GodTheme.textMuted, fontSize: 11),
maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
trailing: _forwardTargetConversation?.id == conv.id
? const Icon(Icons.check_circle, color: GodTheme.success, size: 20)
: null,
onTap: () => Navigator.pop(ctx, conv),
);
},
),
),
],
),
),
);
if (selected != null && mounted) {
setState(() => _forwardTargetConversation = selected);
}
}
/// Execute the forward — send message to target conversation.
Future<void> _executeForward() async {
final message = _forwardingMessage;
final target = _forwardTargetConversation;
if (message == null || target == null) return;
final senderLabel = message.agentName ?? (message.isUser ? 'Вы' : 'Ассистент');
final timestamp = message.createdAt.isNotEmpty
? ' (${_formatForwardTimestamp(message.createdAt)})'
: '';
final forwarded = '--- Переслано от $senderLabel$timestamp ---\n'
'${message.content}\n'
'--- конец пересланного сообщения ---\n\n'
'сточник: чат #${widget.conversationId}, сообщение #${message.id}_';
final repo = ref.read(chatRepositoryProvider);
final result = await repo.sendMessage(target.id, forwarded);
if (mounted) {
_cancelForward();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.isSuccess
? 'Переслано в "${target.title}"'
: 'Ошибка: ${result.error}'),
backgroundColor: result.isSuccess ? GodTheme.success : GodTheme.error,
duration: const Duration(seconds: 2),
),
);
}
}
/// Format timestamp for forwarded message header.
String _formatForwardTimestamp(String isoDate) {
try {
final dt = DateTime.parse(isoDate);
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}, '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
} catch (_) {
return isoDate;
}
}
/// Toggle schedule date picker.
void _toggleSchedulePicker() {
setState(() => _showSchedulePicker = !_showSchedulePicker);
}
/// Schedule the current input text for later.
Future<void> _handleSchedule(DateTime scheduledAt) async {
final inputState = _chatInputKey.currentState;
final text = inputState?.currentText ?? '';
if (text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите текст сообщения'), backgroundColor: GodTheme.error),
);
return;
}
final notifier = ref.read(scheduledMessagesProvider(widget.conversationId).notifier);
final error = await notifier.schedule(text, scheduledAt.toUtc().toIso8601String());
if (mounted) {
setState(() => _showSchedulePicker = false);
if (error == null) {
inputState?.clearText();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Сообщение запланировано'), backgroundColor: GodTheme.success),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $error'), backgroundColor: GodTheme.error),
);
}
}
}
/// Send a scheduled message immediately.
Future<void> _handleSendScheduledNow(ScheduledMessage sm) async {
final notifier = ref.read(scheduledMessagesProvider(widget.conversationId).notifier);
final error = await notifier.sendNow(sm);
if (mounted && error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $error'), backgroundColor: GodTheme.error),
);
}
}
/// Cancel a scheduled message.
Future<void> _handleCancelScheduled(ScheduledMessage sm) async {
final notifier = ref.read(scheduledMessagesProvider(widget.conversationId).notifier);
final error = await notifier.cancel(sm.id);
if (mounted && error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $error'), backgroundColor: GodTheme.error),
);
}
}
/// Edit (reschedule) a scheduled message — show picker with pre-filled content.
void _handleEditScheduled(ScheduledMessage sm) {
// For simplicity, show the date picker — on confirm, update the scheduled time
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => ScheduleDatePicker(
onSchedule: (dt) async {
Navigator.pop(ctx);
final notifier = ref.read(scheduledMessagesProvider(widget.conversationId).notifier);
final error = await notifier.edit(sm.id, scheduledAt: dt.toUtc().toIso8601String());
if (mounted && error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $error'), backgroundColor: GodTheme.error),
);
}
},
onCancel: () => Navigator.pop(ctx),
),
);
}
/// Handle link row to CHAT — binds the selected row to the conversation via API.
/// This is shown in the AppBar header.
Future<void> _handleLinkRowToChat() async {
final row = await showLinkRowSheet(context, ref);
if (row != null && mounted) {
final repo = ref.read(chatRepositoryProvider);
final result = await repo.bindRow(widget.conversationId, row.tableId, row.id);
if (mounted) {
if (result.isSuccess) {
ref.invalidate(conversationDetailProvider(widget.conversationId));
ref.invalidate(conversationsProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Привязано: ${row.displayTitle ?? "Row #${row.id}"}'),
backgroundColor: GodTheme.success,
duration: const Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка: ${result.error}'),
backgroundColor: GodTheme.error,
duration: const Duration(seconds: 3),
),
);
}
}
}
}
/// Handle link row to MESSAGE — inserts row reference into the message text.
/// This is shown in the input area.
Future<void> _handleLinkRowToMessage() async {
final row = await showLinkRowSheet(context, ref);
if (row != null && mounted) {
final title = row.displayTitle ?? 'Row #${row.id}';
final rowRef = '[📎 Table:${row.tableId} / $title (#${row.id})]';
_handleSend(rowRef, []);
}
}
/// Handle summary — calls the summary API endpoint (like AI Chat Panel).
/// The backend triggers the summary agent to process the conversation.
Future<void> _handleSummary() async {
setState(() => _summaryLoading = true);
final dio = ref.read(apiClientProvider);
try {
await dio.post('/chat/conversations/${widget.conversationId}/summary');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Сводка создана'),
backgroundColor: GodTheme.success,
duration: Duration(seconds: 2),
),
);
// Refresh messages to show the summary
ref.read(messagesProvider.notifier).loadMessages(widget.conversationId);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка создания сводки: $e'),
backgroundColor: GodTheme.error,
duration: const Duration(seconds: 3),
),
);
}
} finally {
if (mounted) setState(() => _summaryLoading = false);
}
}
/// Start audio call via LiveKit.
/// [withRecording] - true = call with logging/recording (default), false = silent call.
Future<void> _handleStartCall(BuildContext context, String title, List<Participant> participants, {bool withRecording = true}) async {
final callNotifier = ref.read(callProvider.notifier);
final currentCall = ref.read(callProvider);
if (currentCall.state == CallState.connected || currentCall.state == CallState.connecting) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Звонок уже активен'),
backgroundColor: GodTheme.warning,
),
);
return;
}
// Start call with or without recording
callNotifier.startCall(widget.conversationId, withRecording: withRecording);
// Open call screen
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CallScreen(
conversationId: widget.conversationId,
title: title,
participantNames: participants.map((p) => p.name).toList(),
withRecording: withRecording,
),
),
);
}
}
/// Show rename dialog for the conversation title.
void _showRenameDialog(BuildContext context, WidgetRef ref, String currentTitle) {
final controller = TextEditingController(text: currentTitle);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: GodTheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Rename Conversation', style: TextStyle(color: GodTheme.textPrimary)),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: GodTheme.textPrimary),
decoration: InputDecoration(
hintText: 'Enter new name',
hintStyle: const TextStyle(color: GodTheme.textMuted),
filled: true,
fillColor: GodTheme.surfaceLight,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: GodTheme.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: GodTheme.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: GodTheme.primary, width: 2),
),
),
onSubmitted: (value) async {
if (value.trim().isNotEmpty && value.trim() != currentTitle) {
Navigator.pop(ctx);
final error = await ref
.read(conversationsProvider.notifier)
.rename(widget.conversationId, value.trim());
if (error == null) {
ref.invalidate(conversationDetailProvider(widget.conversationId));
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Rename failed: $error'),
backgroundColor: GodTheme.error,
duration: const Duration(seconds: 5),
),
);
}
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel', style: TextStyle(color: GodTheme.textMuted)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: GodTheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () async {
final value = controller.text.trim();
if (value.isNotEmpty && value != currentTitle) {
Navigator.pop(ctx);
final error = await ref
.read(conversationsProvider.notifier)
.rename(widget.conversationId, value);
if (error == null) {
ref.invalidate(conversationDetailProvider(widget.conversationId));
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Rename failed: $error'),
backgroundColor: GodTheme.error,
duration: const Duration(seconds: 5),
),
);
}
}
},
child: const Text('Save'),
),
],
),
);
}
/// Show participants bottom sheet.
void _showParticipantsSheet(List<Participant> participants) {
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: GodTheme.textMuted.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
const Icon(Icons.group, color: GodTheme.textPrimary, size: 20),
const SizedBox(width: 8),
Text(
'Participants (${participants.length})',
style: const TextStyle(
color: GodTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
const Divider(color: GodTheme.border),
...participants.map((p) => ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: GodTheme.primary.withOpacity(0.15),
child: Text(
_getInitials(p.name),
style: const TextStyle(
color: GodTheme.primary,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
title: Text(
p.name,
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 14),
),
subtitle: p.email != null
? Text(
p.email!,
style: const TextStyle(color: GodTheme.textMuted, fontSize: 12),
)
: null,
trailing: Text(
p.role,
style: const TextStyle(color: GodTheme.textMuted, fontSize: 11),
),
)),
const Divider(color: GodTheme.border),
ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: GodTheme.success.withOpacity(0.1),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.person_add, color: GodTheme.success, size: 18),
),
title: const Text(
'Add Participant',
style: TextStyle(color: GodTheme.success, fontWeight: FontWeight.w600),
),
onTap: () {
Navigator.pop(ctx);
_showAddParticipantPicker(participants);
},
),
],
),
),
),
);
}
/// Show contacts picker to add a new participant.
void _showAddParticipantPicker(List<Participant> existingParticipants) {
final existingIds = existingParticipants.map((p) => p.id).toSet();
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (ctx, scrollController) {
return Consumer(
builder: (ctx, ref, _) {
final contactsAsync = ref.watch(contactsProvider);
return Column(
children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(
color: GodTheme.textMuted.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.person_add, color: GodTheme.textPrimary, size: 20),
SizedBox(width: 8),
Text(
'Add Participant',
style: TextStyle(
color: GodTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
const Divider(color: GodTheme.border),
Expanded(
child: contactsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Text('Error: $err', style: const TextStyle(color: GodTheme.error)),
),
data: (contacts) {
final available = contacts
.where((c) => !existingIds.contains(c.id))
.toList();
if (available.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'All contacts are already participants',
style: TextStyle(color: GodTheme.textMuted),
),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: available.length,
itemBuilder: (ctx, index) {
final contact = available[index];
return ListTile(
key: ValueKey('contact_${contact.id}'),
leading: CircleAvatar(
radius: 18,
backgroundColor: GodTheme.accent.withOpacity(0.15),
child: Text(
contact.initials,
style: const TextStyle(
color: GodTheme.accent,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
title: Text(
contact.name,
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 14),
),
subtitle: contact.email != null
? Text(
contact.email!,
style: const TextStyle(color: GodTheme.textMuted, fontSize: 12),
)
: null,
trailing: const Icon(Icons.add_circle_outline, color: GodTheme.success),
onTap: () async {
Navigator.pop(ctx);
await _addParticipant(contact);
},
);
},
);
},
),
),
],
);
},
);
},
),
);
}
/// Add a participant to the current conversation.
Future<void> _addParticipant(Contact contact) async {
final repo = ref.read(chatRepositoryProvider);
final result = await repo.addParticipant(widget.conversationId, contact.id);
if (result.isSuccess && mounted) {
ref.invalidate(conversationDetailProvider(widget.conversationId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${contact.name} added to conversation'),
backgroundColor: GodTheme.success,
duration: const Duration(seconds: 2),
),
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to add participant: ${result.error ?? "Unknown error"}'),
backgroundColor: GodTheme.error,
duration: const Duration(seconds: 3),
),
);
}
}
String _getInitials(String name) {
final parts = name.trim().split(RegExp(r'\s+'));
if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.isNotEmpty ? name[0].toUpperCase() : '?';
}
/// Build stacked participant avatars for the AppBar.
Widget _buildParticipantAvatars(List<Participant> participants) {
if (participants.isEmpty) return const SizedBox.shrink();
final maxShow = 3;
final shown = participants.take(maxShow).toList();
final extra = participants.length - maxShow;
return SizedBox(
width: (shown.length * 18.0) + 8,
height: 28,
child: Stack(
clipBehavior: Clip.none,
children: [
for (int i = 0; i < shown.length; i++)
Positioned(
left: i * 14.0,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: GodTheme.primary.withOpacity(0.2),
shape: BoxShape.circle,
border: Border.all(color: GodTheme.surface, width: 1.5),
),
child: Center(
child: Text(
_getInitials(shown[i].name),
style: const TextStyle(
color: GodTheme.primary,
fontSize: 9,
fontWeight: FontWeight.w700,
),
),
),
),
),
if (extra > 0)
Positioned(
left: shown.length * 14.0,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
shape: BoxShape.circle,
border: Border.all(color: GodTheme.surface, width: 1.5),
),
child: Center(
child: Text(
'+$extra',
style: const TextStyle(
color: GodTheme.textMuted,
fontSize: 9,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
);
}
/// Build display messages with real-time streaming support.
/// Completed responses: thinking + tools are grouped into the assistant message.
/// In-progress: orphan thinking/tools shown as live streaming block.
List<_DisplayMessage> _buildDisplayMessages(List<Message> allMessages) {
final display = <_DisplayMessage>[];
int pendingToolSteps = 0;
final pendingThinking = <Message>[];
for (final msg in allMessages) {
// Collect thinking messages (reasoning chains)
if (msg.contentType == 'thinking' && msg.content.trim().isNotEmpty) {
pendingThinking.add(msg);
continue;
}
// Count tool steps silently
if (msg.isToolStep) {
if (msg.contentType == 'tool_call') {
pendingToolSteps++;
}
continue;
}
// This is a visible message
if (msg.isHumanVisible) {
int toolCount = 0;
List<Message> thinkingChain = [];
if (msg.isAssistant) {
// Attach accumulated thinking and tools to this assistant message
if (pendingThinking.isNotEmpty) {
thinkingChain = List.from(pendingThinking);
pendingThinking.clear();
}
if (pendingToolSteps > 0) {
toolCount = pendingToolSteps;
pendingToolSteps = 0;
}
}
// Also check toolResults on the message itself
if (msg.toolResults != null && msg.toolResults!.isNotEmpty) {
toolCount = msg.toolResults!.length;
}
display.add(_DisplayMessage(
message: msg,
toolStepCount: toolCount,
thinkingChain: thinkingChain,
));
}
}
// STREAMING: If there are orphan thinking/tool messages at the end
// (AI is still processing), show them as a live streaming block
if (pendingThinking.isNotEmpty || pendingToolSteps > 0) {
display.add(_DisplayMessage(
message: Message(
id: -999,
conversationId: 0,
role: 'assistant',
content: '',
createdAt: DateTime.now().toIso8601String(),
),
toolStepCount: pendingToolSteps,
thinkingChain: List.from(pendingThinking),
isStreaming: true,
));
}
return display;
}
@override
Widget build(BuildContext context) {
final messagesAsync = ref.watch(messagesProvider);
final detailAsync = ref.watch(conversationDetailProvider(widget.conversationId));
final notifier = ref.watch(messagesProvider.notifier);
final isPolling = notifier.isPolling;
final title = detailAsync.whenOrNull(
data: (detail) => detail?.conversation.title,
) ?? 'Chat';
final participants = detailAsync.whenOrNull(
data: (detail) => detail?.conversation.participants,
) ?? [];
var conversation = detailAsync.whenOrNull(
data: (detail) => detail?.conversation,
);
// Detail endpoint may not include bound_row_title/bound_table_name (only listing does).
// Fallback: enrich from conversations list which has the JOINed fields.
if (conversation != null && conversation.boundRowId != null && conversation.boundRowTitle == null) {
final convList = ref.watch(conversationsProvider).valueOrNull ?? [];
final fromList = convList.where((c) => c.id == conversation!.id).firstOrNull;
if (fromList != null && fromList.boundRowTitle != null) {
conversation = Conversation(
id: conversation.id,
title: conversation.title,
lastMessage: conversation.lastMessage,
lastMessageAt: conversation.lastMessageAt,
messageCount: conversation.messageCount,
unreadCount: conversation.unreadCount,
createdAt: conversation.createdAt,
participants: conversation.participants,
boundTableId: conversation.boundTableId,
boundRowId: conversation.boundRowId,
boundRowTitle: fromList.boundRowTitle,
boundTableName: fromList.boundTableName,
boundTableIcon: fromList.boundTableIcon,
);
}
}
// Auto-scroll ONLY when user is near bottom and new messages arrive
ref.listen<AsyncValue<List<Message>>>(messagesProvider, (prev, next) {
final prevCount = prev?.valueOrNull?.length ?? 0;
final nextCount = next.valueOrNull?.length ?? 0;
if (nextCount > prevCount && _isNearBottom) {
_scrollToBottom();
}
});
return Scaffold(
appBar: AppBar(
title: GestureDetector(
onTap: () => _showRenameDialog(context, ref, title),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (participants.isNotEmpty) ...[
_buildParticipantAvatars(participants),
const SizedBox(width: 6),
],
Flexible(
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 6),
const Icon(Icons.edit_outlined, size: 16, color: GodTheme.textMuted),
],
),
),
),
body: Column(
children: [
// Toolbar strip below title
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: const BoxDecoration(
color: GodTheme.surface,
border: Border(bottom: BorderSide(color: GodTheme.border, width: 0.5)),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
// Call button — prominent, green, first position
GestureDetector(
onTap: () => _handleStartCall(context, title, participants, withRecording: true),
onLongPress: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Звонок без записи'),
backgroundColor: GodTheme.warning,
duration: Duration(seconds: 1),
),
);
_handleStartCall(context, title, participants, withRecording: false);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF10B981),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.phone, size: 16, color: Colors.white),
SizedBox(width: 6),
Text('Звонок', style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
)),
],
),
),
),
_ToolbarBtn(
icon: Icons.summarize_outlined,
label: 'Суммари',
loading: _summaryLoading,
onTap: _summaryLoading ? null : _handleSummary,
),
_ToolbarBtn(
icon: Icons.group_outlined,
label: 'Контакты',
onTap: participants.isNotEmpty
? () => _showParticipantsSheet(participants)
: null,
),
_ToolbarBtn(
icon: Icons.refresh,
label: 'Обновить',
onTap: () => ref.read(messagesProvider.notifier).loadMessages(widget.conversationId),
),
_ToolbarBtn(
icon: Icons.link_rounded,
label: 'Привязать',
onTap: _handleLinkRowToChat,
),
_ToolbarBtn(
icon: _searchActive ? Icons.search_off : Icons.search,
label: 'Поиск',
active: _searchActive,
onTap: () {
setState(() {
_searchActive = !_searchActive;
if (!_searchActive) {
_searchQuery = '';
_searchController.clear();
}
});
},
),
],
),
),
),
// Search bar
if (_searchActive)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: const BoxDecoration(
color: GodTheme.surfaceLight,
border: Border(bottom: BorderSide(color: GodTheme.border, width: 0.5)),
),
child: TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 14),
decoration: InputDecoration(
hintText: 'Поиск по сообщениям...',
hintStyle: const TextStyle(color: GodTheme.textMuted, fontSize: 14),
prefixIcon: const Icon(Icons.search, size: 18, color: GodTheme.textMuted),
suffixIcon: _searchQuery.isNotEmpty
? GestureDetector(
onTap: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
child: const Icon(Icons.close, size: 18, color: GodTheme.textMuted),
)
: null,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
filled: true,
fillColor: GodTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: GodTheme.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: GodTheme.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: GodTheme.primary, width: 1.5),
),
),
onChanged: (val) => setState(() => _searchQuery = val.toLowerCase()),
),
),
// Bound row strip
if (conversation?.boundRowId != null)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withOpacity(0.1),
border: const Border(
bottom: BorderSide(color: Color(0xFF3B82F6), width: 0.5),
),
),
child: Row(
children: [
const Icon(Icons.link, size: 14, color: Color(0xFF60A5FA)),
const SizedBox(width: 6),
if (conversation!.boundTableIcon != null) ...[
Text(conversation.boundTableIcon!, style: const TextStyle(fontSize: 12)),
const SizedBox(width: 4),
],
Expanded(
child: Text(
'${conversation.boundTableName != null ? '${conversation.boundTableName}: ' : ''}'
'${conversation.boundRowTitle ?? '#${conversation.boundRowId}'}',
style: const TextStyle(
color: Color(0xFF60A5FA),
fontSize: 12,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// Error banner
if (_sendError != null)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: GodTheme.error.withOpacity(0.1),
child: Row(
children: [
const Icon(Icons.error_outline, size: 16, color: GodTheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
_sendError!,
style: const TextStyle(color: GodTheme.error, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
GestureDetector(
onTap: () => setState(() => _sendError = null),
child: const Icon(Icons.close, size: 16, color: GodTheme.error),
),
],
),
),
// Messages list
Expanded(
child: messagesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: GodTheme.error),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
err.toString(),
style: const TextStyle(color: GodTheme.error),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: () => ref.read(messagesProvider.notifier)
.loadMessages(widget.conversationId),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (messages) {
var displayMessages = _buildDisplayMessages(messages);
// Filter by search query
if (_searchQuery.isNotEmpty) {
displayMessages = displayMessages.where((dm) {
final content = dm.message.content.toLowerCase();
final agent = (dm.message.agentName ?? '').toLowerCase();
return content.contains(_searchQuery) || agent.contains(_searchQuery);
}).toList();
}
if (displayMessages.isEmpty && !isPolling) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: GodTheme.primary.withOpacity(0.1),
),
child: Icon(
Icons.chat_bubble_outline,
size: 28,
color: GodTheme.primary.withOpacity(0.5),
),
),
const SizedBox(height: 16),
const Text(
'Start the conversation',
style: TextStyle(
color: GodTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
const Text(
'Use @ to mention agents or people',
style: TextStyle(color: GodTheme.textMuted, fontSize: 14),
),
],
),
);
}
// Only auto-scroll to bottom on initial load, not on every rebuild
if (!_initialScrollDone) {
_initialScrollDone = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
}
final hasMoreMessages = notifier.hasMore;
final isLoadingMore = notifier.isLoadingMore;
final extraTop = (hasMoreMessages || isLoadingMore) ? 1 : 0;
final extraBottom = (isPolling && !displayMessages.any((d) => d.isStreaming)) ? 1 : 0;
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.only(left: 12, right: 12, top: 8, bottom: 2),
itemCount: extraTop + displayMessages.length + extraBottom,
itemBuilder: (context, index) {
// "Load more" indicator at the top
if (index < extraTop) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: isLoadingMore
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: GodTheme.primary.withOpacity(0.6),
),
)
: GestureDetector(
onTap: () => ref.read(messagesProvider.notifier)
.loadOlderMessages(widget.conversationId),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: GodTheme.border),
),
child: const Text(
'Load earlier messages',
style: TextStyle(color: GodTheme.textMuted, fontSize: 13),
),
),
),
),
);
}
final msgIndex = index - extraTop;
// Show "AI is thinking" at the bottom when polling but no streaming content yet
if (msgIndex >= displayMessages.length) {
return _SimpleThinkingIndicator(
key: const ValueKey('thinking_indicator'),
agentName: notifier.processingAgentName,
);
}
final dm = displayMessages[msgIndex];
// Streaming reasoning block — live display of thinking/tools
if (dm.isStreaming) {
return _StreamingReasoningBlock(
key: const ValueKey('streaming_reasoning'),
thinkingChain: dm.thinkingChain,
toolStepCount: dm.toolStepCount,
agentName: notifier.processingAgentName,
);
}
final prevMsg = msgIndex > 0 ? displayMessages[msgIndex - 1].message : null;
final showAvatar = prevMsg == null || prevMsg.role != dm.message.role;
return MessageBubble(
key: ValueKey('msg_${dm.message.id}'),
message: dm.message,
showAvatar: showAvatar,
toolStepCount: dm.toolStepCount,
thinkingChain: dm.thinkingChain,
baseUrl: getCurrentBaseUrl(),
onForward: _handleForward,
onReact: _handleReact,
reactions: _reactions,
);
},
);
},
),
),
// Scheduled messages bar
Builder(
builder: (context) {
final scheduled = ref.watch(scheduledMessagesProvider(widget.conversationId));
final items = scheduled.valueOrNull ?? [];
if (items.isEmpty) return const SizedBox.shrink();
return ScheduledMessagesBar(
messages: items,
onSendNow: _handleSendScheduledNow,
onEdit: _handleEditScheduled,
onCancel: _handleCancelScheduled,
);
},
),
// Forward card — shown when user taps forward on a message
if (_forwardingMessage != null)
_ForwardCard(
message: _forwardingMessage!,
targetConversation: _forwardTargetConversation,
sourceConversationId: widget.conversationId,
onPickTarget: _pickForwardTarget,
onSend: _executeForward,
onCancel: _cancelForward,
),
// Schedule date picker (shown when tapping schedule button)
if (_showSchedulePicker)
ScheduleDatePicker(
onSchedule: _handleSchedule,
onCancel: () => setState(() => _showSchedulePicker = false),
),
// Chat input with file attachment support, @mentions, link, and mic
Builder(
builder: (context) {
final mentionableUsers = ref.watch(mentionableUsersProvider).valueOrNull ?? [];
return ChatInput(
key: _chatInputKey,
onSend: _handleSend,
enabled: !_sending,
users: mentionableUsers,
onLinkRow: _handleLinkRowToMessage,
onSchedule: _toggleSchedulePicker,
);
},
),
],
),
);
}
}
/// Internal display message with computed tool step count and thinking chain.
class _DisplayMessage {
final Message message;
final int toolStepCount;
final List<Message> thinkingChain;
final bool isStreaming;
const _DisplayMessage({
required this.message,
this.toolStepCount = 0,
this.thinkingChain = const [],
this.isStreaming = false,
});
}
/// Simple "AI is thinking..." indicator when no streaming content yet.
class _SimpleThinkingIndicator extends StatelessWidget {
final String? agentName;
const _SimpleThinkingIndicator({super.key, this.agentName});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: GodTheme.primary.withOpacity(0.6),
),
),
const SizedBox(width: 10),
Text(
agentName != null
? '@$agentName is thinking...'
: 'AI is thinking...',
style: const TextStyle(color: GodTheme.textMuted, fontSize: 13),
),
],
),
);
}
}
/// Live streaming reasoning block — shows thinking/tool steps in real-time.
/// Displayed when the AI agent is still processing (orphan thinking messages).
class _StreamingReasoningBlock extends StatefulWidget {
final List<Message> thinkingChain;
final int toolStepCount;
final String? agentName;
const _StreamingReasoningBlock({
super.key,
required this.thinkingChain,
this.toolStepCount = 0,
this.agentName,
});
@override
State<_StreamingReasoningBlock> createState() => _StreamingReasoningBlockState();
}
class _StreamingReasoningBlockState extends State<_StreamingReasoningBlock>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Combine all thinking content
final allThinkingText = widget.thinkingChain
.map((m) => m.content.trim())
.where((c) => c.isNotEmpty)
.join('\n\n');
// Show last 300 chars for live preview
final preview = allThinkingText.length > 300
? allThinkingText.substring(allThinkingText.length - 300)
: allThinkingText;
return Padding(
padding: const EdgeInsets.only(top: 12, bottom: 2, right: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Animated pulsing avatar
AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Color.lerp(
const Color(0xFF8B5CF6),
const Color(0xFF6366F1),
_pulseController.value,
)!,
Color.lerp(
const Color(0xFF6366F1),
const Color(0xFF8B5CF6),
_pulseController.value,
)!,
],
),
),
child: const Center(
child: Icon(Icons.psychology, size: 16, color: Colors.white),
),
);
},
),
const SizedBox(width: 8),
// Streaming content
Flexible(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF1A1A2E),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
bottomRight: Radius.circular(18),
bottomLeft: Radius.circular(4),
),
border: Border.all(
color: const Color(0xFF8B5CF6).withOpacity(0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with pulsing icon
Row(
children: [
AnimatedBuilder(
animation: _pulseController,
builder: (context, _) {
return Icon(
Icons.psychology,
size: 14,
color: Color.lerp(
const Color(0xFF8B5CF6),
const Color(0xFFa78bfa),
_pulseController.value,
),
);
},
),
const SizedBox(width: 6),
Flexible(
child: Text(
widget.agentName != null
? '@${widget.agentName} reasoning...'
: 'Reasoning...',
style: const TextStyle(
color: Color(0xFF8B5CF6),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
if (widget.thinkingChain.isNotEmpty) ...[
const SizedBox(width: 8),
Text(
'${widget.thinkingChain.length} step${widget.thinkingChain.length != 1 ? "s" : ""}',
style: TextStyle(
color: const Color(0xFF8B5CF6).withOpacity(0.6),
fontSize: 11,
),
),
],
],
),
// Tool steps count
if (widget.toolStepCount > 0) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: GodTheme.background,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.build_outlined, size: 12, color: GodTheme.textMuted),
const SizedBox(width: 4),
Text(
'Used ${widget.toolStepCount} tool${widget.toolStepCount != 1 ? "s" : ""}',
style: const TextStyle(
color: GodTheme.textMuted,
fontSize: 11,
),
),
],
),
),
],
// Live thinking text preview (auto-scrolls to bottom)
if (preview.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(maxHeight: 150),
decoration: BoxDecoration(
color: const Color(0xFF0F0F1A),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: const Color(0xFF8B5CF6).withOpacity(0.1),
),
),
child: SingleChildScrollView(
reverse: true, // Auto-scroll to latest content
child: Text(
preview,
style: TextStyle(
color: GodTheme.textSecondary.withOpacity(0.7),
fontSize: 11,
height: 1.4,
fontFamily: 'monospace',
fontStyle: FontStyle.italic,
),
),
),
),
],
// Typing indicator dots
const SizedBox(height: 8),
const _TypingDots(),
],
),
),
),
],
),
);
}
}
/// Animated typing dots indicator.
class _TypingDots extends StatefulWidget {
const _TypingDots();
@override
State<_TypingDots> createState() => _TypingDotsState();
}
class _TypingDotsState extends State<_TypingDots> with TickerProviderStateMixin {
late List<AnimationController> _controllers;
@override
void initState() {
super.initState();
_controllers = List.generate(3, (index) {
return AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
});
// Stagger the animations
for (int i = 0; i < _controllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 200), () {
if (mounted) _controllers[i].repeat(reverse: true);
});
}
}
@override
void dispose() {
for (final c in _controllers) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _controllers[index],
builder: (context, _) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.lerp(
const Color(0xFF8B5CF6).withOpacity(0.3),
const Color(0xFF8B5CF6),
_controllers[index].value,
),
),
);
},
);
}),
);
}
}
/// Forward card — shows forwarded message preview above input with target chat selector.
class _ForwardCard extends StatelessWidget {
final Message message;
final Conversation? targetConversation;
final int sourceConversationId;
final VoidCallback onPickTarget;
final VoidCallback onSend;
final VoidCallback onCancel;
const _ForwardCard({
required this.message,
required this.targetConversation,
required this.sourceConversationId,
required this.onPickTarget,
required this.onSend,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final senderLabel = message.agentName ?? (message.isUser ? 'Вы' : 'Ассистент');
final preview = message.content.length > 120
? '${message.content.substring(0, 120)}...'
: message.content;
return Container(
decoration: BoxDecoration(
color: GodTheme.surface,
border: Border(
top: BorderSide(color: const Color(0xFF8B5CF6).withOpacity(0.4), width: 1),
),
),
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: forward icon + label + close
Row(
children: [
const Icon(Icons.forward, size: 16, color: Color(0xFF8B5CF6)),
const SizedBox(width: 6),
const Expanded(
child: Text('Пересылка сообщения',
style: TextStyle(color: Color(0xFF8B5CF6), fontSize: 12, fontWeight: FontWeight.w600)),
),
GestureDetector(
onTap: onCancel,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.close, size: 16, color: GodTheme.textMuted),
),
),
],
),
const SizedBox(height: 6),
// Message preview
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF8B5CF6).withOpacity(0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF8B5CF6).withOpacity(0.15)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(senderLabel,
style: const TextStyle(color: Color(0xFF8B5CF6), fontSize: 11, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(preview,
style: const TextStyle(color: GodTheme.textSecondary, fontSize: 12),
maxLines: 3, overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(height: 6),
// Target chat selector + send button
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onPickTarget,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: GodTheme.border),
),
child: Row(
children: [
Icon(
targetConversation != null ? Icons.chat : Icons.chat_bubble_outline,
size: 14,
color: targetConversation != null ? GodTheme.primary : GodTheme.textMuted,
),
const SizedBox(width: 6),
Expanded(
child: Text(
targetConversation?.title ?? 'Выберите чат...',
style: TextStyle(
color: targetConversation != null ? GodTheme.textPrimary : GodTheme.textMuted,
fontSize: 13,
),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.keyboard_arrow_down, size: 16, color: GodTheme.textMuted),
],
),
),
),
),
const SizedBox(width: 8),
// Send forward button
GestureDetector(
onTap: targetConversation != null ? onSend : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: targetConversation != null
? const Color(0xFF8B5CF6)
: GodTheme.textMuted.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.send, size: 14,
color: targetConversation != null ? Colors.white : GodTheme.textMuted),
const SizedBox(width: 4),
Text('Переслать',
style: TextStyle(
color: targetConversation != null ? Colors.white : GodTheme.textMuted,
fontSize: 12, fontWeight: FontWeight.w600)),
],
),
),
),
],
),
],
),
);
}
}
/// Compact toolbar button with icon and label for the chat toolbar strip.
class _ToolbarBtn extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool active;
final bool loading;
const _ToolbarBtn({
required this.icon,
required this.label,
this.onTap,
this.onLongPress,
this.active = false,
this.loading = false,
});
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final color = active
? GodTheme.primary
: enabled
? GodTheme.textSecondary
: GodTheme.textMuted.withOpacity(0.4);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(8),
decoration: active
? BoxDecoration(
color: GodTheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
)
: null,
child: loading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: color,
),
)
: Tooltip(
message: label,
child: Icon(icon, size: 20, color: color),
),
),
),
);
}
}