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 createState() => _ConversationScreenState(); } class _ConversationScreenState extends ConsumerState { final _scrollController = ScrollController(); bool _sending = false; String? _sendError; bool _isNearBottom = true; bool _initialScrollDone = false; final Map> _reactions = {}; // messageId -> list of emojis bool _searchActive = false; String _searchQuery = ''; final _searchController = TextEditingController(); bool _summaryLoading = false; bool _showSchedulePicker = false; final _chatInputKey = GlobalKey(); // 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 = {}; 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'(?>' : m.group(0)!; }, ); } Future _handleSend(String content, List 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 _pickForwardTarget() async { final conversations = ref.read(conversationsProvider).valueOrNull ?? []; if (conversations.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Нет доступных чатов')), ); return; } final selected = await showModalBottomSheet( 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 _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 _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 _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 _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 _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 _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 _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 _handleStartCall(BuildContext context, String title, List 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 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 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 _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 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 allMessages) { final display = <_DisplayMessage>[]; int pendingToolSteps = 0; final pendingThinking = []; 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 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>>(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 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 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 _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), ), ), ), ); } }