diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 126696a..8833217 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -57,9 +57,7 @@ class Channel { } else { // Normal channel: require explicit secret if (explicitSecret == null || explicitSecret.length != 16) { - throw ArgumentError( - 'Normal channels require a 16-byte secret', - ); + throw ArgumentError('Normal channels require a 16-byte secret'); } return Channel( index: index, @@ -78,6 +76,18 @@ class Channel { return Uint8List.fromList(digest.bytes.sublist(0, 16)); } + static bool isHashChannelName(String channelName) { + return channelName.trim().startsWith('#'); + } + + static String pskBase64ForHashChannelName(String channelName) { + final normalized = channelName.trim(); + if (!isHashChannelName(normalized)) { + throw ArgumentError('Only #channels can export derived psk_base64'); + } + return base64.encode(_generateHashChannelSecret(normalized)); + } + /// Create the default public channel (channel 0) /// Uses the well-known pre-shared key from MeshCore factory Channel.publicChannel() { @@ -85,8 +95,22 @@ class Channel { index: 0, name: 'Public Channel', secret: Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]), flags: null, ); @@ -95,6 +119,9 @@ class Channel { /// Check if this is a hash-based channel (name starts with '#') bool get isHashChannel => name.startsWith('#'); + /// Base64-encoded PSK for sharing with firmware CLI and related tooling. + String get pskBase64 => base64.encode(secret); + /// Display name for the channel /// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N" String get displayName { @@ -131,12 +158,7 @@ class Channel { } /// Create a copy with modified fields - Channel copyWith({ - int? index, - String? name, - Uint8List? secret, - int? flags, - }) { + Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) { return Channel( index: index ?? this.index, name: name ?? this.name, diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 6166a5e..5a480e6 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -107,6 +107,15 @@ class _PendingRepeaterOwnerRequest { const _PendingRepeaterOwnerRequest({required this.publicKey}); } +enum ContactsTabSection { + favourites, + teamMembers, + repeaters, + sensors, + rooms, + channels, +} + /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { static const int _maxDirectPayloadHops = 3; @@ -162,6 +171,9 @@ class AppProvider with ChangeNotifier { bool get isContactsEnabled => _isContactsEnabled; bool _isSensorsEnabled = true; bool get isSensorsEnabled => _isSensorsEnabled; + final Map _contactsSectionVisibility = { + for (final section in ContactsTabSection.values) section: true, + }; bool _isVoiceSilenceTrimmingEnabled = true; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; @@ -238,6 +250,7 @@ class AppProvider with ChangeNotifier { _initializeLocationTracking(); _loadMapEnabled(); _loadContactsEnabled(); + _loadContactsSectionVisibility(); _loadSensorsEnabled(); _loadVoiceSilenceTrimmingEnabled(); _loadVoiceBandPassFilterEnabled(); @@ -259,6 +272,27 @@ class AppProvider with ChangeNotifier { return ProfileStorageScope.scopedKey(baseKey); } + bool isContactsSectionEnabled(ContactsTabSection section) { + return _contactsSectionVisibility[section] ?? true; + } + + String _contactsSectionVisibilityKey(ContactsTabSection section) { + switch (section) { + case ContactsTabSection.favourites: + return 'contacts_section_favourites_enabled'; + case ContactsTabSection.teamMembers: + return 'contacts_section_team_members_enabled'; + case ContactsTabSection.repeaters: + return 'contacts_section_repeaters_enabled'; + case ContactsTabSection.sensors: + return 'contacts_section_sensors_enabled'; + case ContactsTabSection.rooms: + return 'contacts_section_rooms_enabled'; + case ContactsTabSection.channels: + return 'contacts_section_channels_enabled'; + } + } + void _startPacketCapturePersistence() { _packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { @@ -565,6 +599,37 @@ class AppProvider with ChangeNotifier { } } + Future _loadContactsSectionVisibility() async { + try { + final prefs = await SharedPreferences.getInstance(); + for (final section in ContactsTabSection.values) { + _contactsSectionVisibility[section] = + prefs.getBool(_scopedKey(_contactsSectionVisibilityKey(section))) ?? + true; + } + notifyListeners(); + } catch (e) { + debugPrint('Error loading contacts section visibility settings: $e'); + } + } + + Future setContactsSectionEnabled( + ContactsTabSection section, + bool enabled, + ) async { + try { + _contactsSectionVisibility[section] = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey(_contactsSectionVisibilityKey(section)), + enabled, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error saving contacts section visibility setting: $e'); + } + } + /// Load sensors enabled setting from shared preferences Future _loadSensorsEnabled() async { try { @@ -933,9 +998,7 @@ class AppProvider with ChangeNotifier { // we also import into firmware so subsequent getContact calls work. // Matches the official app which calls cmdGetAdvertPath for all adverts. if (source == ContactReceiveSource.advert) { - unawaited( - connectionProvider.importReceivedAdvert(contact.publicKey), - ); + unawaited(connectionProvider.importReceivedAdvert(contact.publicKey)); } final isNewPendingAdvert = contactsProvider .addOrUpdatePendingAdvertContact( @@ -1352,9 +1415,7 @@ class AppProvider with ChangeNotifier { if (_handlePendingRepeaterOwnerResponse(tag, responseData)) { return; } - debugPrint( - '📊 [AppProvider] Binary response (0x8C tag=$tag) received', - ); + debugPrint('📊 [AppProvider] Binary response (0x8C tag=$tag) received'); // Binary responses carry Cayenne LPP telemetry data. // The data starts with a channel byte — valid LPP always has at least // 3 bytes (channel + type + value). Skip clearly non-telemetry payloads. @@ -2159,9 +2220,7 @@ class AppProvider with ChangeNotifier { // already added to pending adverts and showed notification. // Nothing else to do — the callback pipeline handles everything. if (wasKnown) { - debugPrint( - ' [pushAdvert] Existing contact refreshed', - ); + debugPrint(' [pushAdvert] Existing contact refreshed'); } } @@ -2721,6 +2780,7 @@ class AppProvider with ChangeNotifier { await Future.wait([ _loadMapEnabled(), _loadContactsEnabled(), + _loadContactsSectionVisibility(), _loadSensorsEnabled(), _loadVoiceSilenceTrimmingEnabled(), _loadVoiceBandPassFilterEnabled(), diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index e3d5b53..179dfbb 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -1,9 +1,11 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import '../l10n/app_localizations.dart'; +import '../models/channel.dart'; import '../models/contact.dart'; import '../models/contact_group.dart'; import '../providers/contacts_provider.dart'; @@ -48,6 +50,7 @@ class _ContactsTabState extends State { ContactSection.repeaters: ContactSortMode.lastSeen, ContactSection.sensors: ContactSortMode.lastSeen, ContactSection.rooms: ContactSortMode.lastSeen, + ContactSection.channels: ContactSortMode.alphabetical, }; @override @@ -296,17 +299,19 @@ class _ContactsTabState extends State { List _sortContacts(List contacts, ContactSection section) { final sorted = List.from(contacts); - if (section == ContactSection.channels) { - sorted.sort( - (a, b) => - a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), - ); - return sorted; - } - - final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen; + final sortMode = + _sortModes[section] ?? + (section == ContactSection.channels + ? ContactSortMode.alphabetical + : ContactSortMode.lastSeen); sorted.sort((a, b) { + if (sortMode == ContactSortMode.alphabetical) { + return a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + } + if (sortMode == ContactSortMode.distance) { final distanceA = _distanceFromCurrentPosition(a); final distanceB = _distanceFromCurrentPosition(b); @@ -321,7 +326,12 @@ class _ContactsTabState extends State { } } - return b.lastSeenTime.compareTo(a.lastSeenTime); + final lastSeenCompare = b.lastSeenTime.compareTo(a.lastSeenTime); + if (lastSeenCompare != 0) { + return lastSeenCompare; + } + + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); }); return sorted; @@ -480,8 +490,35 @@ class _ContactsTabState extends State { widget.onNavigateToMap?.call(); } + Future _exportHashChannelPskBase64( + BuildContext context, + Contact channel, + ) async { + final channelName = channel.advName.trim(); + if (!Channel.isHashChannelName(channelName)) { + return; + } + + final messenger = ScaffoldMessenger.of(context); + final copiedMessage = AppLocalizations.of( + context, + )!.copiedToClipboard('psk_base64'); + + await Clipboard.setData( + ClipboardData(text: Channel.pskBase64ForHashChannelName(channelName)), + ); + if (!mounted) { + return; + } + + messenger.showSnackBar(SnackBar(content: Text(copiedMessage))); + } + void _showChannelActionSheet(BuildContext context, Contact channel) { final l10n = AppLocalizations.of(context)!; + final canExportHashChannelPsk = Channel.isHashChannelName( + channel.advName.trim(), + ); showModalBottomSheet( context: context, @@ -509,6 +546,15 @@ class _ContactsTabState extends State { _showChannelOnMap(context, channel); }, ), + if (canExportHashChannelPsk) + ListTile( + leading: const Icon(Icons.key_outlined), + title: Text('${l10n.exportToClipboard} psk_base64'), + onTap: () async { + Navigator.pop(sheetContext); + await _exportHashChannelPskBase64(context, channel); + }, + ), if (!channel.isPublicChannel) ListTile( leading: Icon(Icons.delete, color: Colors.red), @@ -532,6 +578,60 @@ class _ContactsTabState extends State { ); } + Color _sectionAccentColor(BuildContext context, ContactSection section) { + final colorScheme = Theme.of(context).colorScheme; + switch (section) { + case ContactSection.teamMembers: + return colorScheme.primary; + case ContactSection.repeaters: + return colorScheme.tertiary; + case ContactSection.sensors: + return colorScheme.secondary; + case ContactSection.rooms: + return colorScheme.primary; + case ContactSection.channels: + return Color.alphaBlend( + colorScheme.tertiary.withValues(alpha: 0.65), + colorScheme.primary.withValues(alpha: 0.35), + ); + } + } + + IconData _sortModeIcon(ContactSortMode mode) { + switch (mode) { + case ContactSortMode.lastSeen: + return Icons.schedule_rounded; + case ContactSortMode.distance: + return Icons.near_me_rounded; + case ContactSortMode.alphabetical: + return Icons.sort_by_alpha_rounded; + } + } + + String _sortModeLabel(AppLocalizations l10n, ContactSortMode mode) { + switch (mode) { + case ContactSortMode.lastSeen: + return l10n.lastSeen; + case ContactSortMode.distance: + return l10n.distance; + case ContactSortMode.alphabetical: + return 'A-Z'; + } + } + + List _availableSortModes(ContactSection section) { + switch (section) { + case ContactSection.channels: + return const [ContactSortMode.alphabetical, ContactSortMode.lastSeen]; + default: + return const [ + ContactSortMode.lastSeen, + ContactSortMode.distance, + ContactSortMode.alphabetical, + ]; + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -539,7 +639,10 @@ class _ContactsTabState extends State { return Scaffold( body: Consumer( builder: (context, contactsProvider, child) { + final colorScheme = Theme.of(context).colorScheme; + final appProvider = context.watch(); final messagesProvider = context.watch(); + final connectionProvider = context.watch(); final allChatContacts = _sortContacts( contactsProvider.chatContacts, ContactSection.teamMembers, @@ -632,20 +735,49 @@ class _ContactsTabState extends State { _showSavedGroupsForSection(ContactSection.channels) ? savedChannelGroups : const <_RenderedSavedGroup>[]; - final showTeamMembersSection = allChatContacts.isNotEmpty; - final showRepeatersSection = allRepeaters.isNotEmpty; - final showSensorsSection = allSensors.isNotEmpty; - final showRoomsSection = allRooms.isNotEmpty; - final showChannelsSection = allChannels.isNotEmpty; - // Check if there are any displayable contacts - final hasDisplayableContacts = + final showFavouritesSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.favourites, + ) && + contactsProvider.favouriteContacts.isNotEmpty; + final showTeamMembersSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.teamMembers, + ) && + allChatContacts.isNotEmpty; + final showRepeatersSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.repeaters, + ) && + allRepeaters.isNotEmpty; + final showSensorsSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.sensors, + ) && + allSensors.isNotEmpty; + final showRoomsSection = + appProvider.isContactsSectionEnabled(ContactsTabSection.rooms) && + allRooms.isNotEmpty; + final showChannelsSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.channels, + ) && + allChannels.isNotEmpty; + final hasAnyContactData = allChatContacts.isNotEmpty || allRepeaters.isNotEmpty || allSensors.isNotEmpty || allRooms.isNotEmpty || allChannels.isNotEmpty; + final hasAnyVisibleSection = + showFavouritesSection || + showTeamMembersSection || + showRepeatersSection || + showSensorsSection || + showRoomsSection || + showChannelsSection; - if (!hasDisplayableContacts) { + if (!hasAnyContactData) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -666,10 +798,7 @@ class _ContactsTabState extends State { style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), - if (context - .watch() - .deviceInfo - .isConnected) + if (connectionProvider.deviceInfo.isConnected) Padding( padding: const EdgeInsets.only(top: 16), child: OutlinedButton.icon( @@ -683,289 +812,405 @@ class _ContactsTabState extends State { ); } + if (!hasAnyVisibleSection) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.tune_rounded, + size: 56, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'All contacts sections are hidden', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Enable one or more sections in Settings to show contacts here.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + return RefreshIndicator( onRefresh: _handleRefresh, child: ListView( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 24), children: [ - // Favourites (contacts with firmware favourite flag set) - if (contactsProvider.favouriteContacts.isNotEmpty) ...[ - _SectionHeader( - title: l10n.favourites, - count: contactsProvider.favouriteContacts.length, - icon: Icons.star, - ), - ..._buildContactSectionItems( - contactsProvider.favouriteContacts, - ), - const Divider(height: 32), - ], - - // Team Members (Chat contacts) - if (showTeamMembersSection) ...[ - _SectionHeader( - title: l10n.teamMembers, - count: chatContacts.length, - icon: Icons.people, - trailing: _buildSortMenu( - context, - ContactSection.teamMembers, - ), - ), - _buildSectionFilterField( - context, - ContactSection.teamMembers, - contactsProvider, - onSecondaryAction: () => _createAutoGroupsForSection( - context, - contactsProvider, - ContactSection.teamMembers, - allChatContacts, - emptyMessage: 'No contact auto groups available', - successMessage: 'Updated contact auto groups', - ), - secondaryActionIcon: Icons.auto_awesome_outlined, - secondaryActionTooltip: 'Auto group', - ), - ..._buildSavedGroupCards( - visibleSavedTeamGroups, - ContactSection.teamMembers, - ), - if (chatContacts.isEmpty && - _sectionHasActiveFilter(ContactSection.teamMembers)) - _buildNoFilterResults(context) - else - ..._buildContactSectionItems( - _excludeGroupedContacts( - chatContacts, - visibleSavedTeamGroups, - ), - ), - const Divider(height: 32), - ], - - // Repeaters - if (showRepeatersSection) ...[ - _SectionHeader( - title: l10n.repeaters, - count: repeaters.length, - icon: Icons.router, - trailing: Row( - mainAxisSize: MainAxisSize.min, + if (showFavouritesSection) + _SectionCard( + accentColor: Colors.amber, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (context - .watch() - .deviceInfo - .isConnected) - IconButton( - icon: const Icon(Icons.radar, size: 20), - tooltip: 'Discover repeaters', - visualDensity: VisualDensity.compact, - onPressed: () { - context - .read() - .discoverNodeType(advertType: 2); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.repeaterDiscoverySent), - ), - ); - }, - ), - _buildSortMenu(context, ContactSection.repeaters), + _SectionHeader( + title: l10n.favourites, + count: contactsProvider.favouriteContacts.length, + icon: Icons.star_rounded, + accentColor: Colors.amber, + ), + ..._buildContactSectionItems( + contactsProvider.favouriteContacts, + ), ], ), ), - _buildSectionFilterField( - context, - ContactSection.repeaters, - contactsProvider, - onSecondaryAction: () => _createAutoGroupsForSection( + + if (showTeamMembersSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.teamMembers, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.teamMembers, + count: chatContacts.length, + icon: Icons.people_alt_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.teamMembers, + ), + ), + _buildSectionFilterField( + context, + ContactSection.teamMembers, + contactsProvider, + onSecondaryAction: () => _createAutoGroupsForSection( + context, + contactsProvider, + ContactSection.teamMembers, + allChatContacts, + emptyMessage: 'No contact auto groups available', + successMessage: 'Updated contact auto groups', + ), + secondaryActionIcon: Icons.auto_awesome_outlined, + secondaryActionTooltip: 'Auto group', + ), + ..._buildSavedGroupCards( + visibleSavedTeamGroups, + ContactSection.teamMembers, + ), + if (chatContacts.isEmpty && + _sectionHasActiveFilter(ContactSection.teamMembers)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + chatContacts, + visibleSavedTeamGroups, + ), + ), + ], + ), + ), + + if (showRepeatersSection) + _SectionCard( + accentColor: _sectionAccentColor( context, - contactsProvider, ContactSection.repeaters, - allRepeaters, - maxNamedGroups: 2, - overflowGroupLabel: 'Others', - emptyMessage: 'No repeater auto groups available', - successMessage: 'Updated repeater auto groups', ), - secondaryActionIcon: Icons.auto_awesome_outlined, - secondaryActionTooltip: 'Auto group', - ), - ..._buildSavedGroupCards( - visibleSavedRepeaterGroups, - ContactSection.repeaters, - ), - if (repeaters.isEmpty && - _sectionHasActiveFilter(ContactSection.repeaters)) - _buildNoFilterResults(context) - else if (showRepeatersOthersGroup) - _InferredContactGroupCard( - label: l10n.others, - contacts: ungroupedRepeaters, - compactContacts: true, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - onNavigateToMessages: widget.onNavigateToMessages, - ) - else - ..._buildContactSectionItems( - ungroupedRepeaters, - compact: true, - ), - const Divider(height: 32), - ], - - // Sensors - if (showSensorsSection) ...[ - _SectionHeader( - title: l10n.sensors, - count: sensors.length, - icon: Icons.sensors, - trailing: Row( - mainAxisSize: MainAxisSize.min, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (context - .watch() - .deviceInfo - .isConnected) - IconButton( - icon: const Icon(Icons.radar, size: 20), - tooltip: 'Discover sensors', - visualDensity: VisualDensity.compact, - onPressed: () { - context - .read() - .discoverNodeType(advertType: 4); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.sensorDiscoverySent), - ), - ); - }, + _SectionHeader( + title: l10n.repeaters, + count: repeaters.length, + icon: Icons.router_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.repeaters, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (connectionProvider.deviceInfo.isConnected) + IconButton( + icon: const Icon(Icons.radar, size: 20), + tooltip: 'Discover repeaters', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + foregroundColor: _sectionAccentColor( + context, + ContactSection.repeaters, + ), + backgroundColor: _sectionAccentColor( + context, + ContactSection.repeaters, + ).withValues(alpha: 0.10), + ), + onPressed: () { + context + .read() + .discoverNodeType(advertType: 2); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.repeaterDiscoverySent, + ), + ), + ); + }, + ), + ], + ), + ), + _buildSectionFilterField( + context, + ContactSection.repeaters, + contactsProvider, + onSecondaryAction: () => _createAutoGroupsForSection( + context, + contactsProvider, + ContactSection.repeaters, + allRepeaters, + maxNamedGroups: 2, + overflowGroupLabel: 'Others', + emptyMessage: 'No repeater auto groups available', + successMessage: 'Updated repeater auto groups', + ), + secondaryActionIcon: Icons.auto_awesome_outlined, + secondaryActionTooltip: 'Auto group', + ), + ..._buildSavedGroupCards( + visibleSavedRepeaterGroups, + ContactSection.repeaters, + ), + if (repeaters.isEmpty && + _sectionHasActiveFilter(ContactSection.repeaters)) + _buildNoFilterResults(context) + else if (showRepeatersOthersGroup) + _InferredContactGroupCard( + label: l10n.others, + contacts: ungroupedRepeaters, + compactContacts: true, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + ) + else + ..._buildContactSectionItems( + ungroupedRepeaters, + compact: true, ), - _buildSortMenu(context, ContactSection.sensors), ], ), ), - _buildSectionFilterField( - context, - ContactSection.sensors, - contactsProvider, + + if (showSensorsSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.sensors, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.sensors, + count: sensors.length, + icon: Icons.sensors_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.sensors, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (connectionProvider.deviceInfo.isConnected) + IconButton( + icon: const Icon(Icons.radar, size: 20), + tooltip: 'Discover sensors', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + foregroundColor: _sectionAccentColor( + context, + ContactSection.sensors, + ), + backgroundColor: _sectionAccentColor( + context, + ContactSection.sensors, + ).withValues(alpha: 0.10), + ), + onPressed: () { + context + .read() + .discoverNodeType(advertType: 4); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.sensorDiscoverySent), + ), + ); + }, + ), + ], + ), + ), + _buildSectionFilterField( + context, + ContactSection.sensors, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedSensorGroups, + ContactSection.sensors, + ), + if (sensors.isEmpty && + _sectionHasActiveFilter(ContactSection.sensors)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + sensors, + visibleSavedSensorGroups, + ), + ), + ], + ), ), - ..._buildSavedGroupCards( - visibleSavedSensorGroups, - ContactSection.sensors, + + if (showRoomsSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.rooms, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.rooms, + count: rooms.length, + icon: Icons.meeting_room_outlined, + accentColor: _sectionAccentColor( + context, + ContactSection.rooms, + ), + ), + _buildSectionFilterField( + context, + ContactSection.rooms, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedRoomGroups, + ContactSection.rooms, + ), + if (rooms.isEmpty && + _sectionHasActiveFilter(ContactSection.rooms)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + rooms, + visibleSavedRoomGroups, + ), + ), + ], + ), ), - if (sensors.isEmpty && - _sectionHasActiveFilter(ContactSection.sensors)) - _buildNoFilterResults(context) - else - ..._buildContactSectionItems( - _excludeGroupedContacts( - sensors, - visibleSavedSensorGroups, + + if (showChannelsSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.channels, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.channels, + count: filteredChannels.length, + icon: Icons.broadcast_on_personal_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.channels, + ), + ), + _buildSectionFilterField( + context, + ContactSection.channels, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedChannelGroups, + ContactSection.channels, + ), + if (filteredChannels.isEmpty && + _sectionHasActiveFilter(ContactSection.channels)) + _buildNoFilterResults(context) + else + ..._excludeGroupedContacts( + filteredChannels, + visibleSavedChannelGroups, + ).map( + (channel) => _ChannelActivityCard( + channel: channel, + messagesProvider: messagesProvider, + contactsProvider: contactsProvider, + onTap: () => + _showChannelActionSheet(context, channel), + ), + ), + ], + ), + ), + + if (connectionProvider.deviceInfo.isConnected) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.35, + ), ), ), - const Divider(height: 32), - ], - - // Rooms - if (showRoomsSection) ...[ - _SectionHeader( - title: l10n.rooms, - count: rooms.length, - icon: Icons.tag, - trailing: _buildSortMenu(context, ContactSection.rooms), - ), - _buildSectionFilterField( - context, - ContactSection.rooms, - contactsProvider, - ), - ..._buildSavedGroupCards( - visibleSavedRoomGroups, - ContactSection.rooms, - ), - if (rooms.isEmpty && - _sectionHasActiveFilter(ContactSection.rooms)) - _buildNoFilterResults(context) - else - ..._buildContactSectionItems( - _excludeGroupedContacts(rooms, visibleSavedRoomGroups), - ), - const Divider(height: 32), - ], - - // Channels (visible in both simple and advanced mode) - if (showChannelsSection) ...[ - _SectionHeader( - title: l10n.channels, - count: filteredChannels.length, - icon: Icons.broadcast_on_personal, - ), - _buildSectionFilterField( - context, - ContactSection.channels, - contactsProvider, - ), - ..._buildSavedGroupCards( - visibleSavedChannelGroups, - ContactSection.channels, - ), - if (filteredChannels.isEmpty && - _sectionHasActiveFilter(ContactSection.channels)) - _buildNoFilterResults(context) - else ...[ - ..._excludeGroupedContacts( - filteredChannels, - visibleSavedChannelGroups, - ).map( - (channel) => _ChannelActivityCard( - channel: channel, - messagesProvider: messagesProvider, - contactsProvider: contactsProvider, - onTap: () => _showChannelActionSheet(context, channel), - ), - ), - ], - ], - - // Add Channel Button (visible in both simple and advanced mode, only show when connected) - if (context.watch().deviceInfo.isConnected) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), child: Row( children: [ Expanded( - child: OutlinedButton.icon( + child: FilledButton.tonalIcon( onPressed: () => _openAddContactScreen(context), - icon: Icon(Icons.person_add_alt_1_outlined), + icon: const Icon(Icons.person_add_alt_1_outlined), label: Text(l10n.addContact), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), ), ), ), const SizedBox(width: 12), Expanded( - child: OutlinedButton.icon( + child: FilledButton.tonalIcon( onPressed: () => _showAddChannelDialog(context), - icon: Icon(Icons.add_circle_outline), + icon: const Icon(Icons.add_circle_outline), label: Text(l10n.addChannel), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _sectionAccentColor( + context, + ContactSection.channels, + ).withValues(alpha: 0.14), + foregroundColor: _sectionAccentColor( + context, + ContactSection.channels, ), ), ), @@ -1139,6 +1384,10 @@ class _ContactsTabState extends State { ), ), ), + Padding( + padding: const EdgeInsets.only(right: 4), + child: _buildSortMenu(context, section, compact: true), + ), if (hasFilter) ...[ if (onSecondaryAction != null && secondaryActionIcon != null) @@ -1260,10 +1509,19 @@ class _ContactsTabState extends State { ); } - Widget _buildSortMenu(BuildContext context, ContactSection section) { + Widget _buildSortMenu( + BuildContext context, + ContactSection section, { + bool compact = false, + }) { final l10n = AppLocalizations.of(context)!; - final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen; + final selectedMode = + _sortModes[section] ?? + (section == ContactSection.channels + ? ContactSortMode.alphabetical + : ContactSortMode.lastSeen); final colorScheme = Theme.of(context).colorScheme; + final availableModes = _availableSortModes(section); return PopupMenuButton( tooltip: 'Sort', @@ -1273,53 +1531,75 @@ class _ContactsTabState extends State { _sortModes[section] = sortMode; }); }, - itemBuilder: (context) => [ - PopupMenuItem( - value: ContactSortMode.lastSeen, - child: Row( - children: [ - Icon( - Icons.schedule, - size: 18, - color: selectedMode == ContactSortMode.lastSeen - ? colorScheme.primary - : null, + itemBuilder: (context) => availableModes + .map( + (mode) => PopupMenuItem( + value: mode, + child: Row( + children: [ + Icon( + _sortModeIcon(mode), + size: 18, + color: selectedMode == mode ? colorScheme.primary : null, + ), + const SizedBox(width: 8), + Text(_sortModeLabel(l10n, mode)), + ], ), - SizedBox(width: 8), - Text(l10n.lastSeen), - ], - ), - ), - PopupMenuItem( - value: ContactSortMode.distance, - child: Row( - children: [ - Icon( - Icons.near_me, - size: 18, - color: selectedMode == ContactSortMode.distance - ? colorScheme.primary - : null, + ), + ) + .toList(), + child: compact + ? Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.10), + shape: BoxShape.circle, ), - SizedBox(width: 8), - Text(l10n.distance), - ], - ), - ), - ], - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.more_horiz, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ), + child: Icon( + _sortModeIcon(selectedMode), + size: 18, + color: colorScheme.primary, + ), + ) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.38), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _sortModeIcon(selectedMode), + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + _sortModeLabel(l10n, selectedMode), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.expand_more_rounded, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), ); } } -enum ContactSortMode { lastSeen, distance } +enum ContactSortMode { lastSeen, distance, alphabetical } enum ContactSection { teamMembers, repeaters, sensors, rooms, channels } @@ -1334,44 +1614,130 @@ class _SectionHeader extends StatelessWidget { final String title; final int count; final IconData icon; + final Color accentColor; final Widget? trailing; const _SectionHeader({ required this.title, required this.count, required this.icon, + required this.accentColor, this.trailing, }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon(icon, size: 20), - const SizedBox(width: 8), - Text( + final colorScheme = Theme.of(context).colorScheme; + final titleBlock = Row( + children: [ + Expanded( + child: Text( title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - count.toString(), - style: Theme.of(context).textTheme.labelSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, ), ), - if (trailing != null) ...[const Spacer(), trailing!], + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: accentColor.withValues(alpha: 0.18)), + ), + child: Text( + count.toString(), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ); + + return LayoutBuilder( + builder: (context, constraints) { + final useStackedLayout = trailing != null && constraints.maxWidth < 430; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: accentColor.withValues(alpha: 0.18), + ), + ), + alignment: Alignment.center, + child: Icon(icon, size: 20, color: accentColor), + ), + const SizedBox(width: 12), + Expanded(child: titleBlock), + if (!useStackedLayout && trailing != null) ...[ + const SizedBox(width: 12), + Flexible(child: trailing!), + ], + ], + ), + if (useStackedLayout) ...[ + const SizedBox(height: 10), + Align(alignment: Alignment.centerRight, child: trailing!), + ], + ], + ), + ); + }, + ); + } +} + +class _SectionCard extends StatelessWidget { + final Color accentColor; + final Widget child; + + const _SectionCard({required this.accentColor, required this.child}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surface, + accentColor.withValues(alpha: 0.04), + colorScheme.surfaceContainerLow, + ], + ), + borderRadius: BorderRadius.circular(26), + border: Border.all(color: accentColor.withValues(alpha: 0.14)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 6), + ), ], ), + child: child, ); } } diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 2c0ce92..8228465 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -28,6 +28,7 @@ import '../services/voice_recorder_service.dart'; import '../services/voice_codec_service.dart'; import '../utils/toast_logger.dart'; import '../utils/key_comparison.dart'; +import '../utils/contact_sorting.dart'; import '../utils/voice_message_parser.dart'; import '../utils/image_message_parser.dart'; import '../utils/tictactoe_message_parser.dart'; @@ -299,27 +300,27 @@ class _MessagesTabState extends State { final contactsProvider = context.read(); final normalizedQuery = query.trim().toLowerCase(); final contacts = - contactsProvider.contacts - .where((contact) => contact.type == ContactType.chat) - .where((contact) { - if (normalizedQuery.isEmpty) return true; - return contact.displayName.toLowerCase().contains( - normalizedQuery, - ); - }) - .toList() - ..sort((a, b) { - final aName = a.displayName.toLowerCase(); - final bName = b.displayName.toLowerCase(); - final aStarts = - normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery); - final bStarts = - normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery); - if (aStarts != bStarts) { - return aStarts ? -1 : 1; - } - return aName.compareTo(bName); - }); + contactsProvider.chatContacts.where((contact) { + if (normalizedQuery.isEmpty) return true; + return contact.displayName.toLowerCase().contains(normalizedQuery); + }).toList()..sort((a, b) { + final primary = compareContactsByFavouriteThenLastSeen(a, b); + if (primary != 0) { + return primary; + } + + final aName = a.displayName.toLowerCase(); + final bName = b.displayName.toLowerCase(); + final aStarts = + normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery); + final bStarts = + normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery); + if (aStarts != bStarts) { + return aStarts ? -1 : 1; + } + + return compareContactsByDisplayName(a, b); + }); return contacts.take(8).toList(growable: false); } @@ -408,16 +409,33 @@ class _MessagesTabState extends State { final contactsProvider = context.read(); final messagesProvider = context.read(); - // Filter contacts by type - final contacts = contactsProvider.contacts - .where((c) => c.type == ContactType.chat) - .toList(); - final rooms = contactsProvider.contacts - .where((c) => c.type == ContactType.room) - .toList(); - final channels = contactsProvider.contacts - .where((c) => c.type == ContactType.channel) - .toList(); + final contacts = List.from(contactsProvider.chatContacts) + ..sort((a, b) { + final primary = compareContactsByFavouriteThenLastSeen(a, b); + if (primary != 0) { + return primary; + } + + return compareContactsByDisplayName(a, b); + }); + final rooms = List.from(contactsProvider.rooms) + ..sort((a, b) { + final primary = compareContactsByLastSeen(a, b); + if (primary != 0) { + return primary; + } + + return compareContactsByDisplayName(a, b); + }); + final channels = List.from(contactsProvider.channels) + ..sort((a, b) { + final primary = compareContactsByLastSeen(a, b); + if (primary != 0) { + return primary; + } + + return compareContactsByDisplayName(a, b); + }); showModalBottomSheet( context: context, @@ -534,7 +552,10 @@ class _MessagesTabState extends State { }).firstOrNull; if (recipient == null) { - ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound); + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotReplyContactNotFound, + ); return; } } @@ -563,13 +584,19 @@ class _MessagesTabState extends State { } else { final senderPrefix = message.senderPublicKeyPrefix; if (senderPrefix == null || senderPrefix.length < 6) { - ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplySenderMissing); + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotReplySenderMissing, + ); return; } recipient = contactsProvider.findContactByPrefix(senderPrefix); if (recipient == null) { - ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound); + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotReplyContactNotFound, + ); return; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3de2acc..31968e2 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -190,9 +190,11 @@ class _SettingsScreenState extends State { _isDeveloperModeEnabled = false; _versionTapCount = 0; }); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeDisabled))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.developerModeDisabled), + ), + ); return; } @@ -204,9 +206,11 @@ class _SettingsScreenState extends State { _isDeveloperModeEnabled = true; _versionTapCount = 0; }); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeEnabled))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.developerModeEnabled), + ), + ); return; } @@ -559,7 +563,9 @@ class _SettingsScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid), + content: Text( + AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid, + ), backgroundColor: Colors.orange, ), ); @@ -584,7 +590,9 @@ class _SettingsScreenState extends State { if (!updateInfo.isAvailable) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.youAreRunningTheLatestVersion), + content: Text( + AppLocalizations.of(context)!.youAreRunningTheLatestVersion, + ), backgroundColor: Colors.green, ), ); @@ -594,7 +602,11 @@ class _SettingsScreenState extends State { if (updateInfo.downloadUrl == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.updateAvailableButDownloadUrlNotFound), + content: Text( + AppLocalizations.of( + context, + )!.updateAvailableButDownloadUrlNotFound, + ), backgroundColor: Colors.orange, ), ); @@ -1178,6 +1190,100 @@ class _SettingsScreenState extends State { ), ]), + _buildSectionHeader(AppLocalizations.of(context)!.contacts), + Consumer( + builder: (context, appProvider, child) => _buildSettingsCard([ + SwitchListTile( + secondary: const Icon(Icons.star_outline_rounded), + title: Text(AppLocalizations.of(context)!.favourites), + subtitle: const Text( + 'Show the favourites section in the contacts tab', + ), + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.favourites, + ), + onChanged: (value) async { + await appProvider.setContactsSectionEnabled( + ContactsTabSection.favourites, + value, + ); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.people_alt_outlined), + title: Text(AppLocalizations.of(context)!.teamMembers), + subtitle: const Text( + 'Show direct team contacts in the contacts tab', + ), + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.teamMembers, + ), + onChanged: (value) async { + await appProvider.setContactsSectionEnabled( + ContactsTabSection.teamMembers, + value, + ); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.router_outlined), + title: Text(AppLocalizations.of(context)!.repeaters), + subtitle: const Text('Show repeater nodes in the contacts tab'), + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.repeaters, + ), + onChanged: (value) async { + await appProvider.setContactsSectionEnabled( + ContactsTabSection.repeaters, + value, + ); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.sensors_outlined), + title: Text(AppLocalizations.of(context)!.sensors), + subtitle: const Text('Show sensor nodes in the contacts tab'), + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.sensors, + ), + onChanged: (value) async { + await appProvider.setContactsSectionEnabled( + ContactsTabSection.sensors, + value, + ); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.meeting_room_outlined), + title: Text(AppLocalizations.of(context)!.rooms), + subtitle: const Text('Show rooms in the contacts tab'), + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.rooms, + ), + onChanged: (value) async { + await appProvider.setContactsSectionEnabled( + ContactsTabSection.rooms, + value, + ); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.broadcast_on_personal_outlined), + title: Text(AppLocalizations.of(context)!.channels), + subtitle: const Text('Show channels in the contacts tab'), + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.channels, + ), + onChanged: (value) async { + await appProvider.setContactsSectionEnabled( + ContactsTabSection.channels, + value, + ); + }, + ), + ]), + ), + _buildSectionHeader('Messaging'), _buildSettingsCard([ ListTile( @@ -1205,7 +1311,9 @@ class _SettingsScreenState extends State { Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.route), - title: Text(AppLocalizations.of(context)!.nearestRepeaterFallback), + title: Text( + AppLocalizations.of(context)!.nearestRepeaterFallback, + ), subtitle: const Text( 'After normal retries fail, try one final resend through the nearest repeater', ), @@ -1234,7 +1342,9 @@ class _SettingsScreenState extends State { 'Clear Messages', style: TextStyle(color: Colors.red), ), - subtitle: Text(AppLocalizations.of(context)!.deleteAllStoredMessageHistory), + subtitle: Text( + AppLocalizations.of(context)!.deleteAllStoredMessageHistory, + ), onTap: _clearMessages, ), Consumer( @@ -1344,7 +1454,9 @@ class _SettingsScreenState extends State { builder: (context, drawingProvider, child) => SwitchListTile( secondary: Icon(Icons.fmd_good_outlined), title: Text(AppLocalizations.of(context)!.showSarMarkersLabel), - subtitle: Text(AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap), + subtitle: Text( + AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap, + ), value: drawingProvider.showSarMarkers, onChanged: (value) { drawingProvider.toggleSarMarkers(); @@ -1354,7 +1466,9 @@ class _SettingsScreenState extends State { Consumer( builder: (context, mapProvider, child) => SwitchListTile( secondary: Icon(Icons.timeline), - title: Text(AppLocalizations.of(context)!.showAllContactTrailsLabel), + title: Text( + AppLocalizations.of(context)!.showAllContactTrailsLabel, + ), subtitle: const Text( 'Display location trails for all contacts that have history', ), @@ -1420,7 +1534,11 @@ class _SettingsScreenState extends State { builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.compress), title: Text(AppLocalizations.of(context)!.voiceCompressor), - subtitle: Text(AppLocalizations.of(context)!.balancesQuietAndLoudSpeechLevels), + subtitle: Text( + AppLocalizations.of( + context, + )!.balancesQuietAndLoudSpeechLevels, + ), value: appProvider.isVoiceCompressorEnabled, onChanged: (value) async { await appProvider.toggleVoiceCompressorEnabled(value); @@ -1431,7 +1549,11 @@ class _SettingsScreenState extends State { builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.speed), title: Text(AppLocalizations.of(context)!.voiceLimiter), - subtitle: Text(AppLocalizations.of(context)!.preventsClippingPeaksBeforeEncoding), + subtitle: Text( + AppLocalizations.of( + context, + )!.preventsClippingPeaksBeforeEncoding, + ), value: appProvider.isVoiceLimiterEnabled, onChanged: (value) async { await appProvider.toggleVoiceLimiterEnabled(value); @@ -1442,7 +1564,9 @@ class _SettingsScreenState extends State { builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.auto_fix_high), title: Text(AppLocalizations.of(context)!.micAutoGain), - subtitle: Text(AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel), + subtitle: Text( + AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel, + ), value: appProvider.isVoiceAutoGainEnabled, onChanged: (value) async { await appProvider.toggleVoiceAutoGainEnabled(value); @@ -1478,7 +1602,9 @@ class _SettingsScreenState extends State { Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.content_cut), - title: Text(AppLocalizations.of(context)!.trimSilenceInVoiceMessages), + title: Text( + AppLocalizations.of(context)!.trimSilenceInVoiceMessages, + ), subtitle: const Text( 'Removes long silent parts before sending voice', ), @@ -1648,7 +1774,9 @@ class _SettingsScreenState extends State { ), ListTile( leading: Icon(Icons.timer), - title: Text(AppLocalizations.of(context)!.activeuseUpdateInterval), + title: Text( + AppLocalizations.of(context)!.activeuseUpdateInterval, + ), subtitle: Text('$_fastLocationActiveCadenceSeconds s'), trailing: const Icon(Icons.chevron_right), onTap: _editFastLocationActiveCadence, diff --git a/lib/utils/contact_sorting.dart b/lib/utils/contact_sorting.dart new file mode 100644 index 0000000..c77b5e9 --- /dev/null +++ b/lib/utils/contact_sorting.dart @@ -0,0 +1,24 @@ +import '../models/contact.dart'; + +int compareContactsByLastSeen(Contact a, Contact b) { + return b.lastSeenTime.compareTo(a.lastSeenTime); +} + +int compareContactsByFavouriteThenLastSeen(Contact a, Contact b) { + if (a.isFavourite != b.isFavourite) { + return a.isFavourite ? -1 : 1; + } + + return compareContactsByLastSeen(a, b); +} + +int compareContactsByDisplayName(Contact a, Contact b) { + final nameCompare = a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + if (nameCompare != 0) { + return nameCompare; + } + + return a.publicKeyHex.compareTo(b.publicKeyHex); +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 0a54e4f..e016555 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -54,6 +54,14 @@ class ContactTile extends StatelessWidget { return l10n.daysAgo(diff.inDays); } + String _threeBytePrefix() { + final bytes = contact.publicKey.take(3); + return bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } + @override Widget build(BuildContext context) { final isChannel = contact.type == ContactType.channel; @@ -205,46 +213,73 @@ class ContactTile extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Stack( - clipBehavior: Clip.none, - children: [ - ContactAvatar(contact: contact, radius: 24), - if (contact.isNew) - Positioned( - top: -2, - right: -2, - child: Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), + SizedBox( + width: 58, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar(contact: contact, radius: 24), + if (contact.isNew) + Positioned( + top: -2, + right: -2, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + ), + ), + if (contact.type == ContactType.room && + roomLoginState != null) + Positioned( + bottom: -2, + right: -2, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: _getRoomStatusColor(roomLoginState), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + child: Icon( + _getRoomStatusIcon(roomLoginState), + size: 11, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _threeBytePrefix(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.6, ), ), - if (contact.type == ContactType.room && - roomLoginState != null) - Positioned( - bottom: -2, - right: -2, - child: Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: _getRoomStatusColor(roomLoginState), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - child: Icon( - _getRoomStatusIcon(roomLoginState), - size: 11, - color: Colors.white, - ), - ), - ), - ], + ], + ), ), const SizedBox(width: 10), Expanded( diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index 8be9073..aa4c593 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; -import '../../models/contact.dart'; + import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; import '../common/contact_avatar.dart'; +enum _RecipientSortMode { activity, alphabetical } + /// Bottom sheet for selecting message recipient (channel, contact, or room) class RecipientSelectorSheet extends StatefulWidget { final List contacts; @@ -35,6 +38,7 @@ class RecipientSelectorSheet extends StatefulWidget { class _RecipientSelectorSheetState extends State { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; + _RecipientSortMode _sortMode = _RecipientSortMode.activity; @override void dispose() { @@ -42,302 +46,374 @@ class _RecipientSelectorSheetState extends State { super.dispose(); } - List _filterContacts(List contacts) { - if (_searchQuery.isEmpty) return contacts; - final query = _searchQuery.toLowerCase(); - return contacts.where((contact) { - final name = contact.displayName.toLowerCase(); - return name.contains(query); + int _unreadFor(Contact? contact) { + if (contact == null) { + return widget.unreadCount; + } + + return widget.unreadCountsByPublicKey[contact.publicKeyHex] ?? 0; + } + + List _filterAndSortContacts( + List contacts, { + bool prioritizePublicChannel = false, + }) { + final normalizedQuery = _searchQuery.trim().toLowerCase(); + final filtered = contacts.where((contact) { + if (normalizedQuery.isEmpty) { + return true; + } + + return contact.displayName.toLowerCase().contains(normalizedQuery) || + contact.advName.toLowerCase().contains(normalizedQuery) || + contact.publicKeyShort.toLowerCase().contains(normalizedQuery); }).toList(); + + filtered.sort((a, b) { + if (prioritizePublicChannel && a.isPublicChannel != b.isPublicChannel) { + return a.isPublicChannel ? -1 : 1; + } + + if (_sortMode == _RecipientSortMode.activity) { + final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a)); + if (unreadCompare != 0) { + return unreadCompare; + } + } + + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + }); + + return filtered; } bool _isSelected(String type, Contact? contact) { - if (widget.currentDestinationType != type) return false; - if (contact == null) return widget.currentRecipientPublicKey == null; + if (widget.currentDestinationType != type) { + return false; + } + if (contact == null) { + return widget.currentRecipientPublicKey == null; + } return contact.publicKeyHex == widget.currentRecipientPublicKey; } + IconData _typeIcon(Contact contact) { + switch (contact.type) { + case ContactType.channel: + return Icons.broadcast_on_personal_rounded; + case ContactType.room: + return Icons.meeting_room_outlined; + case ContactType.repeater: + return Icons.router_outlined; + case ContactType.sensor: + return Icons.sensors_outlined; + case ContactType.chat: + return Icons.person_rounded; + case ContactType.none: + return Icons.help_outline_rounded; + } + } + + Color _sectionColor(BuildContext context, String type) { + final colorScheme = Theme.of(context).colorScheme; + switch (type) { + case 'channel': + return Color.alphaBlend( + colorScheme.tertiary.withValues(alpha: 0.55), + colorScheme.primary.withValues(alpha: 0.35), + ); + case 'room': + return colorScheme.secondary; + case 'contact': + return colorScheme.primary; + default: + return colorScheme.tertiary; + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final filteredContacts = _filterContacts(widget.contacts); - final filteredRooms = _filterContacts(widget.rooms); - final filteredChannels = _filterContacts(widget.channels); - final showChannelsSection = widget.channels.isNotEmpty; - final showContactsSection = widget.contacts.isNotEmpty; - final showRoomsSection = widget.rooms.isNotEmpty; - final showAnyRecipients = - showChannelsSection || showContactsSection || showRoomsSection; + final colorScheme = Theme.of(context).colorScheme; + final filteredContacts = _filterAndSortContacts(widget.contacts); + final filteredRooms = _filterAndSortContacts(widget.rooms); + final filteredChannels = _filterAndSortContacts( + widget.channels, + prioritizePublicChannel: true, + ); + final hasChannels = widget.channels.isNotEmpty; + final hasContacts = widget.contacts.isNotEmpty; + final hasRooms = widget.rooms.isNotEmpty; + final hasAnyRecipients = hasChannels || hasContacts || hasRooms; return Container( constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.8, + maxHeight: MediaQuery.of(context).size.height * 0.88, ), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), - ), - ), - child: Row( + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Column( children: [ - Text( - l10n.selectRecipient, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - tooltip: l10n.close, + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.selectRecipient, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (widget.showAllOption) + _SummaryChip( + icon: Icons.all_inbox_rounded, + label: l10n.showAll, + count: widget.unreadCount, + accentColor: colorScheme.primary, + ), + if (hasChannels) + _SummaryChip( + icon: Icons.broadcast_on_personal_rounded, + label: l10n.channels, + count: filteredChannels.length, + accentColor: _sectionColor( + context, + 'channel', + ), + ), + if (hasContacts) + _SummaryChip( + icon: Icons.person_rounded, + label: l10n.contacts, + count: filteredContacts.length, + accentColor: _sectionColor( + context, + 'contact', + ), + ), + if (hasRooms) + _SummaryChip( + icon: Icons.meeting_room_outlined, + label: l10n.rooms, + count: filteredRooms.length, + accentColor: _sectionColor(context, 'room'), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + IconButton.filledTonal( + onPressed: () => Navigator.pop(context), + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: l10n.searchRecipients, + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + filled: true, + fillColor: colorScheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, + ), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _SortChip( + icon: Icons.flash_on_rounded, + label: l10n.active, + selected: _sortMode == _RecipientSortMode.activity, + onTap: () { + setState(() { + _sortMode = _RecipientSortMode.activity; + }); + }, + ), + _SortChip( + icon: Icons.sort_by_alpha_rounded, + label: 'A-Z', + selected: _sortMode == _RecipientSortMode.alphabetical, + onTap: () { + setState(() { + _sortMode = _RecipientSortMode.alphabetical; + }); + }, + ), + ], + ), ), ], ), ), - - // Search field - Padding( - padding: EdgeInsets.all(16), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: l10n.searchRecipients, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - setState(() { - _searchQuery = ''; - }); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - setState(() { - _searchQuery = value; - }); - }, - ), - ), - - // Recipients list Flexible( child: ListView( - shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), children: [ - if (widget.showAllOption) ...[ - _buildOptionTile( - context: context, - icon: Icons.all_inbox, - title: l10n.showAll, - subtitle: l10n.allMessages, - unreadCount: widget.unreadCount, - isSelected: _isSelected('all', null), - onTap: () { - widget.onSelect('all', null); - Navigator.pop(context); - }, - ), - if (showAnyRecipients) const Divider(), - ], - // Channels section - if (showChannelsSection) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Text( - l10n.channels, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - if (filteredChannels.isEmpty) - Padding( - padding: EdgeInsets.all(16), - child: Text( - l10n.noChannelsFound, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - fontStyle: FontStyle.italic, + if (widget.showAllOption) + _buildAllOptionCard(context, l10n, colorScheme), + if (hasChannels) + _buildSectionCard( + context, + type: 'channel', + title: l10n.channels, + icon: Icons.broadcast_on_personal_rounded, + count: filteredChannels.length, + emptyLabel: l10n.noChannelsFound, + children: [ + for (final channel in filteredChannels) + _buildRecipientCard( + context: context, + type: 'channel', + contact: channel, + title: channel.getLocalizedDisplayName(context), + subtitle: channel.isPublicChannel + ? l10n.broadcastToAllNearby + : '${l10n.channel} ${channel.publicKey[1]}', + unreadCount: _unreadFor(channel), + isSelected: _isSelected('channel', channel), + onTap: () { + widget.onSelect('channel', channel); + Navigator.pop(context); + }, ), - textAlign: TextAlign.center, - ), - ) - else - ...filteredChannels.map((channel) { - return _buildRecipientTile( - context: context, - contact: channel, - title: channel.getLocalizedDisplayName(context), - subtitle: channel.isPublicChannel - ? l10n.broadcastToAllNearby - : '${l10n.channel} ${channel.publicKey[1]}', // Show slot number - unreadCount: - widget.unreadCountsByPublicKey[channel - .publicKeyHex] ?? - 0, - isSelected: _isSelected('channel', channel), - onTap: () { - widget.onSelect('channel', channel); - Navigator.pop(context); - }, - ); - }), - ], - - if (showChannelsSection && - (showContactsSection || showRoomsSection)) - const Divider(), - - // Contacts section - if (showContactsSection) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Text( - l10n.contacts, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), + ], ), - if (filteredContacts.isEmpty) - Padding( - padding: EdgeInsets.all(16), - child: Text( - l10n.noContactsFound, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - fontStyle: FontStyle.italic, + if (hasContacts) + _buildSectionCard( + context, + type: 'contact', + title: l10n.contacts, + icon: Icons.people_alt_rounded, + count: filteredContacts.length, + emptyLabel: l10n.noContactsFound, + children: [ + for (final contact in filteredContacts) + _buildRecipientCard( + context: context, + type: 'contact', + contact: contact, + title: contact.displayName, + subtitle: contact.publicKeyShort, + unreadCount: _unreadFor(contact), + isSelected: _isSelected('contact', contact), + onTap: () { + widget.onSelect('contact', contact); + Navigator.pop(context); + }, ), - textAlign: TextAlign.center, - ), - ) - else - ...filteredContacts.map((contact) { - return _buildRecipientTile( - context: context, - contact: contact, - title: contact.displayName, - subtitle: contact.publicKeyShort, - unreadCount: - widget.unreadCountsByPublicKey[contact - .publicKeyHex] ?? - 0, - isSelected: _isSelected('contact', contact), - onTap: () { - widget.onSelect('contact', contact); - Navigator.pop(context); - }, - ); - }), - ], - - if (showContactsSection && showRoomsSection) const Divider(), - - // Rooms section - if (showRoomsSection) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Text( - l10n.rooms, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), + ], ), - if (filteredRooms.isEmpty) - Padding( - padding: EdgeInsets.all(16), - child: Text( - l10n.noRoomsFound, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - fontStyle: FontStyle.italic, + if (hasRooms) + _buildSectionCard( + context, + type: 'room', + title: l10n.rooms, + icon: Icons.meeting_room_outlined, + count: filteredRooms.length, + emptyLabel: l10n.noRoomsFound, + children: [ + for (final room in filteredRooms) + _buildRecipientCard( + context: context, + type: 'room', + contact: room, + title: room.displayName, + subtitle: room.publicKeyShort, + unreadCount: _unreadFor(room), + isSelected: _isSelected('room', room), + onTap: () { + widget.onSelect('room', room); + Navigator.pop(context); + }, ), - textAlign: TextAlign.center, - ), - ) - else - ...filteredRooms.map((room) { - return _buildRecipientTile( - context: context, - contact: room, - title: room.displayName, - subtitle: room.publicKeyShort, - unreadCount: - widget.unreadCountsByPublicKey[room.publicKeyHex] ?? - 0, - isSelected: _isSelected('room', room), - onTap: () { - widget.onSelect('room', room); - Navigator.pop(context); - }, - ); - }), - ], - - // Empty state - if (!showAnyRecipients) ...[ + ], + ), + if (!hasAnyRecipients) Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.symmetric(vertical: 40), child: Column( children: [ Icon( - Icons.people_outline, + Icons.people_outline_rounded, size: 64, - color: Theme.of(context).disabledColor, + color: colorScheme.onSurfaceVariant, ), - SizedBox(height: 16), + const SizedBox(height: 16), Text( l10n.noRecipientsAvailable, style: Theme.of(context).textTheme.bodyLarge - ?.copyWith( - color: Theme.of(context).disabledColor, - ), + ?.copyWith(color: colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), ], ), ), - ], - - const SizedBox(height: 16), ], ), ), @@ -346,8 +422,193 @@ class _RecipientSelectorSheetState extends State { ); } - Widget _buildRecipientTile({ + Widget _buildSectionCard( + BuildContext context, { + required String type, + required String title, + required IconData icon, + required int count, + required String emptyLabel, + required List children, + }) { + final colorScheme = Theme.of(context).colorScheme; + final accentColor = _sectionColor(context, type); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surface, + accentColor.withValues(alpha: 0.05), + colorScheme.surfaceContainerLow, + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: accentColor.withValues(alpha: 0.14)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Icon(icon, size: 18, color: accentColor), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: accentColor.withValues(alpha: 0.18), + ), + ), + child: Text( + '$count', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + if (children.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + emptyLabel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ) + else ...[ + for (var i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) const SizedBox(height: 8), + ], + ], + ], + ), + ); + } + + Widget _buildAllOptionCard( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + final isSelected = _isSelected('all', null); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primaryContainer.withValues(alpha: 0.4), + colorScheme.surfaceContainerHigh, + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.28) + : colorScheme.outlineVariant.withValues(alpha: 0.2), + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: () { + widget.onSelect('all', null); + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Icon( + Icons.all_inbox_rounded, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.showAll, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + l10n.allMessages, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + _buildTrailing( + context, + unreadCount: widget.unreadCount, + isSelected: isSelected, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildRecipientCard({ required BuildContext context, + required String type, required Contact contact, required String title, required String subtitle, @@ -355,88 +616,111 @@ class _RecipientSelectorSheetState extends State { required bool isSelected, required VoidCallback onTap, }) { - return ListTile( - leading: ContactAvatar(contact: contact, radius: 20, displayName: title), - title: Row( - children: [ - Expanded( - child: Text( - title, - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), + final colorScheme = Theme.of(context).colorScheme; + final accentColor = _sectionColor(context, type); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Ink( + decoration: BoxDecoration( + color: isSelected + ? accentColor.withValues(alpha: 0.12) + : colorScheme.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? accentColor.withValues(alpha: 0.24) + : colorScheme.outlineVariant.withValues(alpha: 0.18), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar( + contact: contact, + radius: 22, + displayName: title, + ), + Positioned( + right: -3, + bottom: -3, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: accentColor.withValues(alpha: 0.3), + ), + ), + alignment: Alignment.center, + child: Icon( + _typeIcon(contact), + size: 10, + color: accentColor, + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: contact.isChannel ? null : 'monospace', + ), + ), + ], + ), + ), + const SizedBox(width: 10), + _buildTrailing( + context, + unreadCount: unreadCount, + isSelected: isSelected, + ), + ], ), ), - ], - ), - subtitle: Text( - subtitle, - style: const TextStyle(fontSize: 12, fontFamily: 'monospace').copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), ), ), - trailing: _buildTrailing( - context, - unreadCount: unreadCount, - isSelected: isSelected, - ), - onTap: onTap, ); } - Widget _buildOptionTile({ - required BuildContext context, - required IconData icon, - required String title, - required String subtitle, - required int unreadCount, - required bool isSelected, - required VoidCallback onTap, - }) { - return ListTile( - leading: CircleAvatar( - radius: 20, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Icon(icon, color: Theme.of(context).colorScheme.primary), - ), - title: Row( - children: [ - Expanded( - child: Text( - title, - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - ), - ), - ], - ), - subtitle: Text( - subtitle, - style: const TextStyle(fontSize: 12, fontFamily: 'monospace').copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - ), - trailing: _buildTrailing( - context, - unreadCount: unreadCount, - isSelected: isSelected, - ), - onTap: onTap, - ); - } - - Widget? _buildTrailing( + Widget _buildTrailing( BuildContext context, { required int unreadCount, required bool isSelected, }) { + final colorScheme = Theme.of(context).colorScheme; + if (unreadCount <= 0 && !isSelected) { - return null; + return const SizedBox.shrink(); } return Row( @@ -447,25 +731,123 @@ class _RecipientSelectorSheetState extends State { key: Key('unread-badge-$unreadCount'), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, + color: colorScheme.primary, borderRadius: BorderRadius.circular(999), ), child: Text( unreadCount > 99 ? '99+' : '$unreadCount', style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, + color: colorScheme.onPrimary, fontSize: 12, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, ), ), ), if (unreadCount > 0 && isSelected) const SizedBox(width: 8), if (isSelected) - Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - ), + Icon(Icons.check_circle_rounded, color: colorScheme.primary), ], ); } } + +class _SummaryChip extends StatelessWidget { + final IconData icon; + final String label; + final int count; + final Color accentColor; + + const _SummaryChip({ + required this.icon, + required this.label, + required this.count, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: accentColor.withValues(alpha: 0.14)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: accentColor), + const SizedBox(width: 8), + Text( + '$count', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _SortChip extends StatelessWidget { + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + const _SortChip({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return FilterChip( + selected: selected, + onSelected: (_) => onTap(), + avatar: Icon( + icon, + size: 16, + color: selected + ? colorScheme.onSecondaryContainer + : colorScheme.primary, + ), + label: Text( + label, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), + ), + side: BorderSide( + color: selected + ? colorScheme.secondaryContainer + : colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + selectedColor: colorScheme.secondaryContainer, + checkmarkColor: colorScheme.onSecondaryContainer, + backgroundColor: colorScheme.surfaceContainerLow, + labelStyle: TextStyle( + color: selected + ? colorScheme.onSecondaryContainer + : colorScheme.onSurface, + ), + showCheckmark: false, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ); + } +} diff --git a/test/models/channel_test.dart b/test/models/channel_test.dart new file mode 100644 index 0000000..6b4f9f9 --- /dev/null +++ b/test/models/channel_test.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; + +void main() { + test('hash channels expose deterministic psk_base64', () { + final channel = Channel.create(index: 3, name: '#ops'); + + expect(channel.isHashChannel, isTrue); + expect(channel.pskBase64, 'O2RN43fDLHh5NgWiWqkVvw=='); + expect( + Channel.pskBase64ForHashChannelName('#ops'), + 'O2RN43fDLHh5NgWiWqkVvw==', + ); + }); + + test('normal channels reject derived hashtag psk export helper', () { + expect( + () => Channel.pskBase64ForHashChannelName('ops'), + throwsArgumentError, + ); + }); + + test('normal channels still expose their stored psk_base64', () { + final channel = Channel.create( + index: 4, + name: 'ops', + explicitSecret: Uint8List.fromList(List.generate(16, (i) => i)), + ); + + expect(channel.isHashChannel, isFalse); + expect(channel.pskBase64, 'AAECAwQFBgcICQoLDA0ODw=='); + }); +} diff --git a/test/screens/contacts_tab_test.dart b/test/screens/contacts_tab_test.dart index 23541a9..2115a7a 100644 --- a/test/screens/contacts_tab_test.dart +++ b/test/screens/contacts_tab_test.dart @@ -1,8 +1,10 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact_group.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart'; @@ -15,8 +17,28 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + String? clipboardText; + setUp(() { SharedPreferences.setMockInitialValues({}); + clipboardText = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + switch (call.method) { + case 'Clipboard.setData': + clipboardText = + (call.arguments as Map)['text'] as String?; + return null; + case 'Clipboard.getData': + return {'text': clipboardText}; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); }); Contact buildChannel({required String name, required int channelIndex}) { @@ -123,6 +145,7 @@ void main() { await tester.tap(find.text('Ops')); await tester.pumpAndSettle(); + expect(find.text('Export psk_base64'), findsNothing); expect(find.text('Delete Channel'), findsOneWidget); await tester.tap(find.text('Delete Channel')); @@ -137,6 +160,26 @@ void main() { ); }); + testWidgets('hash channel activity card exports psk_base64', (tester) async { + await pumpContactsTab( + tester, + contacts: [buildChannel(name: '#ops', channelIndex: 3)], + ); + + expect(find.text('#ops'), findsOneWidget); + await tester.tap(find.text('#ops')); + await tester.pumpAndSettle(); + + expect(find.text('Export psk_base64'), findsOneWidget); + + await tester.tap(find.text('Export psk_base64')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(clipboardText, Channel.pskBase64ForHashChannelName('#ops')); + expect(find.text('psk_base64 copied to clipboard'), findsOneWidget); + }); + testWidgets('repeaters show Others group when multiple groups exist', ( tester, ) async { @@ -218,7 +261,7 @@ void main() { contacts: [buildSensor(seed: 60, name: 'WX Station')], ); - expect(find.text('Sensors'), findsOneWidget); + expect(find.text('Sensors'), findsWidgets); expect(find.text('WX Station'), findsOneWidget); }); } diff --git a/test/widgets/recipient_selector_sheet_test.dart b/test/widgets/recipient_selector_sheet_test.dart index 8222170..1d222c8 100644 --- a/test/widgets/recipient_selector_sheet_test.dart +++ b/test/widgets/recipient_selector_sheet_test.dart @@ -11,6 +11,8 @@ void main() { required String name, required ContactType type, int secondByte = 0, + int flags = 0, + int lastAdvert = 0, }) { final publicKey = Uint8List(32); publicKey[1] = secondByte; @@ -18,11 +20,11 @@ void main() { return Contact( publicKey: publicKey, type: type, - flags: 0, + flags: flags, outPathLen: 0, outPath: Uint8List(0), advName: name, - lastAdvert: 0, + lastAdvert: lastAdvert, advLat: 0, advLon: 0, lastMod: 0, @@ -98,4 +100,54 @@ void main() { expect(find.text('Show all'), findsNothing); expect(find.text('John Smith'), findsOneWidget); }); + + testWidgets('sorts contacts with favourites first, then last seen', ( + tester, + ) async { + final recentNonFavourite = buildContact( + name: 'Alpha', + type: ContactType.chat, + secondByte: 1, + lastAdvert: 300, + ); + final olderFavourite = buildContact( + name: 'Bravo', + type: ContactType.chat, + secondByte: 2, + flags: 0x01, + lastAdvert: 100, + ); + final newerFavourite = buildContact( + name: 'Charlie', + type: ContactType.chat, + secondByte: 3, + flags: 0x01, + lastAdvert: 200, + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: RecipientSelectorSheet( + contacts: [recentNonFavourite, olderFavourite, newerFavourite], + rooms: const [], + channels: const [], + unreadCount: 0, + unreadCountsByPublicKey: const {}, + showAllOption: false, + onSelect: (selectedRecipient, draftMessage) {}, + ), + ), + ), + ); + + final charlieY = tester.getTopLeft(find.text('Charlie')).dy; + final bravoY = tester.getTopLeft(find.text('Bravo')).dy; + final alphaY = tester.getTopLeft(find.text('Alpha')).dy; + + expect(charlieY, lessThan(bravoY)); + expect(bravoY, lessThan(alphaY)); + }); }