import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../services/path_history_service.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; import '../utils/emoji_utils.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/gif_picker.dart'; class ChatScreen extends StatefulWidget { final Contact contact; const ChatScreen({super.key, required this.contact}); @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { final _textController = TextEditingController(); final _scrollController = ScrollController(); bool _clearPath = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.read().setActiveContact(widget.contact.publicKeyHex); }); } @override void dispose() { context.read().setActiveContact(null); _textController.dispose(); _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Consumer2( builder: (context, pathService, connector, _) { final contact = _resolveContact(connector); final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadLabel = 'Unread: $unreadCount'; final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact); final canShowPathDetails = !_clearPath && contact.path.isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(contact.name), if (canShowPathDetails) GestureDetector( behavior: HitTestBehavior.opaque, onLongPress: () => _showFullPathDialog(context, contact.path), child: Text( '$pathLabel • $unreadLabel', overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal), ), ) else Text( '$pathLabel • $unreadLabel', overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal), ), ], ); }, ), centerTitle: false, actions: [ PopupMenuButton( icon: Icon(_clearPath ? Icons.waves : Icons.route), tooltip: 'Routing mode', onSelected: (mode) { setState(() { _clearPath = (mode == 'flood'); }); }, itemBuilder: (context) => [ PopupMenuItem( value: 'auto', child: Row( children: [ Icon(Icons.auto_mode, size: 20, color: !_clearPath ? Theme.of(context).primaryColor : null), const SizedBox(width: 8), Text( 'Auto (use saved path)', style: TextStyle( fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal, ), ), ], ), ), PopupMenuItem( value: 'flood', child: Row( children: [ Icon(Icons.waves, size: 20, color: _clearPath ? Theme.of(context).primaryColor : null), const SizedBox(width: 8), Text( 'Force Flood Mode', style: TextStyle( fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal, ), ), ], ), ), ], ), IconButton( icon: const Icon(Icons.timeline), tooltip: 'Path management', onPressed: () => _showPathHistory(context), ), IconButton( icon: const Icon(Icons.info_outline), onPressed: () => _showContactInfo(context), ), ], ), body: Consumer( builder: (context, connector, child) { final messages = connector.getMessages(widget.contact); return Column( children: [ Expanded( child: messages.isEmpty ? _buildEmptyState() : _buildMessageList(messages), ), _buildInputBar(connector), ], ); }, ), ); } Widget _buildEmptyState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( 'No messages yet', style: TextStyle(fontSize: 16, color: Colors.grey[600]), ), const SizedBox(height: 8), Text( 'Send a message to ${widget.contact.name}', style: TextStyle(fontSize: 14, color: Colors.grey[500]), ), ], ), ); } Widget _buildMessageList(List messages) { return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; return _MessageBubble( message: message, senderName: widget.contact.name, onTap: () => _openMessagePath(message), onLongPress: () => _showMessageActions(message), ); }, ); } Widget _buildInputBar(MeshCoreConnector connector) { final maxBytes = maxContactMessageBytes(); final colorScheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: colorScheme.surface, border: Border( top: BorderSide(color: Theme.of(context).dividerColor), ), ), child: SafeArea( child: Row( children: [ IconButton( icon: const Icon(Icons.gif_box), onPressed: () => _showGifPicker(context), tooltip: 'Send GIF', ), Expanded( child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { final gifId = _parseGifId(value.text); if (gifId != null) { return Row( children: [ Expanded( child: GifMessage( url: 'https://media.giphy.com/media/$gifId/giphy.gif', backgroundColor: colorScheme.surfaceContainerHighest, fallbackTextColor: colorScheme.onSurface.withValues(alpha: 0.6), width: 160, height: 110, ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.close), onPressed: () => _textController.clear(), ), ], ); } return TextField( controller: _textController, inputFormatters: [ Utf8LengthLimitingTextInputFormatter(maxBytes), ], decoration: const InputDecoration( hintText: 'Type a message...', border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(connector), ); }, ), ), const SizedBox(width: 8), IconButton.filled( icon: const Icon(Icons.send), onPressed: () => _sendMessage(connector), ), ], ), ), ); } String? _parseGifId(String text) { final trimmed = text.trim(); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); return match?.group(1); } void _showGifPicker(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => GifPicker( onGifSelected: (gifId) { _textController.text = 'g:$gifId'; }, ), ); } void _sendMessage(MeshCoreConnector connector) { final text = _textController.text.trim(); if (text.isEmpty) return; final maxBytes = maxContactMessageBytes(); if (utf8.encode(text).length > maxBytes) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Message too long (max $maxBytes bytes).')), ); return; } connector.sendMessage( widget.contact, text, clearPath: _clearPath, ); _textController.clear(); Future.delayed(const Duration(milliseconds: 100), () { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } void _showPathHistory(BuildContext context) { final connector = Provider.of(context, listen: false); showDialog( context: context, builder: (context) => Consumer( builder: (context, pathService, _) { final paths = pathService.getRecentPaths(widget.contact.publicKeyHex); return AlertDialog( title: const Row( children: [ Icon(Icons.timeline), SizedBox(width: 8), Text('Path Management'), ], ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (paths.isNotEmpty) ...[ const Text( 'Recent ACK Paths (tap to use):', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), ), if (paths.length >= 100) ...[ const SizedBox(height: 8), Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.amber[100], borderRadius: BorderRadius.circular(8), ), child: const Text( 'Path history is full. Remove entries to add new ones.', style: TextStyle(fontSize: 12), ), ), ], const SizedBox(height: 8), ...paths.map((path) { return Card( margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( dense: true, leading: CircleAvatar( radius: 16, backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green, child: Text( '${path.hopCount}', style: const TextStyle(fontSize: 12), ), ), title: Text( '${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}', style: const TextStyle(fontSize: 14), ), subtitle: Text( '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} successes', style: const TextStyle(fontSize: 11), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.close, size: 16), tooltip: 'Remove path', onPressed: () async { await pathService.removePathRecord( widget.contact.publicKeyHex, path.pathBytes, ); }, ), path.wasFloodDiscovery ? const Icon(Icons.waves, size: 16, color: Colors.grey) : const Icon(Icons.route, size: 16, color: Colors.grey), ], ), onLongPress: () => _showFullPathDialog(context, path.pathBytes), onTap: () async { if (path.pathBytes.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Path details not available yet. Try sending a message to refresh.'), duration: Duration(seconds: 2), ), ); return; } final pathBytes = Uint8List.fromList(path.pathBytes); final pathLength = path.pathBytes.length; await connector.setContactPath( widget.contact, pathBytes, pathLength, ); // Update contact in memory directly for immediate UI feedback connector.updateContactInMemory( widget.contact.publicKeyHex, pathBytes: pathBytes, pathLength: pathLength, ); if (!context.mounted) return; setState(() { _clearPath = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'), duration: const Duration(seconds: 2), ), ); Navigator.pop(context); }, ), ); }), const Divider(), ] else ...[ const Text('No path history yet.\nSend a message to discover paths.'), const Divider(), ], const SizedBox(height: 8), const Text( 'Path Actions:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), ), const SizedBox(height: 8), ListTile( dense: true, leading: const CircleAvatar( radius: 16, backgroundColor: Colors.purple, child: Icon(Icons.edit_road, size: 16), ), title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)), subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)), onTap: () { Navigator.pop(context); _showCustomPathDialog(context); }, ), ListTile( dense: true, leading: const CircleAvatar( radius: 16, backgroundColor: Colors.orange, child: Icon(Icons.clear_all, size: 16), ), title: const Text('Clear Path', style: TextStyle(fontSize: 14)), subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)), onTap: () async { await connector.clearContactPath(widget.contact); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Path cleared. Next message will rediscover route.'), duration: Duration(seconds: 2), ), ); Navigator.pop(context); }, ), ListTile( dense: true, leading: const CircleAvatar( radius: 16, backgroundColor: Colors.blue, child: Icon(Icons.waves, size: 16), ), title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)), subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), onTap: () { setState(() { _clearPath = true; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'), duration: Duration(seconds: 2), ), ); Navigator.pop(context); }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ); }, ), ); } String _formatRelativeTime(DateTime time) { final diff = DateTime.now().difference(time); if (diff.inSeconds < 60) return 'Just now'; if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; if (diff.inHours < 24) return '${diff.inHours}h ago'; return '${diff.inDays}d ago'; } void _showFullPathDialog(BuildContext context, List pathBytes) { if (pathBytes.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Path details not available yet. Try sending a message to refresh.'), duration: Duration(seconds: 2), ), ); return; } final formattedPath = pathBytes .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(','); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Full Path'), content: SelectableText(formattedPath), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ), ); } Contact _resolveContact(MeshCoreConnector connector) { return connector.contacts.firstWhere( (c) => c.publicKeyHex == widget.contact.publicKeyHex, orElse: () => widget.contact, ); } String _currentPathLabel(Contact contact) { if (contact.pathLength < 0) return 'Flood (auto)'; if (contact.pathLength == 0) return 'Direct'; if (contact.pathIdList.isNotEmpty) return contact.pathIdList; return '${contact.pathLength} hops'; } void _showContactInfo(BuildContext context) { final connector = Provider.of(context, listen: false); connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex); showDialog( context: context, builder: (context) => Consumer( builder: (context, connector, _) { final contact = _resolveContact(connector); final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex); return AlertDialog( title: Text(contact.name), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildInfoRow('Type', contact.typeLabel), _buildInfoRow('Path', contact.pathLabel), if (contact.hasLocation) _buildInfoRow( 'Location', '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', ), _buildInfoRow('Public Key', contact.publicKeyHex.substring(0, 16) + '...'), const Divider(), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('SMAZ compression'), subtitle: const Text('Compress outgoing messages'), value: smazEnabled, onChanged: (value) { connector.setContactSmazEnabled(contact.publicKeyHex, value); }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ); }, ), ); } Widget _buildInfoRow(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 80, child: Text(label, style: TextStyle(color: Colors.grey[600])), ), Expanded(child: Text(value)), ], ), ); } void _showCustomPathDialog(BuildContext context) { final connector = Provider.of(context, listen: false); final currentContact = _resolveContact(connector); if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) { connector.getContacts(); } showDialog( context: context, builder: (context) => AlertDialog( title: const Row( children: [ Icon(Icons.edit_road), SizedBox(width: 8), Text('Set Custom Path'), ], ), content: Consumer( builder: (context, connector, _) { final contact = _resolveContact(connector); final pathForInput = contact.pathIdList; final currentPathLabel = _currentPathLabel(contact); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text( 'Current path', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), const Spacer(), TextButton.icon( onPressed: connector.isConnected ? connector.getContacts : null, icon: const Icon(Icons.refresh, size: 16), label: const Text('Reload'), ), ], ), Text( currentPathLabel, style: const TextStyle(fontSize: 11, color: Colors.grey), ), const SizedBox(height: 16), const Text( 'Choose how to set the message path:', style: TextStyle(fontSize: 14), ), const SizedBox(height: 16), ListTile( dense: true, leading: const CircleAvatar( radius: 16, backgroundColor: Colors.blue, child: Icon(Icons.text_fields, size: 16), ), title: const Text('Enter Path Manually', style: TextStyle(fontSize: 14)), subtitle: const Text('Type IDs like: A1B2C3D4,FFEEDDCC', style: TextStyle(fontSize: 11)), onTap: () { Navigator.pop(context); _showManualPathInput( context, initialPath: pathForInput.isEmpty ? null : pathForInput, ); }, ), const SizedBox(height: 8), ListTile( dense: true, leading: const CircleAvatar( radius: 16, backgroundColor: Colors.green, child: Icon(Icons.contacts, size: 16), ), title: const Text('Select from Contacts', style: TextStyle(fontSize: 14)), subtitle: const Text('Pick repeaters/rooms as hops', style: TextStyle(fontSize: 11)), onTap: () { Navigator.pop(context); _showContactPathPicker(context); }, ), ], ); }, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), ], ), ); } void _showManualPathInput(BuildContext context, {String? initialPath}) { final connector = Provider.of(context, listen: false); final controller = TextEditingController(text: initialPath ?? ''); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Enter Custom Path'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Enter 2-character hex prefixes for each hop, separated by commas.', style: TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(height: 8), const Text( 'Example: A1,F2,3C (each node uses first byte of its public key)', style: TextStyle(fontSize: 11, color: Colors.grey), ), const SizedBox(height: 16), TextField( controller: controller, decoration: const InputDecoration( labelText: 'Path (hex prefixes)', hintText: 'A1,F2,3C', border: OutlineInputBorder(), helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)', ), textCapitalization: TextCapitalization.characters, maxLength: 191, // 64 hops * 2 chars + 63 commas ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () async { final path = controller.text.trim().toUpperCase(); if (path.isEmpty) { if (context.mounted) Navigator.pop(context); return; } // Parse comma-separated hex prefixes final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); final pathBytesList = []; final invalidPrefixes = []; for (final id in pathIds) { if (id.length < 2) { invalidPrefixes.add(id); continue; } final prefix = id.substring(0, 2); try { final byte = int.parse(prefix, radix: 16); pathBytesList.add(byte); } catch (e) { invalidPrefixes.add(id); } } if (!context.mounted) return; // Show error for invalid prefixes if (invalidPrefixes.isNotEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'), duration: const Duration(seconds: 3), backgroundColor: Colors.red, ), ); return; } // Check max path length (64 hops) if (pathBytesList.length > 64) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Path too long. Maximum 64 hops allowed.'), duration: Duration(seconds: 3), backgroundColor: Colors.red, ), ); return; } if (pathBytesList.isNotEmpty) { await connector.setContactPath( widget.contact, Uint8List.fromList(pathBytesList), pathBytesList.length, ); if (context.mounted) { Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Path set: ${pathBytesList.length} ${pathBytesList.length == 1 ? "hop" : "hops"}'), duration: const Duration(seconds: 2), ), ); } } }, child: const Text('Set Path'), ), ], ), ); } void _showContactPathPicker(BuildContext context) { final connector = Provider.of(context, listen: false); final selectedContacts = []; // Filter to only repeaters and room servers final validContacts = connector.contacts .where((c) => (c.type == 2 || c.type == 3) && c != widget.contact) .toList(); showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: const Text('Build Path from Contacts'), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (validContacts.isEmpty) ...[ const Icon(Icons.info_outline, size: 48, color: Colors.grey), const SizedBox(height: 16), const Text( 'No repeaters or room servers found.', style: TextStyle(fontSize: 14), textAlign: TextAlign.center, ), const SizedBox(height: 8), const Text( 'Custom paths require intermediate hops that can relay messages.', style: TextStyle(fontSize: 12, color: Colors.grey), textAlign: TextAlign.center, ), ] else if (selectedContacts.isNotEmpty) ...[ const Text( 'Selected Path:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: selectedContacts.asMap().entries.map((entry) { final idx = entry.key; final contact = entry.value; return Chip( avatar: CircleAvatar( child: Text('${idx + 1}'), ), label: Text(contact.name), onDeleted: () { setDialogState(() { selectedContacts.removeAt(idx); }); }, ); }).toList(), ), const Divider(), ] else const Text( 'Tap repeaters/rooms to add them to the path:', style: TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(height: 8), if (validContacts.isNotEmpty) Flexible( child: ListView.builder( shrinkWrap: true, itemCount: validContacts.length, itemBuilder: (context, index) { final contact = validContacts[index]; final isSelected = selectedContacts.contains(contact); return ListTile( dense: true, leading: CircleAvatar( radius: 16, backgroundColor: isSelected ? Colors.green : (contact.type == 2 ? Colors.blue : Colors.purple), child: Icon( contact.type == 2 ? Icons.router : Icons.meeting_room, size: 16, color: Colors.white, ), ), title: Text(contact.name, style: const TextStyle(fontSize: 14)), subtitle: Text( '${contact.typeLabel} • ${contact.publicKeyHex.substring(0, 8)}', style: const TextStyle(fontSize: 10), ), trailing: isSelected ? const Icon(Icons.check_circle, color: Colors.green) : const Icon(Icons.add_circle_outline), onTap: () { setDialogState(() { if (isSelected) { selectedContacts.remove(contact); } else { selectedContacts.add(contact); } }); }, ); }, ), ), ], ), ), actions: [ if (selectedContacts.isNotEmpty) TextButton( onPressed: () { setDialogState(() { selectedContacts.clear(); }); }, child: const Text('Clear'), ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: selectedContacts.isEmpty ? null : () async { // Build path bytes from selected contacts (prefix byte of each pub key) final pathBytesList = []; for (final contact in selectedContacts) { if (contact.publicKeyHex.length >= 2) { try { pathBytesList.add(int.parse(contact.publicKeyHex.substring(0, 2), radix: 16)); } catch (e) { // Skip invalid hex } } } if (pathBytesList.isNotEmpty) { await connector.setContactPath( widget.contact, Uint8List.fromList(pathBytesList), pathBytesList.length, ); final pathIds = selectedContacts .map((c) => c.publicKeyHex.substring(0, 8)) .join(','); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Custom path set: $pathIds'), duration: const Duration(seconds: 2), ), ); Navigator.pop(context); } } }, child: const Text('Set Path'), ), ], ), ), ); } void _openMessagePath(Message message) { final connector = context.read(); final senderName = message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.name; final pathMessage = ChannelMessage( senderKey: null, senderName: senderName, text: message.text, timestamp: message.timestamp, isOutgoing: message.isOutgoing, status: ChannelMessageStatus.sent, repeatCount: 0, pathLength: message.pathLength, pathBytes: message.pathBytes, ); Navigator.push( context, MaterialPageRoute( builder: (context) => ChannelMessagePathScreen(message: pathMessage), ), ); } void _showMessageActions(Message message) { showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.add_reaction_outlined), title: const Text('Add Reaction'), onTap: () { Navigator.pop(sheetContext); _showEmojiPicker(message); }, ), ListTile( leading: const Icon(Icons.copy), title: const Text('Copy'), onTap: () { Navigator.pop(sheetContext); _copyMessageText(message.text); }, ), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Delete'), onTap: () async { Navigator.pop(sheetContext); await _deleteMessage(message); }, ), if (message.isOutgoing && message.status == MessageStatus.failed) ListTile( leading: const Icon(Icons.refresh), title: const Text('Retry'), onTap: () { Navigator.pop(sheetContext); _retryMessage(message); }, ), ListTile( leading: const Icon(Icons.close), title: const Text('Cancel'), onTap: () => Navigator.pop(sheetContext), ), ], ), ), ); } void _copyMessageText(String text) { Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Message copied')), ); } Future _deleteMessage(Message message) async { await context.read().deleteMessage(message); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Message deleted')), ); } void _retryMessage(Message message) { final connector = Provider.of(context, listen: false); // Retry with clearPath if the message has no path or pathLength is -1 (indicating flood was used) final shouldClearPath = message.pathLength != null && message.pathLength! < 0; connector.sendMessage( widget.contact, message.text, clearPath: shouldClearPath, ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Retrying message')), ); } void _showEmojiPicker(Message message) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => EmojiPicker( onEmojiSelected: (emoji) { _sendReaction(message, emoji); }, ), ); } void _sendReaction(Message message, String emoji) { final connector = context.read(); final reactionText = 'r:${message.messageId}:$emoji'; connector.sendMessage(widget.contact, reactionText); } } class _MessageBubble extends StatelessWidget { final Message message; final String senderName; final VoidCallback? onTap; final VoidCallback? onLongPress; const _MessageBubble({ required this.message, required this.senderName, this.onTap, this.onLongPress, }); @override Widget build(BuildContext context) { final isOutgoing = message.isOutgoing; final colorScheme = Theme.of(context).colorScheme; final gifId = _parseGifId(message.text); final poi = _parsePoiMessage(message.text); final isFailed = message.status == MessageStatus.failed; final attempts = message.retryCount + 1; final bubbleColor = isFailed ? colorScheme.errorContainer : (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest); final textColor = isFailed ? colorScheme.onErrorContainer : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ GestureDetector( onTap: onTap, onLongPress: onLongPress, child: Row( mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isOutgoing) ...[ _buildAvatar(senderName, colorScheme), const SizedBox(width: 8), ], Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.65, ), decoration: BoxDecoration( color: bubbleColor, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isOutgoing) ...[ Text( senderName, style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: colorScheme.primary, ), ), const SizedBox(height: 4), ], if (poi != null) _buildPoiMessage(context, poi, textColor, metaColor) else if (gifId != null) GifMessage( url: 'https://media.giphy.com/media/$gifId/giphy.gif', backgroundColor: bubbleColor, fallbackTextColor: textColor.withValues(alpha: 0.7), ) else Text( message.text, style: TextStyle( color: textColor, ), ), if (isOutgoing) ...[ const SizedBox(height: 4), Text( 'Attempts: $attempts', style: TextStyle( fontSize: 10, color: metaColor, ), ), ], const SizedBox(height: 4), Wrap( spacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( _formatTime(message.timestamp), style: TextStyle( fontSize: 10, color: metaColor, ), ), if (isOutgoing) ...[ const SizedBox(width: 4), _buildStatusIcon(metaColor), ], if (message.tripTimeMs != null && message.status == MessageStatus.delivered) ...[ const SizedBox(width: 4), Icon( Icons.speed, size: 10, color: isOutgoing ? metaColor : Colors.green[700], ), Text( '${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', style: TextStyle( fontSize: 9, color: isOutgoing ? metaColor : Colors.green[700], ), ), ], ], ), ], ), ), ), ], ), ), if (message.reactions.isNotEmpty) ...[ const SizedBox(height: 4), Padding( padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), child: _buildReactionsDisplay(context, message, colorScheme), ), ], ], ), ); } String? _parseGifId(String text) { final trimmed = text.trim(); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); return match?.group(1); } _PoiInfo? _parsePoiMessage(String text) { final trimmed = text.trim(); final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$') .firstMatch(trimmed); if (match == null) return null; final lat = double.tryParse(match.group(1) ?? ''); final lon = double.tryParse(match.group(2) ?? ''); if (lat == null || lon == null) return null; final label = match.group(3) ?? ''; return _PoiInfo(lat: lat, lon: lon, label: label); } Widget _buildPoiMessage( BuildContext context, _PoiInfo poi, Color textColor, Color metaColor, ) { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( icon: Icon(Icons.location_on_outlined, color: textColor), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => MapScreen( highlightPosition: LatLng(poi.lat, poi.lon), highlightLabel: poi.label, ), ), ); }, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'POI Shared', style: TextStyle( color: textColor, fontWeight: FontWeight.w600, ), ), if (poi.label.isNotEmpty) Text( poi.label, style: TextStyle( color: metaColor, fontSize: 12, ), ), ], ), ), ], ); } Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) { return Wrap( spacing: 6, runSpacing: 6, children: message.reactions.entries.map((entry) { final emoji = entry.key; final count = entry.value; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12), border: Border.all( color: colorScheme.outline.withValues(alpha: 0.3), width: 1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( emoji, style: const TextStyle(fontSize: 16), ), if (count > 1) ...[ const SizedBox(width: 4), Text( '$count', style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: colorScheme.onSecondaryContainer, ), ), ], ], ), ); }).toList(), ); } Widget _buildAvatar(String senderName, ColorScheme colorScheme) { final initial = _getFirstCharacterOrEmoji(senderName); final color = _getColorForName(senderName); return CircleAvatar( radius: 18, backgroundColor: color.withValues(alpha: 0.2), child: Text( initial, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: color, ), ), ); } String _getFirstCharacterOrEmoji(String name) { if (name.isEmpty) return '?'; final emoji = firstEmoji(name); if (emoji != null) return emoji; final runes = name.runes.toList(); if (runes.isEmpty) return '?'; return String.fromCharCode(runes[0]).toUpperCase(); } Color _getColorForName(String name) { // Generate a consistent color based on the name hash final hash = name.hashCode; final colors = [ Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.pink, Colors.teal, Colors.indigo, Colors.cyan, Colors.amber, Colors.deepOrange, ]; return colors[hash.abs() % colors.length]; } Widget _buildStatusIcon(Color color) { IconData icon; switch (message.status) { case MessageStatus.pending: icon = Icons.access_time; break; case MessageStatus.sent: icon = Icons.schedule; break; case MessageStatus.delivered: icon = Icons.check; break; case MessageStatus.failed: icon = Icons.error_outline; break; } return Icon( icon, size: 12, color: color, ); } String _formatTime(DateTime time) { final hour = time.hour.toString().padLeft(2, '0'); final minute = time.minute.toString().padLeft(2, '0'); return '$hour:$minute'; } } class _PoiInfo { final double lat; final double lon; final String label; const _PoiInfo({ required this.lat, required this.lon, required this.label, }); }