import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/services/notification_service.dart'; import 'package:meshcore_open/utils/app_logger.dart'; import 'package:meshcore_open/utils/platform_info.dart'; import 'package:meshcore_open/widgets/app_bar.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 '../l10n/contact_localization.dart'; import '../models/contact_group.dart'; import '../services/ui_view_state_service.dart'; import '../theme/mesh_theme.dart'; import '../utils/contact_search.dart'; import '../storage/contact_group_store.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/mesh_ui.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; import 'discovery_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(); final ContactGroupStore _groupStore = ContactGroupStore(); MeshCoreConnector? _scopeSyncConnector; List _groups = []; String _loadedGroupScopeKeyHex = ''; Timer? _searchDebounce; final List _pendingOperations = []; StreamSubscription? _frameSubscription; @override void initState() { super.initState(); _searchController.text = context .read() .contactsSearchText; _loadGroups(); _setupFrameListener(); _clearAdvertNotifications(); } void _clearAdvertNotifications() { final connector = context.read(); final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList(); NotificationService().clearAdvertNotifications(contactIds); } @override void didChangeDependencies() { super.didChangeDependencies(); final connector = context.read(); if (!identical(_scopeSyncConnector, connector)) { _scopeSyncConnector?.removeListener(_handleConnectorScopeChange); _scopeSyncConnector = connector; _scopeSyncConnector?.addListener(_handleConnectorScopeChange); } _handleConnectorScopeChange(); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); _frameSubscription?.cancel(); _scopeSyncConnector?.removeListener(_handleConnectorScopeChange); super.dispose(); } void _handleConnectorScopeChange() { final connector = _scopeSyncConnector; if (connector == null) return; _syncGroupScopeIfNeeded(connector); } Future _loadGroups() async { final selfPublicKeyHex = context.read().selfPublicKeyHex; if (selfPublicKeyHex.isEmpty) { return; } _groupStore.setPublicKeyHex = selfPublicKeyHex; final groups = await _groupStore.loadGroups(); if (!mounted) return; setState(() { _loadedGroupScopeKeyHex = selfPublicKeyHex; _groups = groups; _ensureValidSelectedGroup(); }); } Future _saveGroups() async { final selfPublicKeyHex = context.read().selfPublicKeyHex; if (selfPublicKeyHex.isEmpty) { return; } _groupStore.setPublicKeyHex = selfPublicKeyHex; await _groupStore.saveGroups(_groups); } bool _hasGroupStoreScope(MeshCoreConnector connector) { return connector.selfPublicKeyHex.isNotEmpty; } void _syncGroupScopeIfNeeded(MeshCoreConnector connector) { final selfPublicKeyHex = connector.selfPublicKeyHex; if (selfPublicKeyHex.isEmpty || selfPublicKeyHex == _loadedGroupScopeKeyHex) { return; } _loadGroups(); } void _collapseContactsSearch(UiViewStateService viewState) { _searchDebounce?.cancel(); _searchDebounce = null; _searchController.clear(); viewState.setContactsSearchText(''); viewState.setContactsSearchExpanded(false); } void _showGroupsUnavailableMessage(BuildContext context) { showDismissibleSnackBar( context, content: Text(context.l10n.common_loading), ); } 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); try { 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) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_invalidAdvertFormat), ); } _pendingOperations.remove(ContactOperationType.export); return; } final hexString = pubKeyToHex(advertPacket); Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); } // Generic OK/ERR acks carry no command correlation, so consume only // the oldest pending operation per ack instead of clearing all. if (code == respCodeOk) { if (!mounted) return; if (_pendingOperations.isEmpty) return; final op = _pendingOperations.removeAt(0); switch (op) { case ContactOperationType.import: showDismissibleSnackBar( context, content: Text(context.l10n.contacts_contactImported), ); case ContactOperationType.zeroHopShare: showDismissibleSnackBar( context, content: Text(context.l10n.contacts_zeroHopContactAdvertSent), ); case ContactOperationType.export: showDismissibleSnackBar( context, content: Text(context.l10n.contacts_contactAdvertCopied), ); } } if (code == respCodeErr) { if (!mounted) return; if (_pendingOperations.isEmpty) return; final op = _pendingOperations.removeAt(0); switch (op) { case ContactOperationType.import: showDismissibleSnackBar( context, content: Text(context.l10n.contacts_contactImportFailed), ); case ContactOperationType.zeroHopShare: showDismissibleSnackBar( context, content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), ); case ContactOperationType.export: showDismissibleSnackBar( context, content: Text(context.l10n.contacts_contactAdvertCopyFailed), ); } } } catch (e) { appLogger.error( 'Error processing received frame: $e', tag: 'ContactsScreen', ); } }); } Future _contactExport(Uint8List pubKey) async { final connector = Provider.of(context, listen: false); final exportContactFrame = buildExportContactFrame(pubKey); _pendingOperations.add(ContactOperationType.export); try { await connector.sendFrame(exportContactFrame, expectsGenericAck: true); } catch (e) { _pendingOperations.remove(ContactOperationType.export); if (mounted) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_contactAdvertCopyFailed), ); } } } Future _contactZeroHop(Uint8List pubKey) async { final connector = Provider.of(context, listen: false); final exportContactZeroHopFrame = buildZeroHopContact(pubKey); _pendingOperations.add(ContactOperationType.zeroHopShare); try { await connector.sendFrame( exportContactZeroHopFrame, expectsGenericAck: true, ); } catch (e) { _pendingOperations.remove(ContactOperationType.zeroHopShare); if (mounted) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), ); } } } 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) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_clipboardEmpty), ); } return; } final text = clipboardData.text!.trim(); if (!text.startsWith('meshcore://')) { if (mounted) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_invalidAdvertFormat), ); } return; } final hexString = text.substring('meshcore://'.length); final Uint8List importContactFrame; try { final bytes = hex2Uint8List(hexString); importContactFrame = buildImportContactFrame(bytes); } catch (e) { if (mounted) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_invalidAdvertFormat), ); } return; } _pendingOperations.add(ContactOperationType.import); try { await connector.sendFrame(importContactFrame, expectsGenericAck: true); } catch (e) { _pendingOperations.remove(ContactOperationType.import); if (mounted) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_contactImportFailed), ); } } } @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( title: AppBarTitle(context.l10n.contacts_title), automaticallyImplyLeading: false, bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( tooltip: context.l10n.contacts_moreOptions, itemBuilder: (context) => >[ PopupMenuItem( child: Row( children: [ const Icon(Icons.person_add_rounded), const SizedBox(width: 8), Text(context.l10n.discoveredContacts_Title), ], ), onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => const DiscoveryScreen(), ), ), ), PopupMenuItem( child: Row( children: [ const Icon(Icons.paste), const SizedBox(width: 8), Text(context.l10n.contacts_addContactFromClipboard), ], ), onTap: () => _contactImport(), ), const PopupMenuDivider(), PopupMenuItem( child: Row( children: [ const Icon(Icons.connect_without_contact), const SizedBox(width: 8), Text(context.l10n.contacts_zeroHopAdvert), ], ), onTap: () => { connector.sendSelfAdvert(flood: false), showDismissibleSnackBar( context, 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), showDismissibleSnackBar( context, 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([])), ), const PopupMenuDivider(), PopupMenuItem( child: Row( children: [ Icon( Icons.logout, color: Theme.of(context).colorScheme.error, ), 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), floatingActionButton: FloatingActionButton( onPressed: () => _showAddContactSheet(context), child: const Icon(Icons.person_add), ), bottomNavigationBar: SafeArea( top: false, child: QuickSwitchBar( selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), contactsUnreadCount: connector.getTotalContactsUnreadCount(), channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), ); } void _showAddContactSheet(BuildContext context) { showMeshSheet( context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ BottomSheetHeader(title: context.l10n.contacts_title), ListTile( leading: const Icon(Icons.paste), title: Text(context.l10n.contacts_addContactFromClipboard), onTap: () { Navigator.pop(sheetContext); _contactImport(); }, ), ListTile( leading: const Icon(Icons.person_add_rounded), title: Text(context.l10n.discoveredContacts_Title), onTap: () { Navigator.pop(sheetContext); Navigator.push( context, MaterialPageRoute( builder: (context) => const DiscoveryScreen(), ), ); }, ), const SizedBox(height: 8), ], ), ), ); } Future _disconnect( BuildContext context, MeshCoreConnector connector, ) async { await showDisconnectDialog(context, connector); } ContactGroup? _selectedGroupForName(String selectedGroupName) { if (selectedGroupName == contactsAllGroupsValue) return null; for (final group in _groups) { if (group.name == selectedGroupName) return group; } return null; } void _ensureValidSelectedGroup() { final viewState = context.read(); if (viewState.contactsSelectedGroupName == contactsAllGroupsValue) return; final exists = _groups.any( (group) => group.name == viewState.contactsSelectedGroupName, ); if (!exists) { viewState.setContactsSelectedGroupName(contactsAllGroupsValue); } } void _closeDropdownAndRun(BuildContext popupContext, VoidCallback action) { final route = ModalRoute.of(popupContext); if (route != null && route.isCurrent) { Navigator.of(popupContext).pop(); } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; action(); }); } Widget _buildFilterButton( BuildContext context, UiViewStateService viewState, ) { return ContactsFilterMenu( sortOption: viewState.contactsSortOption, typeFilter: viewState.contactsTypeFilter, showUnreadOnly: viewState.contactsShowUnreadOnly, onSortChanged: (value) { viewState.setContactsSortOption(value); }, onTypeFilterChanged: (value) { viewState.setContactsTypeFilter(value); }, onUnreadOnlyChanged: (value) { viewState.setContactsShowUnreadOnly(value); }, ); } Widget _buildGroupButton( BuildContext context, MeshCoreConnector connector, UiViewStateService viewState, List contacts, List sortedGroups, ) { final canManageGroups = _hasGroupStoreScope(connector); final selectedGroupName = _selectedGroupForName(viewState.contactsSelectedGroupName)?.name ?? context.l10n.listFilter_all; final double menuWidth = (MediaQuery.sizeOf(context).width - 16).clamp( 0.0, double.infinity, ); return PopupMenuButton( position: PopupMenuPosition.under, constraints: BoxConstraints.tightFor(width: menuWidth), onSelected: (String value) { viewState.setContactsSelectedGroupName(value); }, itemBuilder: (menuContext) => [ PopupMenuItem( value: contactsAllGroupsValue, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(menuContext.l10n.listFilter_all), IconButton( tooltip: menuContext.l10n.contacts_newGroup, icon: const Icon(Icons.group_add, size: 20), onPressed: canManageGroups ? () => _closeDropdownAndRun( menuContext, () => _showGroupEditor(this.context, contacts), ) : () => _closeDropdownAndRun( menuContext, () => _showGroupsUnavailableMessage(this.context), ), ), ], ), ), ...sortedGroups.map((group) { return PopupMenuItem( value: group.name, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text(group.name, overflow: TextOverflow.ellipsis), ), IconButton( tooltip: menuContext.l10n.contacts_editGroup, icon: const Icon(Icons.edit, size: 20), onPressed: canManageGroups ? () => _closeDropdownAndRun( menuContext, () => _showGroupEditor( this.context, contacts, group: group, ), ) : () => _closeDropdownAndRun( menuContext, () => _showGroupsUnavailableMessage(this.context), ), ), const SizedBox(width: 8), IconButton( tooltip: menuContext.l10n.contacts_deleteGroup, icon: Icon( Icons.delete, size: 20, color: Theme.of(context).colorScheme.error, ), onPressed: canManageGroups ? () => _closeDropdownAndRun( menuContext, () => _confirmDeleteGroup(this.context, group), ) : () => _closeDropdownAndRun( menuContext, () => _showGroupsUnavailableMessage(this.context), ), ), ], ), ); }), ], child: SizedBox( height: 48, child: DecoratedBox( decoration: BoxDecoration( border: Border.all(color: Theme.of(context).colorScheme.outline), borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ Expanded( child: Text( selectedGroupName, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), const Icon(Icons.arrow_drop_down), ], ), ), ), ), ); } Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final viewState = context.watch(); final contacts = connector.contacts; final waitingForInitialContacts = connector.isConnected && !connector.hasLoadedContacts && !connector.isLoadingContacts; final waitingForFirstContact = connector.isLoadingContacts && contacts.isEmpty; if (waitingForInitialContacts || waitingForFirstContact) { 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, action: FilledButton.icon( onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (context) => const DiscoveryScreen()), ), icon: const Icon(Icons.person_add_rounded), label: Text(context.l10n.discoveredContacts_Title), ), ); } final filteredAndSorted = _filterAndSortContacts( contacts, connector, viewState, ); String hintText = ""; switch (viewState.contactsTypeFilter) { case ContactTypeFilter.all: hintText = context.l10n.contacts_searchContacts( filteredAndSorted.length, viewState.contactsShowUnreadOnly ? " ${context.l10n.contacts_unread}" : "", ); break; case ContactTypeFilter.users: hintText = context.l10n.contacts_searchUsers( filteredAndSorted.length, viewState.contactsShowUnreadOnly ? " ${context.l10n.contacts_unread}" : "", ); break; case ContactTypeFilter.repeaters: hintText = context.l10n.contacts_searchRepeaters( filteredAndSorted.length, viewState.contactsShowUnreadOnly ? " ${context.l10n.contacts_unread}" : "", ); break; case ContactTypeFilter.rooms: hintText = context.l10n.contacts_searchRoomServers( filteredAndSorted.length, viewState.contactsShowUnreadOnly ? " ${context.l10n.contacts_unread}" : "", ); break; case ContactTypeFilter.favorites: hintText = context.l10n.contacts_searchFavorites( filteredAndSorted.length, viewState.contactsShowUnreadOnly ? " ${context.l10n.contacts_unread}" : "", ); break; } final groupsByName = {}; for (final group in _groups) { groupsByName.putIfAbsent(group.name, () => group); } final sortedGroups = groupsByName.values.toList() ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); final screenWidth = MediaQuery.sizeOf(context).width; final searchExpandedWidth = (screenWidth * 0.52).clamp( 97.0, double.infinity, ); // allow expansion up to 52% of screen width, but not less than the collapsed width final searchCollapsedWidth = (screenWidth * 0.22).clamp( 97.0, 120.0, ); //two 48px icon buttons + 1px divider return Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: Row( children: [ Expanded( child: _buildGroupButton( context, connector, viewState, contacts, sortedGroups, ), ), const SizedBox(width: 8), AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, width: viewState.contactsSearchExpanded ? searchExpandedWidth : searchCollapsedWidth, height: 48, child: DecoratedBox( decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.outline, ), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Expanded( child: viewState.contactsSearchExpanded ? TextField( controller: _searchController, autofocus: true, decoration: InputDecoration( hintText: hintText, border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), onChanged: (value) { _searchDebounce?.cancel(); _searchDebounce = Timer( const Duration(milliseconds: 300), () { if (!mounted) return; context .read() .setContactsSearchText(value); }, ); }, ) : const SizedBox.shrink(), ), SizedBox( width: 48, height: 48, child: IconButton( tooltip: viewState.contactsSearchExpanded ? context.l10n.contacts_searchClose : context.l10n.contacts_searchOpen, onPressed: () { if (viewState.contactsSearchExpanded) { _collapseContactsSearch(viewState); return; } viewState.setContactsSearchExpanded(true); }, icon: Icon( viewState.contactsSearchExpanded ? Icons.close : Icons.search, ), ), ), Container( width: 1, height: 24, color: Theme.of(context).colorScheme.outlineVariant, ), SizedBox( width: 48, height: 48, child: _buildFilterButton(context, viewState), ), ], ), ), ), ], ), ), Expanded( child: RefreshIndicator( onRefresh: () => connector.getContacts(), child: filteredAndSorted.isEmpty ? LayoutBuilder( builder: (context, constraints) => ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: EmptyState( icon: Icons.search_off, title: viewState.contactsShowUnreadOnly ? context.l10n.contacts_noUnreadContacts : context.l10n.contacts_noContactsFound, ), ), ], ), ) : ListView.builder( padding: const EdgeInsets.only(bottom: 88), itemCount: filteredAndSorted.length, itemBuilder: (context, index) { final contact = filteredAndSorted[index]; final unreadCount = connector.getUnreadCountForContact( contact, ); return _ContactTileEntrance( index: index, contact: contact, lastSeen: _resolveLastSeen(contact), unreadCount: unreadCount, isFavorite: contact.isFavorite, onTap: () => _openChat(context, contact), onLongPress: () => _showContactOptions(context, connector, contact), ); }, ), ), ), ], ); } List _filterAndSortContacts( List contacts, MeshCoreConnector connector, UiViewStateService viewState, ) { var filtered = contacts.where((contact) { if (viewState.contactsSearchText.isEmpty) return true; return matchesContactQuery(contact, viewState.contactsSearchText); }).toList(); final selectedGroup = _selectedGroupForName( viewState.contactsSelectedGroupName, ); if (selectedGroup != null) { final memberKeys = selectedGroup.memberKeys.toSet(); filtered = filtered .where((contact) => memberKeys.contains(contact.publicKeyHex)) .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 (viewState.contactsTypeFilter != ContactTypeFilter.all) { filtered = filtered .where( (contact) => _matchesTypeFilter(contact, viewState.contactsTypeFilter), ) .toList(); } if (viewState.contactsShowUnreadOnly) { filtered = filtered.where((contact) { return connector.getUnreadCountForContact(contact) > 0; }).toList(); } switch (viewState.contactsSortOption) { 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, ContactTypeFilter typeFilter) { switch (typeFilter) { case ContactTypeFilter.all: return true; case ContactTypeFilter.favorites: return contact.isFavorite; 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; } 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 { final connector = context.read(); final unread = connector.getUnreadCountForContactKey( contact.publicKeyHex, ); connector.markContactRead(contact.publicKeyHex); Navigator.push( context, MaterialPageRoute( builder: (context) => ChatScreen(contact: contact, initialUnreadCount: unread), ), ); } } 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, isAdmin) { // Navigate to repeater hub screen after successful login Navigator.push( context, MaterialPageRoute( builder: (context) => RepeaterHubScreen( repeater: repeater, password: password, isAdmin: isAdmin, ), ), ); }, ), ); } void _showRoomLogin( BuildContext context, Contact room, RoomLoginDestination destination, ) { showDialog( context: context, builder: (context) => RoomLoginDialog( room: room, onLogin: (password, isAdmin) { final connector = context.read(); final unread = connector.getUnreadCountForContactKey( room.publicKeyHex, ); connector.markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( builder: (context) => destination == RoomLoginDestination.management ? RepeaterHubScreen( repeater: room, password: password, isAdmin: isAdmin, ) : ChatScreen(contact: room, initialUnreadCount: unread), ), ); }, ), ); } void _confirmDeleteGroup(BuildContext context, ContactGroup group) { if (!_hasGroupStoreScope(context.read())) { _showGroupsUnavailableMessage(context); return; } 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); _ensureValidSelectedGroup(); }); await _saveGroups(); }, child: Text( context.l10n.common_delete, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), ], ), ); } void _showGroupEditor( BuildContext context, List contacts, { ContactGroup? group, }) { if (!_hasGroupStoreScope(context.read())) { _showGroupsUnavailableMessage(context); return; } 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: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.8, ), 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), Expanded( 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(context.l10n), ), 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) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_groupNameRequired), ); return; } if (name.toLowerCase() == contactsAllGroupsValue.toLowerCase()) { showDismissibleSnackBar( context, content: Text(context.l10n.contacts_groupNameReserved), ); return; } final exists = _groups.any((g) { if (isEditing && g.name == group.name) return false; return g.name.toLowerCase() == name.toLowerCase(); }); if (exists) { showDismissibleSnackBar( context, content: Text( context.l10n.contacts_groupAlreadyExists(name), ), ); return; } setState(() { final viewState = context.read(); if (isEditing) { final index = _groups.indexWhere( (g) => g.name == group.name, ); if (index != -1) { final wasSelected = viewState.contactsSelectedGroupName == group.name; _groups[index] = ContactGroup( name: name, memberKeys: selectedKeys.toList(), ); if (wasSelected) { viewState.setContactsSelectedGroupName(name); } } } else { _groups.add( ContactGroup( name: name, memberKeys: selectedKeys.toList(), ), ); viewState.setContactsSelectedGroupName(name); } _ensureValidSelectedGroup(); }); 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; final isFavorite = contact.isFavorite; showMeshSheet( context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ BottomSheetHeader( title: contact.name, subtitle: contact.typeLabel(context.l10n), ), if (isRepeater) ...[ ListTile( leading: Icon(Icons.radar, color: MeshPalette.signal), title: Text(context.l10n.contacts_ping), onTap: () { Navigator.pop(sheetContext); final hw = context .read() .pathHashByteWidth; Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPing, path: Uint8List.fromList([contact.publicKey.first]), targetContact: contact, pathHashByteWidth: hw, ), ), ); }, ), ListTile( leading: Icon(Icons.cell_tower, color: MeshPalette.warn), title: Text(context.l10n.contacts_manageRepeater), onTap: () { Navigator.pop(sheetContext); _showRepeaterLogin(context, contact); }, ), ] else if (isRoom) ...[ ListTile( leading: Icon(Icons.radar, color: MeshPalette.signal), title: Text(context.l10n.contacts_pathTrace), onTap: () { Navigator.pop(sheetContext); final hw = context .read() .pathHashByteWidth; Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: contact.pathBytesForDisplay.isNotEmpty ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing, path: contact.pathBytesForDisplay.isNotEmpty ? contact.pathBytesForDisplay : Uint8List.fromList([contact.publicKey.first]), flipPathAround: contact.pathBytesForDisplay.isNotEmpty, targetContact: contact, pathHashByteWidth: hw, ), ), ); }, ), ListTile( leading: Icon(Icons.meeting_room, color: MeshPalette.blue), title: Text(context.l10n.contacts_roomLogin), onTap: () { Navigator.pop(sheetContext); _showRoomLogin(context, contact, RoomLoginDestination.chat); }, ), ListTile( leading: Icon(Icons.room_preferences, color: MeshPalette.warn), title: Text(context.l10n.room_management), onTap: () { Navigator.pop(sheetContext); _showRoomLogin( context, contact, RoomLoginDestination.management, ); }, ), ] else ...[ if (contact.pathLength > 0) ListTile( leading: Icon(Icons.radar, color: MeshPalette.signal), title: Text(context.l10n.contacts_chatTraceRoute), onTap: () { Navigator.pop(sheetContext); final hw = context .read() .pathHashByteWidth; Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_pathTraceTo( contact.name, ), path: contact.pathBytesForDisplay, flipPathAround: true, targetContact: contact, pathHashByteWidth: hw, ), ), ); }, ), ], ListTile( leading: Icon( isFavorite ? Icons.star : Icons.star_border, color: MeshPalette.warn, ), title: Text( isFavorite ? context.l10n.listFilter_removeFromFavorites : context.l10n.listFilter_addToFavorites, ), onTap: () async { Navigator.pop(sheetContext); await connector.setContactFlags( contact, isFavorite: !isFavorite, ); }, ), 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: Icon( Icons.delete, color: Theme.of(context).colorScheme.error, ), title: Text( context.l10n.contacts_deleteContact, style: TextStyle(color: Theme.of(context).colorScheme.error), ), onTap: () { Navigator.pop(sheetContext); _confirmDelete(context, connector, contact); }, ), const SizedBox(height: 8), ], ), ), ); } 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: TextStyle(color: Theme.of(context).colorScheme.error), ), ), ], ), ); } } class _ContactTile extends StatelessWidget { final Contact contact; final DateTime lastSeen; final int unreadCount; final bool isFavorite; final VoidCallback onTap; final VoidCallback onLongPress; const _ContactTile({ required this.contact, required this.lastSeen, required this.unreadCount, required this.isFavorite, required this.onTap, required this.onLongPress, }); /// Node-type avatar color per design language. Color _avatarColor() { switch (contact.type) { case advTypeRepeater: return MeshPalette.warn; case advTypeRoom: return MeshPalette.magenta; case advTypeSensor: return const Color(0xFF4ACCC4); // teal default: return MeshPalette .blue; // chat — AvatarCircle handles deterministic hue } } /// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials. IconData? _avatarIcon() { switch (contact.type) { case advTypeRepeater: return Icons.cell_tower; case advTypeRoom: return Icons.meeting_room; case advTypeSensor: return Icons.sensors; default: return null; // chat uses initials } } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final emoji = firstEmoji(contact.name); final isChat = contact.type == advTypeChat; final pathLen = contact.pathBytesForDisplay.length; final isDirect = contact.pathLength >= 0; final hasPath = pathLen > 0 || contact.pathLength == 0; return GestureDetector( onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null, child: MeshCard( onTap: onTap, onLongPress: onLongPress, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( children: [ // Avatar if (emoji != null) Container( width: 42, height: 42, decoration: BoxDecoration( shape: BoxShape.circle, color: scheme.surfaceContainerHigh, border: Border.all(color: scheme.outlineVariant), ), alignment: Alignment.center, child: Text(emoji, style: const TextStyle(fontSize: 20)), ) else AvatarCircle( name: contact.name, size: 42, color: isChat ? null : _avatarColor(), icon: _avatarIcon(), ), const SizedBox(width: 12), // Main content Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Name row + route chip Row( children: [ Expanded( child: Text( contact.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: unreadCount > 0 ? FontWeight.w700 : FontWeight.w500, fontSize: 15, color: scheme.onSurface, ), ), ), if (isFavorite) ...[ const SizedBox(width: 4), Icon(Icons.star, size: 13, color: MeshPalette.warn), ], if (contact.hasLocation) ...[ const SizedBox(width: 4), Icon( Icons.location_on, size: 13, color: scheme.onSurfaceVariant.withValues( alpha: 0.55, ), ), ], ], ), const SizedBox(height: 3), // Path / subtitle row Row( children: [ Expanded( child: Text( contact.pathLabel(context.l10n), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, color: scheme.onSurfaceVariant, ), ), ), if (hasPath) ...[ const SizedBox(width: 6), RouteChip( isDirect: isDirect, hops: isDirect ? contact.pathLength : null, ), ], ], ), ], ), ), const SizedBox(width: 10), // Trailing: time + unread badge // Clamp text scale to prevent overflow in trailing section. 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, mainAxisSize: MainAxisSize.min, children: [ if (unreadCount > 0) ...[ UnreadBadge(count: unreadCount), const SizedBox(height: 4), ], Text( _formatLastSeen(context, lastSeen), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.right, style: MeshTheme.mono( fontSize: 11, color: unreadCount > 0 ? MeshPalette.blue : scheme.onSurfaceVariant, ), ), ], ), ), ], ), ), ); } 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); } } // Wrap each contact tile with staggered entrance. class _ContactTileEntrance extends StatelessWidget { final int index; final Contact contact; final DateTime lastSeen; final int unreadCount; final bool isFavorite; final VoidCallback onTap; final VoidCallback onLongPress; const _ContactTileEntrance({ required this.index, required this.contact, required this.lastSeen, required this.unreadCount, required this.isFavorite, required this.onTap, required this.onLongPress, }); @override Widget build(BuildContext context) { return ListEntrance( index: index, child: _ContactTile( contact: contact, lastSeen: lastSeen, unreadCount: unreadCount, isFavorite: isFavorite, onTap: onTap, onLongPress: onLongPress, ), ); } }