import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; import '../models/contact.dart'; import '../models/contact_group.dart'; import '../storage/contact_group_store.dart'; import '../utils/contact_search.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import '../widgets/unread_badge.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; import 'map_screen.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; enum RoomLoginDestination { chat, management } enum ContactOperationType { import, export, zeroHopShare } class ContactsScreen extends StatefulWidget { final bool hideBackButton; const ContactsScreen({super.key, this.hideBackButton = false}); @override State createState() => _ContactsScreenState(); } class _ContactsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; ContactSortOption _sortOption = ContactSortOption.lastSeen; bool _showUnreadOnly = false; ContactTypeFilter _typeFilter = ContactTypeFilter.all; final ContactGroupStore _groupStore = ContactGroupStore(); List _groups = []; Timer? _searchDebounce; final Set _pendingOperations = {}; StreamSubscription? _frameSubscription; @override void initState() { super.initState(); _loadGroups(); _setupFrameListener(); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); _frameSubscription?.cancel(); super.dispose(); } Future _loadGroups() async { final groups = await _groupStore.loadGroups(); if (!mounted) return; setState(() { _groups = groups; }); } Future _saveGroups() async { await _groupStore.saveGroups(_groups); } void _setupFrameListener() { final connector = Provider.of(context, listen: false); // Listen for incoming text messages from the repeater _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); final code = frameBuffer.readUInt8(); if (code == respCodeExportContact) { final advertPacket = frameBuffer.readRemainingBytes(); // Validate packet has expected minimum size (98+ bytes per protocol) if (advertPacket.length < 98) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.contacts_invalidAdvertFormat), ), ); } _pendingOperations.remove(ContactOperationType.export); return; } final hexString = pubKeyToHex(advertPacket); Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); } if (code == respCodeOk) { // Show a snackbar indicating success if (!mounted) return; if (_pendingOperations.contains(ContactOperationType.import)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.contacts_contactImported)), ); } if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.contacts_zeroHopContactAdvertSent), ), ); } if (_pendingOperations.contains(ContactOperationType.export)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)), ); } _pendingOperations.clear(); } if (code == respCodeErr) { // Show a snackbar indicating failure if (!mounted) return; if (_pendingOperations.contains(ContactOperationType.import)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.contacts_contactImportFailed)), ); } if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), ), ); } if (_pendingOperations.contains(ContactOperationType.export)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.contacts_contactAdvertCopyFailed), ), ); } _pendingOperations.clear(); } }); } Future _contactExport(Uint8List pubKey) async { final connector = Provider.of(context, listen: false); final exportContactFrame = buildExportContactFrame(pubKey); _pendingOperations.add(ContactOperationType.export); await connector.sendFrame(exportContactFrame); } Future _contactZeroHop(Uint8List pubKey) async { final connector = Provider.of(context, listen: false); final exportContactZeroHopFrame = buildZeroHopContact(pubKey); _pendingOperations.add(ContactOperationType.zeroHopShare); await connector.sendFrame(exportContactZeroHopFrame); } Future _contactImport() async { final connector = Provider.of(context, listen: false); final clipboardData = await Clipboard.getData('text/plain'); if (clipboardData == null || clipboardData.text == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)), ); } return; } final text = clipboardData.text!.trim(); if (!text.startsWith('meshcore://')) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)), ); } return; } final hexString = text.substring('meshcore://'.length); try { final importContactFrame = buildImportContactFrame(hexString); _pendingOperations.add(ContactOperationType.import); await connector.sendFrame(importContactFrame); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)), ); } } } @override Widget build(BuildContext context) { final connector = context.watch(); // Auto-navigate back to scanner if disconnected if (!checkConnectionAndNavigate(connector)) { return const SizedBox.shrink(); } final allowBack = !connector.isConnected; return PopScope( canPop: allowBack, child: Scaffold( appBar: AppBar( leading: BatteryIndicator(connector: connector), title: Text(context.l10n.contacts_title), centerTitle: true, automaticallyImplyLeading: false, actions: [ PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( child: Row( children: [ const Icon(Icons.connect_without_contact), const SizedBox(width: 8), Text(context.l10n.contacts_zeroHopAdvert), ], ), onTap: () => { connector.sendSelfAdvert(flood: false), ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.settings_advertisementSent), ), ), }, ), PopupMenuItem( child: Row( children: [ const Icon(Icons.cell_tower), const SizedBox(width: 8), Text(context.l10n.contacts_floodAdvert), ], ), onTap: () => { connector.sendSelfAdvert(flood: true), ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.settings_advertisementSent), ), ), }, ), PopupMenuItem( child: Row( children: [ const Icon(Icons.copy), const SizedBox(width: 8), Text(context.l10n.contacts_copyAdvertToClipboard), ], ), onTap: () => _contactExport(Uint8List.fromList([])), ), PopupMenuItem( child: Row( children: [ const Icon(Icons.paste), const SizedBox(width: 8), Text(context.l10n.contacts_addContactFromClipboard), ], ), onTap: () => _contactImport(), ), ], icon: const Icon(Icons.connect_without_contact), ), PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( child: Row( children: [ const Icon(Icons.logout, color: Colors.red), const SizedBox(width: 8), Text(context.l10n.common_disconnect), ], ), onTap: () => _disconnect(context, connector), ), PopupMenuItem( child: Row( children: [ const Icon(Icons.settings), const SizedBox(width: 8), Text(context.l10n.settings_title), ], ), onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => const SettingsScreen(), ), ), ), ], icon: const Icon(Icons.more_vert), ), ], ), body: _buildContactsBody(context, connector), bottomNavigationBar: SafeArea( top: false, child: QuickSwitchBar( selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), ), ), ), ); } Future _disconnect( BuildContext context, MeshCoreConnector connector, ) async { await showDisconnectDialog(context, connector); } Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) { return ContactsFilterMenu( sortOption: _sortOption, typeFilter: _typeFilter, showUnreadOnly: _showUnreadOnly, onSortChanged: (value) { setState(() { _sortOption = value; }); }, onTypeFilterChanged: (value) { setState(() { _typeFilter = value; }); }, onUnreadOnlyChanged: (value) { setState(() { _showUnreadOnly = value; }); }, onNewGroup: () => _showGroupEditor(context, connector.contacts), ); } Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final contacts = connector.contacts; if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (contacts.isEmpty && _groups.isEmpty) { return EmptyState( icon: Icons.people_outline, title: context.l10n.contacts_noContacts, subtitle: context.l10n.contacts_contactsWillAppear, ); } final filteredAndSorted = _filterAndSortContacts(contacts, connector); final filteredGroups = _showUnreadOnly ? const [] : _filterAndSortGroups(_groups, contacts); return Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: context.l10n.contacts_searchContacts, prefixIcon: const Icon(Icons.search), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (_searchQuery.isNotEmpty) IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); setState(() { _searchQuery = ''; }); }, ), _buildFilterButton(context, connector), ], ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), ), onChanged: (value) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 300), () { if (!mounted) return; setState(() { _searchQuery = value.toLowerCase(); }); }); }, ), ), Expanded( child: filteredAndSorted.isEmpty && filteredGroups.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.search_off, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( _showUnreadOnly ? context.l10n.contacts_noUnreadContacts : context.l10n.contacts_noContactsFound, style: TextStyle(fontSize: 16, color: Colors.grey[600]), ), ], ), ) : RefreshIndicator( onRefresh: () => connector.getContacts(), child: ListView.builder( itemCount: filteredGroups.length + filteredAndSorted.length, itemBuilder: (context, index) { if (index < filteredGroups.length) { final group = filteredGroups[index]; return _buildGroupTile(context, group, contacts); } final contact = filteredAndSorted[index - filteredGroups.length]; final unreadCount = connector.getUnreadCountForContact( contact, ); return _ContactTile( contact: contact, lastSeen: _resolveLastSeen(contact), unreadCount: unreadCount, onTap: () => _openChat(context, contact), onLongPress: () => _showContactOptions(context, connector, contact), ); }, ), ), ), ], ); } List _filterAndSortGroups( List groups, List contacts, ) { final query = _searchQuery.trim().toLowerCase(); final contactsByKey = {}; for (final contact in contacts) { contactsByKey[contact.publicKeyHex] = contact; } final filtered = groups .where((group) { if (query.isEmpty) return true; if (group.name.toLowerCase().contains(query)) return true; for (final key in group.memberKeys) { final contact = contactsByKey[key]; if (contact != null && matchesContactQuery(contact, query)) { return true; } } return false; }) .where((group) { if (_typeFilter == ContactTypeFilter.all) return true; for (final key in group.memberKeys) { final contact = contactsByKey[key]; if (contact != null && _matchesTypeFilter(contact)) return true; } return false; }) .toList(); filtered.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); return filtered; } List _filterAndSortContacts( List contacts, MeshCoreConnector connector, ) { var filtered = contacts.where((contact) { if (_searchQuery.isEmpty) return true; return matchesContactQuery(contact, _searchQuery); }).toList(); // Filter out own node from the list if (connector.selfPublicKey != null) { final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!); filtered = filtered.where((contact) { return contact.publicKeyHex != selfPubKeyHex; }).toList(); } if (_typeFilter != ContactTypeFilter.all) { filtered = filtered.where(_matchesTypeFilter).toList(); } if (_showUnreadOnly) { filtered = filtered.where((contact) { return connector.getUnreadCountForContact(contact) > 0; }).toList(); } switch (_sortOption) { case ContactSortOption.lastSeen: filtered.sort( (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), ); break; case ContactSortOption.recentMessages: filtered.sort((a, b) { final aMessages = connector.getMessages(a); final bMessages = connector.getMessages(b); final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp; final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp; return bLastMsg.compareTo(aLastMsg); }); break; case ContactSortOption.name: filtered.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); break; } return filtered; } bool _matchesTypeFilter(Contact contact) { switch (_typeFilter) { case ContactTypeFilter.all: return true; case ContactTypeFilter.users: return contact.type == advTypeChat; case ContactTypeFilter.repeaters: return contact.type == advTypeRepeater; case ContactTypeFilter.rooms: return contact.type == advTypeRoom; } } DateTime _resolveLastSeen(Contact contact) { if (contact.type != advTypeChat) return contact.lastSeen; return contact.lastMessageAt.isAfter(contact.lastSeen) ? contact.lastMessageAt : contact.lastSeen; } Widget _buildGroupTile( BuildContext context, ContactGroup group, List contacts, ) { final memberContacts = _resolveGroupContacts(group, contacts); final subtitle = _formatGroupMembers(context, memberContacts); return ListTile( leading: const CircleAvatar( backgroundColor: Colors.teal, child: Icon(Icons.group, color: Colors.white, size: 20), ), title: Text(group.name), subtitle: Text(subtitle), trailing: Text( memberContacts.length.toString(), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), onTap: () => _showGroupOptions(context, group, contacts), onLongPress: () => _showGroupOptions(context, group, contacts), ); } List _resolveGroupContacts( ContactGroup group, List contacts, ) { final byKey = {}; for (final contact in contacts) { byKey[contact.publicKeyHex] = contact; } final resolved = []; for (final key in group.memberKeys) { final contact = byKey[key]; if (contact != null) { resolved.add(contact); } } resolved.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); return resolved; } String _formatGroupMembers(BuildContext context, List members) { if (members.isEmpty) return context.l10n.contacts_noMembers; final names = members.map((c) => c.name).toList(); if (names.length <= 2) return names.join(', '); return '${names.take(2).join(', ')} +${names.length - 2}'; } void _openChat(BuildContext context, Contact contact) { // Check if this is a repeater if (contact.type == advTypeRepeater) { _showRepeaterLogin(context, contact); } else if (contact.type == advTypeRoom) { _showRoomLogin(context, contact, RoomLoginDestination.chat); } else { context.read().markContactRead(contact.publicKeyHex); Navigator.push( context, MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), ); } } void _handleQuickSwitch(int index, BuildContext context) { if (index == 0) return; switch (index) { case 1: Navigator.pushReplacement( context, buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), ); break; case 2: Navigator.pushReplacement( context, buildQuickSwitchRoute(const MapScreen(hideBackButton: true)), ); break; } } void _showRepeaterLogin(BuildContext context, Contact repeater) { showDialog( context: context, builder: (context) => RepeaterLoginDialog( repeater: repeater, onLogin: (password) { // Navigate to repeater hub screen after successful login Navigator.push( context, MaterialPageRoute( builder: (context) => RepeaterHubScreen(repeater: repeater, password: password), ), ); }, ), ); } void _showRoomLogin( BuildContext context, Contact room, RoomLoginDestination destination, ) { showDialog( context: context, builder: (context) => RoomLoginDialog( room: room, onLogin: (password) { context.read().markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( builder: (context) => destination == RoomLoginDestination.management ? RepeaterHubScreen(repeater: room, password: password) : ChatScreen(contact: room), ), ); }, ), ); } void _showGroupOptions( BuildContext context, ContactGroup group, List contacts, ) { final members = _resolveGroupContacts(group, contacts); showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.edit), title: Text(context.l10n.contacts_editGroup), onTap: () { Navigator.pop(sheetContext); _showGroupEditor(context, contacts, group: group); }, ), ListTile( leading: const Icon(Icons.delete, color: Colors.red), title: Text( context.l10n.contacts_deleteGroup, style: const TextStyle(color: Colors.red), ), onTap: () { Navigator.pop(sheetContext); _confirmDeleteGroup(context, group); }, ), if (members.isNotEmpty) const Divider(), ...members.map((member) { return ListTile( leading: const Icon(Icons.person), title: Text(member.name), subtitle: Text(member.typeLabel), onTap: () { Navigator.pop(sheetContext); _openChat(context, member); }, ); }), ], ), ), ), ); } void _confirmDeleteGroup(BuildContext context, ContactGroup group) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(context.l10n.contacts_deleteGroup), content: Text(context.l10n.contacts_deleteGroupConfirm(group.name)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.common_cancel), ), TextButton( onPressed: () async { Navigator.pop(dialogContext); setState(() { _groups.removeWhere((g) => g.name == group.name); }); await _saveGroups(); }, child: Text( context.l10n.common_delete, style: const TextStyle(color: Colors.red), ), ), ], ), ); } void _showGroupEditor( BuildContext context, List contacts, { ContactGroup? group, }) { final isEditing = group != null; final nameController = TextEditingController(text: group?.name ?? ''); final selectedKeys = {...group?.memberKeys ?? []}; String filterQuery = ''; final sortedContacts = List.from(contacts) ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); showDialog( context: context, builder: (dialogContext) => StatefulBuilder( builder: (builderContext, setDialogState) { final filteredContacts = filterQuery.isEmpty ? sortedContacts : sortedContacts .where( (contact) => matchesContactQuery(contact, filterQuery), ) .toList(); return AlertDialog( title: Text( isEditing ? context.l10n.contacts_editGroup : context.l10n.contacts_newGroup, ), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, decoration: InputDecoration( labelText: context.l10n.contacts_groupName, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( decoration: InputDecoration( hintText: context.l10n.contacts_filterContacts, prefixIcon: const Icon(Icons.search), border: const OutlineInputBorder(), isDense: true, ), onChanged: (value) { setDialogState(() { filterQuery = value.toLowerCase(); }); }, ), const SizedBox(height: 12), SizedBox( height: 240, child: filteredContacts.isEmpty ? Center( child: Text( context.l10n.contacts_noContactsMatchFilter, ), ) : ListView.builder( itemCount: filteredContacts.length, itemBuilder: (context, index) { final contact = filteredContacts[index]; final isSelected = selectedKeys.contains( contact.publicKeyHex, ); return CheckboxListTile( value: isSelected, title: Text(contact.name), subtitle: Text(contact.typeLabel), onChanged: (value) { setDialogState(() { if (value == true) { selectedKeys.add(contact.publicKeyHex); } else { selectedKeys.remove(contact.publicKeyHex); } }); }, ); }, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.common_cancel), ), TextButton( onPressed: () async { final name = nameController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.contacts_groupNameRequired), ), ); return; } final exists = _groups.any((g) { if (isEditing && g.name == group.name) return false; return g.name.toLowerCase() == name.toLowerCase(); }); if (exists) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.contacts_groupAlreadyExists(name), ), ), ); return; } setState(() { if (isEditing) { final index = _groups.indexWhere( (g) => g.name == group.name, ); if (index != -1) { _groups[index] = ContactGroup( name: name, memberKeys: selectedKeys.toList(), ); } } else { _groups.add( ContactGroup( name: name, memberKeys: selectedKeys.toList(), ), ); } }); await _saveGroups(); if (dialogContext.mounted) { Navigator.pop(dialogContext); } }, child: Text( isEditing ? context.l10n.common_save : context.l10n.common_create, ), ), ], ); }, ), ); } void _showContactOptions( BuildContext context, MeshCoreConnector connector, Contact contact, ) { final isRepeater = contact.type == advTypeRepeater; final isRoom = contact.type == advTypeRoom; showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (isRepeater) ...[ ListTile( leading: const Icon(Icons.radar, color: Colors.green), title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing, path: contact.traceRouteBytes ?? Uint8List(0), ), ), ); }, ), ListTile( leading: const Icon(Icons.cell_tower, color: Colors.orange), title: Text(context.l10n.contacts_manageRepeater), onTap: () { Navigator.pop(sheetContext); _showRepeaterLogin(context, contact); }, ), ] else if (isRoom) ...[ ListTile( leading: const Icon(Icons.radar, color: Colors.green), title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing, path: contact.traceRouteBytes ?? Uint8List(0), ), ), ); }, ), ListTile( leading: const Icon(Icons.room, color: Colors.blue), title: Text(context.l10n.contacts_roomLogin), onTap: () { Navigator.pop(sheetContext); _showRoomLogin(context, contact, RoomLoginDestination.chat); }, ), ListTile( leading: const Icon( Icons.room_preferences, color: Colors.orange, ), title: Text(context.l10n.room_management), onTap: () { Navigator.pop(sheetContext); _showRoomLogin( context, contact, RoomLoginDestination.management, ); }, ), ] else ...[ if (contact.pathLength > 0) ListTile( leading: const Icon(Icons.radar, color: Colors.green), title: Text(context.l10n.contacts_chatTraceRoute), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_pathTraceTo( contact.name, ), path: contact.traceRouteBytes ?? Uint8List(0), ), ), ); }, ), ListTile( leading: const Icon(Icons.chat), title: Text(context.l10n.contacts_openChat), onTap: () { Navigator.pop(sheetContext); _openChat(context, contact); }, ), ], ListTile( leading: const Icon(Icons.copy), title: Text(context.l10n.contacts_ShareContact), onTap: () { Navigator.pop(sheetContext); _contactExport(contact.publicKey); }, ), ListTile( leading: const Icon(Icons.connect_without_contact), title: Text(context.l10n.contacts_ShareContactZeroHop), onTap: () { Navigator.pop(sheetContext); _contactZeroHop(contact.publicKey); }, ), ListTile( leading: const Icon(Icons.delete, color: Colors.red), title: Text( context.l10n.contacts_deleteContact, style: const TextStyle(color: Colors.red), ), onTap: () { Navigator.pop(sheetContext); _confirmDelete(context, connector, contact); }, ), ], ), ), ); } void _confirmDelete( BuildContext context, MeshCoreConnector connector, Contact contact, ) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(context.l10n.contacts_deleteContact), content: Text(context.l10n.contacts_removeConfirm(contact.name)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.common_cancel), ), TextButton( onPressed: () { Navigator.pop(dialogContext); connector.removeContact(contact); }, child: Text( context.l10n.common_delete, style: const TextStyle(color: Colors.red), ), ), ], ), ); } } class _ContactTile extends StatelessWidget { final Contact contact; final DateTime lastSeen; final int unreadCount; final VoidCallback onTap; final VoidCallback onLongPress; const _ContactTile({ required this.contact, required this.lastSeen, required this.unreadCount, required this.onTap, required this.onLongPress, }); @override Widget build(BuildContext context) { return ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), child: _buildContactAvatar(contact), ), title: Text(contact.name), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(contact.pathLabel), Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)), ], ), // Clamp text scaling in trailing section to prevent overflow while // maintaining accessibility. Primary content (title/subtitle) scales normally. trailing: MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: TextScaler.linear( MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (unreadCount > 0) ...[ UnreadBadge(count: unreadCount), const SizedBox(height: 4), ], Text( _formatLastSeen(context, lastSeen), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), Row( mainAxisSize: MainAxisSize.min, children: [ if (contact.hasLocation) Icon(Icons.location_on, size: 14, color: Colors.grey[400]), ], ), ], ), ), onTap: onTap, onLongPress: onLongPress, ); } Widget _buildContactAvatar(Contact contact) { final emoji = firstEmoji(contact.name); if (emoji != null) { return Text(emoji, style: const TextStyle(fontSize: 18)); } return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20); } IconData _getTypeIcon(int type) { switch (type) { case advTypeChat: return Icons.chat; case advTypeRepeater: return Icons.cell_tower; case advTypeRoom: return Icons.group; case advTypeSensor: return Icons.sensors; default: return Icons.device_unknown; } } Color _getTypeColor(int type) { switch (type) { case advTypeChat: return Colors.blue; case advTypeRepeater: return Colors.orange; case advTypeRoom: return Colors.purple; case advTypeSensor: return Colors.green; default: return Colors.grey; } } String _formatLastSeen(BuildContext context, DateTime lastSeen) { final now = DateTime.now(); final diff = now.difference(lastSeen); if (diff.isNegative || diff.inMinutes < 5) { return context.l10n.contacts_lastSeenNow; } if (diff.inMinutes < 60) { return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes); } if (diff.inHours < 24) { final hours = diff.inHours; return hours == 1 ? context.l10n.contacts_lastSeenHourAgo : context.l10n.contacts_lastSeenHoursAgo(hours); } final days = diff.inDays; return days == 1 ? context.l10n.contacts_lastSeenDayAgo : context.l10n.contacts_lastSeenDaysAgo(days); } }