fix: Align contact sorting labels #0

This commit is contained in:
Janez T
2026-03-19 14:06:05 +01:00
parent e474168ba8
commit 8da69705ee
11 changed files with 1952 additions and 778 deletions

View File

@@ -57,9 +57,7 @@ class Channel {
} else { } else {
// Normal channel: require explicit secret // Normal channel: require explicit secret
if (explicitSecret == null || explicitSecret.length != 16) { if (explicitSecret == null || explicitSecret.length != 16) {
throw ArgumentError( throw ArgumentError('Normal channels require a 16-byte secret');
'Normal channels require a 16-byte secret',
);
} }
return Channel( return Channel(
index: index, index: index,
@@ -78,6 +76,18 @@ class Channel {
return Uint8List.fromList(digest.bytes.sublist(0, 16)); 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) /// Create the default public channel (channel 0)
/// Uses the well-known pre-shared key from MeshCore /// Uses the well-known pre-shared key from MeshCore
factory Channel.publicChannel() { factory Channel.publicChannel() {
@@ -85,8 +95,22 @@ class Channel {
index: 0, index: 0,
name: 'Public Channel', name: 'Public Channel',
secret: Uint8List.fromList([ secret: Uint8List.fromList([
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0x8b,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, 0x33,
0x87,
0xe9,
0xc5,
0xcd,
0xea,
0x6a,
0xc9,
0xe5,
0xed,
0xba,
0xa1,
0x15,
0xcd,
0x72,
]), ]),
flags: null, flags: null,
); );
@@ -95,6 +119,9 @@ class Channel {
/// Check if this is a hash-based channel (name starts with '#') /// Check if this is a hash-based channel (name starts with '#')
bool get isHashChannel => name.startsWith('#'); 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 /// Display name for the channel
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N" /// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName { String get displayName {
@@ -131,12 +158,7 @@ class Channel {
} }
/// Create a copy with modified fields /// Create a copy with modified fields
Channel copyWith({ Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
int? index,
String? name,
Uint8List? secret,
int? flags,
}) {
return Channel( return Channel(
index: index ?? this.index, index: index ?? this.index,
name: name ?? this.name, name: name ?? this.name,

View File

@@ -107,6 +107,15 @@ class _PendingRepeaterOwnerRequest {
const _PendingRepeaterOwnerRequest({required this.publicKey}); const _PendingRepeaterOwnerRequest({required this.publicKey});
} }
enum ContactsTabSection {
favourites,
teamMembers,
repeaters,
sensors,
rooms,
channels,
}
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
@@ -162,6 +171,9 @@ class AppProvider with ChangeNotifier {
bool get isContactsEnabled => _isContactsEnabled; bool get isContactsEnabled => _isContactsEnabled;
bool _isSensorsEnabled = true; bool _isSensorsEnabled = true;
bool get isSensorsEnabled => _isSensorsEnabled; bool get isSensorsEnabled => _isSensorsEnabled;
final Map<ContactsTabSection, bool> _contactsSectionVisibility = {
for (final section in ContactsTabSection.values) section: true,
};
bool _isVoiceSilenceTrimmingEnabled = true; bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
@@ -238,6 +250,7 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking(); _initializeLocationTracking();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled(); _loadContactsEnabled();
_loadContactsSectionVisibility();
_loadSensorsEnabled(); _loadSensorsEnabled();
_loadVoiceSilenceTrimmingEnabled(); _loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
@@ -259,6 +272,27 @@ class AppProvider with ChangeNotifier {
return ProfileStorageScope.scopedKey(baseKey); 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() { void _startPacketCapturePersistence() {
_packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer?.cancel();
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
@@ -565,6 +599,37 @@ class AppProvider with ChangeNotifier {
} }
} }
Future<void> _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<void> 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 /// Load sensors enabled setting from shared preferences
Future<void> _loadSensorsEnabled() async { Future<void> _loadSensorsEnabled() async {
try { try {
@@ -933,9 +998,7 @@ class AppProvider with ChangeNotifier {
// we also import into firmware so subsequent getContact calls work. // we also import into firmware so subsequent getContact calls work.
// Matches the official app which calls cmdGetAdvertPath for all adverts. // Matches the official app which calls cmdGetAdvertPath for all adverts.
if (source == ContactReceiveSource.advert) { if (source == ContactReceiveSource.advert) {
unawaited( unawaited(connectionProvider.importReceivedAdvert(contact.publicKey));
connectionProvider.importReceivedAdvert(contact.publicKey),
);
} }
final isNewPendingAdvert = contactsProvider final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertContact( .addOrUpdatePendingAdvertContact(
@@ -1352,9 +1415,7 @@ class AppProvider with ChangeNotifier {
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) { if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
return; return;
} }
debugPrint( debugPrint('📊 [AppProvider] Binary response (0x8C tag=$tag) received');
'📊 [AppProvider] Binary response (0x8C tag=$tag) received',
);
// Binary responses carry Cayenne LPP telemetry data. // Binary responses carry Cayenne LPP telemetry data.
// The data starts with a channel byte — valid LPP always has at least // The data starts with a channel byte — valid LPP always has at least
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads. // 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. // already added to pending adverts and showed notification.
// Nothing else to do — the callback pipeline handles everything. // Nothing else to do — the callback pipeline handles everything.
if (wasKnown) { if (wasKnown) {
debugPrint( debugPrint(' [pushAdvert] Existing contact refreshed');
' [pushAdvert] Existing contact refreshed',
);
} }
} }
@@ -2721,6 +2780,7 @@ class AppProvider with ChangeNotifier {
await Future.wait([ await Future.wait([
_loadMapEnabled(), _loadMapEnabled(),
_loadContactsEnabled(), _loadContactsEnabled(),
_loadContactsSectionVisibility(),
_loadSensorsEnabled(), _loadSensorsEnabled(),
_loadVoiceSilenceTrimmingEnabled(), _loadVoiceSilenceTrimmingEnabled(),
_loadVoiceBandPassFilterEnabled(), _loadVoiceBandPassFilterEnabled(),

View File

@@ -1,9 +1,11 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/channel.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
@@ -48,6 +50,7 @@ class _ContactsTabState extends State<ContactsTab> {
ContactSection.repeaters: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen,
ContactSection.sensors: ContactSortMode.lastSeen, ContactSection.sensors: ContactSortMode.lastSeen,
ContactSection.rooms: ContactSortMode.lastSeen, ContactSection.rooms: ContactSortMode.lastSeen,
ContactSection.channels: ContactSortMode.alphabetical,
}; };
@override @override
@@ -296,17 +299,19 @@ class _ContactsTabState extends State<ContactsTab> {
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) { List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts); final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) { final sortMode =
sorted.sort( _sortModes[section] ??
(a, b) => (section == ContactSection.channels
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), ? ContactSortMode.alphabetical
); : ContactSortMode.lastSeen);
return sorted;
}
final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen;
sorted.sort((a, b) { sorted.sort((a, b) {
if (sortMode == ContactSortMode.alphabetical) {
return a.displayName.toLowerCase().compareTo(
b.displayName.toLowerCase(),
);
}
if (sortMode == ContactSortMode.distance) { if (sortMode == ContactSortMode.distance) {
final distanceA = _distanceFromCurrentPosition(a); final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b); final distanceB = _distanceFromCurrentPosition(b);
@@ -321,7 +326,12 @@ class _ContactsTabState extends State<ContactsTab> {
} }
} }
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; return sorted;
@@ -480,8 +490,35 @@ class _ContactsTabState extends State<ContactsTab> {
widget.onNavigateToMap?.call(); widget.onNavigateToMap?.call();
} }
Future<void> _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) { void _showChannelActionSheet(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canExportHashChannelPsk = Channel.isHashChannelName(
channel.advName.trim(),
);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -509,6 +546,15 @@ class _ContactsTabState extends State<ContactsTab> {
_showChannelOnMap(context, channel); _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) if (!channel.isPublicChannel)
ListTile( ListTile(
leading: Icon(Icons.delete, color: Colors.red), leading: Icon(Icons.delete, color: Colors.red),
@@ -532,6 +578,60 @@ class _ContactsTabState extends State<ContactsTab> {
); );
} }
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<ContactSortMode> _availableSortModes(ContactSection section) {
switch (section) {
case ContactSection.channels:
return const [ContactSortMode.alphabetical, ContactSortMode.lastSeen];
default:
return const [
ContactSortMode.lastSeen,
ContactSortMode.distance,
ContactSortMode.alphabetical,
];
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -539,7 +639,10 @@ class _ContactsTabState extends State<ContactsTab> {
return Scaffold( return Scaffold(
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final colorScheme = Theme.of(context).colorScheme;
final appProvider = context.watch<AppProvider>();
final messagesProvider = context.watch<MessagesProvider>(); final messagesProvider = context.watch<MessagesProvider>();
final connectionProvider = context.watch<ConnectionProvider>();
final allChatContacts = _sortContacts( final allChatContacts = _sortContacts(
contactsProvider.chatContacts, contactsProvider.chatContacts,
ContactSection.teamMembers, ContactSection.teamMembers,
@@ -632,20 +735,49 @@ class _ContactsTabState extends State<ContactsTab> {
_showSavedGroupsForSection(ContactSection.channels) _showSavedGroupsForSection(ContactSection.channels)
? savedChannelGroups ? savedChannelGroups
: const <_RenderedSavedGroup>[]; : const <_RenderedSavedGroup>[];
final showTeamMembersSection = allChatContacts.isNotEmpty; final showFavouritesSection =
final showRepeatersSection = allRepeaters.isNotEmpty; appProvider.isContactsSectionEnabled(
final showSensorsSection = allSensors.isNotEmpty; ContactsTabSection.favourites,
final showRoomsSection = allRooms.isNotEmpty; ) &&
final showChannelsSection = allChannels.isNotEmpty; contactsProvider.favouriteContacts.isNotEmpty;
// Check if there are any displayable contacts final showTeamMembersSection =
final hasDisplayableContacts = 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 || allChatContacts.isNotEmpty ||
allRepeaters.isNotEmpty || allRepeaters.isNotEmpty ||
allSensors.isNotEmpty || allSensors.isNotEmpty ||
allRooms.isNotEmpty || allRooms.isNotEmpty ||
allChannels.isNotEmpty; allChannels.isNotEmpty;
final hasAnyVisibleSection =
showFavouritesSection ||
showTeamMembersSection ||
showRepeatersSection ||
showSensorsSection ||
showRoomsSection ||
showChannelsSection;
if (!hasDisplayableContacts) { if (!hasAnyContactData) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -666,10 +798,7 @@ class _ContactsTabState extends State<ContactsTab> {
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (context if (connectionProvider.deviceInfo.isConnected)
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
Padding( Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: OutlinedButton.icon( child: OutlinedButton.icon(
@@ -683,31 +812,74 @@ class _ContactsTabState extends State<ContactsTab> {
); );
} }
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( return RefreshIndicator(
onRefresh: _handleRefresh, onRefresh: _handleRefresh,
child: ListView( child: ListView(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
children: [
if (showFavouritesSection)
_SectionCard(
accentColor: Colors.amber,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Favourites (contacts with firmware favourite flag set)
if (contactsProvider.favouriteContacts.isNotEmpty) ...[
_SectionHeader( _SectionHeader(
title: l10n.favourites, title: l10n.favourites,
count: contactsProvider.favouriteContacts.length, count: contactsProvider.favouriteContacts.length,
icon: Icons.star, icon: Icons.star_rounded,
accentColor: Colors.amber,
), ),
..._buildContactSectionItems( ..._buildContactSectionItems(
contactsProvider.favouriteContacts, contactsProvider.favouriteContacts,
), ),
const Divider(height: 32),
], ],
),
),
// Team Members (Chat contacts) if (showTeamMembersSection)
if (showTeamMembersSection) ...[ _SectionCard(
accentColor: _sectionAccentColor(
context,
ContactSection.teamMembers,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader( _SectionHeader(
title: l10n.teamMembers, title: l10n.teamMembers,
count: chatContacts.length, count: chatContacts.length,
icon: Icons.people, icon: Icons.people_alt_rounded,
trailing: _buildSortMenu( accentColor: _sectionAccentColor(
context, context,
ContactSection.teamMembers, ContactSection.teamMembers,
), ),
@@ -741,38 +913,58 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedTeamGroups, visibleSavedTeamGroups,
), ),
), ),
const Divider(height: 32),
], ],
),
),
// Repeaters if (showRepeatersSection)
if (showRepeatersSection) ...[ _SectionCard(
accentColor: _sectionAccentColor(
context,
ContactSection.repeaters,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader( _SectionHeader(
title: l10n.repeaters, title: l10n.repeaters,
count: repeaters.length, count: repeaters.length,
icon: Icons.router, icon: Icons.router_rounded,
accentColor: _sectionAccentColor(
context,
ContactSection.repeaters,
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (context if (connectionProvider.deviceInfo.isConnected)
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
IconButton( IconButton(
icon: const Icon(Icons.radar, size: 20), icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover repeaters', tooltip: 'Discover repeaters',
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
foregroundColor: _sectionAccentColor(
context,
ContactSection.repeaters,
),
backgroundColor: _sectionAccentColor(
context,
ContactSection.repeaters,
).withValues(alpha: 0.10),
),
onPressed: () { onPressed: () {
context context
.read<ConnectionProvider>() .read<ConnectionProvider>()
.discoverNodeType(advertType: 2); .discoverNodeType(advertType: 2);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(l10n.repeaterDiscoverySent), content: Text(
l10n.repeaterDiscoverySent,
),
), ),
); );
}, },
), ),
_buildSortMenu(context, ContactSection.repeaters),
], ],
), ),
), ),
@@ -816,26 +1008,45 @@ class _ContactsTabState extends State<ContactsTab> {
ungroupedRepeaters, ungroupedRepeaters,
compact: true, compact: true,
), ),
const Divider(height: 32),
], ],
),
),
// Sensors if (showSensorsSection)
if (showSensorsSection) ...[ _SectionCard(
accentColor: _sectionAccentColor(
context,
ContactSection.sensors,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader( _SectionHeader(
title: l10n.sensors, title: l10n.sensors,
count: sensors.length, count: sensors.length,
icon: Icons.sensors, icon: Icons.sensors_rounded,
accentColor: _sectionAccentColor(
context,
ContactSection.sensors,
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (context if (connectionProvider.deviceInfo.isConnected)
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
IconButton( IconButton(
icon: const Icon(Icons.radar, size: 20), icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover sensors', tooltip: 'Discover sensors',
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
foregroundColor: _sectionAccentColor(
context,
ContactSection.sensors,
),
backgroundColor: _sectionAccentColor(
context,
ContactSection.sensors,
).withValues(alpha: 0.10),
),
onPressed: () { onPressed: () {
context context
.read<ConnectionProvider>() .read<ConnectionProvider>()
@@ -847,7 +1058,6 @@ class _ContactsTabState extends State<ContactsTab> {
); );
}, },
), ),
_buildSortMenu(context, ContactSection.sensors),
], ],
), ),
), ),
@@ -870,16 +1080,27 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedSensorGroups, visibleSavedSensorGroups,
), ),
), ),
const Divider(height: 32),
], ],
),
),
// Rooms if (showRoomsSection)
if (showRoomsSection) ...[ _SectionCard(
accentColor: _sectionAccentColor(
context,
ContactSection.rooms,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader( _SectionHeader(
title: l10n.rooms, title: l10n.rooms,
count: rooms.length, count: rooms.length,
icon: Icons.tag, icon: Icons.meeting_room_outlined,
trailing: _buildSortMenu(context, ContactSection.rooms), accentColor: _sectionAccentColor(
context,
ContactSection.rooms,
),
), ),
_buildSectionFilterField( _buildSectionFilterField(
context, context,
@@ -895,17 +1116,32 @@ class _ContactsTabState extends State<ContactsTab> {
_buildNoFilterResults(context) _buildNoFilterResults(context)
else else
..._buildContactSectionItems( ..._buildContactSectionItems(
_excludeGroupedContacts(rooms, visibleSavedRoomGroups), _excludeGroupedContacts(
rooms,
visibleSavedRoomGroups,
),
), ),
const Divider(height: 32),
], ],
),
),
// Channels (visible in both simple and advanced mode) if (showChannelsSection)
if (showChannelsSection) ...[ _SectionCard(
accentColor: _sectionAccentColor(
context,
ContactSection.channels,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader( _SectionHeader(
title: l10n.channels, title: l10n.channels,
count: filteredChannels.length, count: filteredChannels.length,
icon: Icons.broadcast_on_personal, icon: Icons.broadcast_on_personal_rounded,
accentColor: _sectionAccentColor(
context,
ContactSection.channels,
),
), ),
_buildSectionFilterField( _buildSectionFilterField(
context, context,
@@ -919,7 +1155,7 @@ class _ContactsTabState extends State<ContactsTab> {
if (filteredChannels.isEmpty && if (filteredChannels.isEmpty &&
_sectionHasActiveFilter(ContactSection.channels)) _sectionHasActiveFilter(ContactSection.channels))
_buildNoFilterResults(context) _buildNoFilterResults(context)
else ...[ else
..._excludeGroupedContacts( ..._excludeGroupedContacts(
filteredChannels, filteredChannels,
visibleSavedChannelGroups, visibleSavedChannelGroups,
@@ -928,44 +1164,53 @@ class _ContactsTabState extends State<ContactsTab> {
channel: channel, channel: channel,
messagesProvider: messagesProvider, messagesProvider: messagesProvider,
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
onTap: () => _showChannelActionSheet(context, channel), onTap: () =>
_showChannelActionSheet(context, channel),
), ),
), ),
], ],
], ),
),
// Add Channel Button (visible in both simple and advanced mode, only show when connected) if (connectionProvider.deviceInfo.isConnected)
if (context.watch<ConnectionProvider>().deviceInfo.isConnected) Container(
Padding( padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric( decoration: BoxDecoration(
horizontal: 16, color: colorScheme.surfaceContainerLow,
vertical: 8, borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: 0.35,
),
),
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton.icon( child: FilledButton.tonalIcon(
onPressed: () => _openAddContactScreen(context), onPressed: () => _openAddContactScreen(context),
icon: Icon(Icons.person_add_alt_1_outlined), icon: const Icon(Icons.person_add_alt_1_outlined),
label: Text(l10n.addContact), label: Text(l10n.addContact),
style: OutlinedButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(vertical: 14),
horizontal: 24,
vertical: 12,
),
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: OutlinedButton.icon( child: FilledButton.tonalIcon(
onPressed: () => _showAddChannelDialog(context), onPressed: () => _showAddChannelDialog(context),
icon: Icon(Icons.add_circle_outline), icon: const Icon(Icons.add_circle_outline),
label: Text(l10n.addChannel), label: Text(l10n.addChannel),
style: OutlinedButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(vertical: 14),
horizontal: 24, backgroundColor: _sectionAccentColor(
vertical: 12, context,
ContactSection.channels,
).withValues(alpha: 0.14),
foregroundColor: _sectionAccentColor(
context,
ContactSection.channels,
), ),
), ),
), ),
@@ -1139,6 +1384,10 @@ class _ContactsTabState extends State<ContactsTab> {
), ),
), ),
), ),
Padding(
padding: const EdgeInsets.only(right: 4),
child: _buildSortMenu(context, section, compact: true),
),
if (hasFilter) ...[ if (hasFilter) ...[
if (onSecondaryAction != null && if (onSecondaryAction != null &&
secondaryActionIcon != null) secondaryActionIcon != null)
@@ -1260,10 +1509,19 @@ class _ContactsTabState extends State<ContactsTab> {
); );
} }
Widget _buildSortMenu(BuildContext context, ContactSection section) { Widget _buildSortMenu(
BuildContext context,
ContactSection section, {
bool compact = false,
}) {
final l10n = AppLocalizations.of(context)!; 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 colorScheme = Theme.of(context).colorScheme;
final availableModes = _availableSortModes(section);
return PopupMenuButton<ContactSortMode>( return PopupMenuButton<ContactSortMode>(
tooltip: 'Sort', tooltip: 'Sort',
@@ -1273,53 +1531,75 @@ class _ContactsTabState extends State<ContactsTab> {
_sortModes[section] = sortMode; _sortModes[section] = sortMode;
}); });
}, },
itemBuilder: (context) => [ itemBuilder: (context) => availableModes
PopupMenuItem<ContactSortMode>( .map(
value: ContactSortMode.lastSeen, (mode) => PopupMenuItem<ContactSortMode>(
value: mode,
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.schedule, _sortModeIcon(mode),
size: 18, size: 18,
color: selectedMode == ContactSortMode.lastSeen color: selectedMode == mode ? colorScheme.primary : null,
? colorScheme.primary
: null,
), ),
SizedBox(width: 8), const SizedBox(width: 8),
Text(l10n.lastSeen), Text(_sortModeLabel(l10n, mode)),
], ],
), ),
), ),
PopupMenuItem<ContactSortMode>( )
value: ContactSortMode.distance, .toList(),
child: Row( child: compact
children: [ ? Container(
Icon( padding: const EdgeInsets.all(6),
Icons.near_me, decoration: BoxDecoration(
size: 18, color: colorScheme.primary.withValues(alpha: 0.10),
color: selectedMode == ContactSortMode.distance shape: BoxShape.circle,
? colorScheme.primary
: null,
), ),
SizedBox(width: 8),
Text(l10n.distance),
],
),
),
],
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon( child: Icon(
Icons.more_horiz, _sortModeIcon(selectedMode),
size: 18, 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, color: colorScheme.onSurfaceVariant,
), ),
],
),
), ),
); );
} }
} }
enum ContactSortMode { lastSeen, distance } enum ContactSortMode { lastSeen, distance, alphabetical }
enum ContactSection { teamMembers, repeaters, sensors, rooms, channels } enum ContactSection { teamMembers, repeaters, sensors, rooms, channels }
@@ -1334,44 +1614,130 @@ class _SectionHeader extends StatelessWidget {
final String title; final String title;
final int count; final int count;
final IconData icon; final IconData icon;
final Color accentColor;
final Widget? trailing; final Widget? trailing;
const _SectionHeader({ const _SectionHeader({
required this.title, required this.title,
required this.count, required this.count,
required this.icon, required this.icon,
required this.accentColor,
this.trailing, this.trailing,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final colorScheme = Theme.of(context).colorScheme;
padding: const EdgeInsets.symmetric(vertical: 8), final titleBlock = Row(
child: Row(
children: [ children: [
Icon(icon, size: 20), Expanded(
const SizedBox(width: 8), child: Text(
Text(
title, title,
style: Theme.of( maxLines: 1,
context, overflow: TextOverflow.ellipsis,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer, color: colorScheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(999),
border: Border.all(color: accentColor.withValues(alpha: 0.18)),
), ),
child: Text( child: Text(
count.toString(), count.toString(),
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: accentColor,
fontWeight: FontWeight.w800,
), ),
), ),
if (trailing != null) ...[const Spacer(), trailing!], ),
],
);
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,
); );
} }
} }

View File

@@ -28,6 +28,7 @@ import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import '../utils/contact_sorting.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/tictactoe_message_parser.dart'; import '../utils/tictactoe_message_parser.dart';
@@ -299,16 +300,15 @@ class _MessagesTabState extends State<MessagesTab> {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
final contacts = final contacts =
contactsProvider.contacts contactsProvider.chatContacts.where((contact) {
.where((contact) => contact.type == ContactType.chat)
.where((contact) {
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains( return contact.displayName.toLowerCase().contains(normalizedQuery);
normalizedQuery, }).toList()..sort((a, b) {
); final primary = compareContactsByFavouriteThenLastSeen(a, b);
}) if (primary != 0) {
.toList() return primary;
..sort((a, b) { }
final aName = a.displayName.toLowerCase(); final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase(); final bName = b.displayName.toLowerCase();
final aStarts = final aStarts =
@@ -318,7 +318,8 @@ class _MessagesTabState extends State<MessagesTab> {
if (aStarts != bStarts) { if (aStarts != bStarts) {
return aStarts ? -1 : 1; return aStarts ? -1 : 1;
} }
return aName.compareTo(bName);
return compareContactsByDisplayName(a, b);
}); });
return contacts.take(8).toList(growable: false); return contacts.take(8).toList(growable: false);
@@ -408,16 +409,33 @@ class _MessagesTabState extends State<MessagesTab> {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
// Filter contacts by type final contacts = List<Contact>.from(contactsProvider.chatContacts)
final contacts = contactsProvider.contacts ..sort((a, b) {
.where((c) => c.type == ContactType.chat) final primary = compareContactsByFavouriteThenLastSeen(a, b);
.toList(); if (primary != 0) {
final rooms = contactsProvider.contacts return primary;
.where((c) => c.type == ContactType.room) }
.toList();
final channels = contactsProvider.contacts return compareContactsByDisplayName(a, b);
.where((c) => c.type == ContactType.channel) });
.toList(); final rooms = List<Contact>.from(contactsProvider.rooms)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
final channels = List<Contact>.from(contactsProvider.channels)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -534,7 +552,10 @@ class _MessagesTabState extends State<MessagesTab> {
}).firstOrNull; }).firstOrNull;
if (recipient == null) { if (recipient == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound); ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplyContactNotFound,
);
return; return;
} }
} }
@@ -563,13 +584,19 @@ class _MessagesTabState extends State<MessagesTab> {
} else { } else {
final senderPrefix = message.senderPublicKeyPrefix; final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) { if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplySenderMissing); ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplySenderMissing,
);
return; return;
} }
recipient = contactsProvider.findContactByPrefix(senderPrefix); recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) { if (recipient == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound); ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplyContactNotFound,
);
return; return;
} }

View File

@@ -190,9 +190,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_isDeveloperModeEnabled = false; _isDeveloperModeEnabled = false;
_versionTapCount = 0; _versionTapCount = 0;
}); });
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeDisabled))); content: Text(AppLocalizations.of(context)!.developerModeDisabled),
),
);
return; return;
} }
@@ -204,9 +206,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_isDeveloperModeEnabled = true; _isDeveloperModeEnabled = true;
_versionTapCount = 0; _versionTapCount = 0;
}); });
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeEnabled))); content: Text(AppLocalizations.of(context)!.developerModeEnabled),
),
);
return; return;
} }
@@ -559,7 +563,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid), content: Text(
AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid,
),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
@@ -584,7 +590,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!updateInfo.isAvailable) { if (!updateInfo.isAvailable) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.youAreRunningTheLatestVersion), content: Text(
AppLocalizations.of(context)!.youAreRunningTheLatestVersion,
),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -594,7 +602,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (updateInfo.downloadUrl == null) { if (updateInfo.downloadUrl == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.updateAvailableButDownloadUrlNotFound), content: Text(
AppLocalizations.of(
context,
)!.updateAvailableButDownloadUrlNotFound,
),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
@@ -1178,6 +1190,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader(AppLocalizations.of(context)!.contacts),
Consumer<AppProvider>(
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'), _buildSectionHeader('Messaging'),
_buildSettingsCard([ _buildSettingsCard([
ListTile( ListTile(
@@ -1205,7 +1311,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.route), secondary: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.nearestRepeaterFallback), title: Text(
AppLocalizations.of(context)!.nearestRepeaterFallback,
),
subtitle: const Text( subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater', 'After normal retries fail, try one final resend through the nearest repeater',
), ),
@@ -1234,7 +1342,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Clear Messages', 'Clear Messages',
style: TextStyle(color: Colors.red), style: TextStyle(color: Colors.red),
), ),
subtitle: Text(AppLocalizations.of(context)!.deleteAllStoredMessageHistory), subtitle: Text(
AppLocalizations.of(context)!.deleteAllStoredMessageHistory,
),
onTap: _clearMessages, onTap: _clearMessages,
), ),
Consumer<AppProvider>( Consumer<AppProvider>(
@@ -1344,7 +1454,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, drawingProvider, child) => SwitchListTile( builder: (context, drawingProvider, child) => SwitchListTile(
secondary: Icon(Icons.fmd_good_outlined), secondary: Icon(Icons.fmd_good_outlined),
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel), title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
subtitle: Text(AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap), subtitle: Text(
AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap,
),
value: drawingProvider.showSarMarkers, value: drawingProvider.showSarMarkers,
onChanged: (value) { onChanged: (value) {
drawingProvider.toggleSarMarkers(); drawingProvider.toggleSarMarkers();
@@ -1354,7 +1466,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<MapProvider>( Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile( builder: (context, mapProvider, child) => SwitchListTile(
secondary: Icon(Icons.timeline), secondary: Icon(Icons.timeline),
title: Text(AppLocalizations.of(context)!.showAllContactTrailsLabel), title: Text(
AppLocalizations.of(context)!.showAllContactTrailsLabel,
),
subtitle: const Text( subtitle: const Text(
'Display location trails for all contacts that have history', 'Display location trails for all contacts that have history',
), ),
@@ -1420,7 +1534,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.compress), secondary: Icon(Icons.compress),
title: Text(AppLocalizations.of(context)!.voiceCompressor), title: Text(AppLocalizations.of(context)!.voiceCompressor),
subtitle: Text(AppLocalizations.of(context)!.balancesQuietAndLoudSpeechLevels), subtitle: Text(
AppLocalizations.of(
context,
)!.balancesQuietAndLoudSpeechLevels,
),
value: appProvider.isVoiceCompressorEnabled, value: appProvider.isVoiceCompressorEnabled,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleVoiceCompressorEnabled(value); await appProvider.toggleVoiceCompressorEnabled(value);
@@ -1431,7 +1549,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.speed), secondary: Icon(Icons.speed),
title: Text(AppLocalizations.of(context)!.voiceLimiter), title: Text(AppLocalizations.of(context)!.voiceLimiter),
subtitle: Text(AppLocalizations.of(context)!.preventsClippingPeaksBeforeEncoding), subtitle: Text(
AppLocalizations.of(
context,
)!.preventsClippingPeaksBeforeEncoding,
),
value: appProvider.isVoiceLimiterEnabled, value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value); await appProvider.toggleVoiceLimiterEnabled(value);
@@ -1442,7 +1564,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.auto_fix_high), secondary: Icon(Icons.auto_fix_high),
title: Text(AppLocalizations.of(context)!.micAutoGain), title: Text(AppLocalizations.of(context)!.micAutoGain),
subtitle: Text(AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel), subtitle: Text(
AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel,
),
value: appProvider.isVoiceAutoGainEnabled, value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value); await appProvider.toggleVoiceAutoGainEnabled(value);
@@ -1478,7 +1602,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.content_cut), secondary: Icon(Icons.content_cut),
title: Text(AppLocalizations.of(context)!.trimSilenceInVoiceMessages), title: Text(
AppLocalizations.of(context)!.trimSilenceInVoiceMessages,
),
subtitle: const Text( subtitle: const Text(
'Removes long silent parts before sending voice', 'Removes long silent parts before sending voice',
), ),
@@ -1648,7 +1774,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
ListTile( ListTile(
leading: Icon(Icons.timer), leading: Icon(Icons.timer),
title: Text(AppLocalizations.of(context)!.activeuseUpdateInterval), title: Text(
AppLocalizations.of(context)!.activeuseUpdateInterval,
),
subtitle: Text('$_fastLocationActiveCadenceSeconds s'), subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence, onTap: _editFastLocationActiveCadence,

View File

@@ -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);
}

View File

@@ -54,6 +54,14 @@ class ContactTile extends StatelessWidget {
return l10n.daysAgo(diff.inDays); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel; final isChannel = contact.type == ContactType.channel;
@@ -205,7 +213,12 @@ class ContactTile extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 58,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Stack( Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -221,7 +234,10 @@ class ContactTile extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue, color: Colors.blue,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2), border: Border.all(
color: Colors.white,
width: 2,
),
), ),
), ),
), ),
@@ -235,7 +251,10 @@ class ContactTile extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getRoomStatusColor(roomLoginState), color: _getRoomStatusColor(roomLoginState),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2), border: Border.all(
color: Colors.white,
width: 2,
),
), ),
child: Icon( child: Icon(
_getRoomStatusIcon(roomLoginState), _getRoomStatusIcon(roomLoginState),
@@ -246,6 +265,22 @@ class ContactTile extends StatelessWidget {
), ),
], ],
), ),
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,
),
),
],
),
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(

File diff suppressed because it is too large Load Diff

View File

@@ -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<int>.generate(16, (i) => i)),
);
expect(channel.isHashChannel, isFalse);
expect(channel.pskBase64, 'AAECAwQFBgcICQoLDA0ODw==');
});
}

View File

@@ -1,8 +1,10 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.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.dart';
import 'package:meshcore_sar_app/models/contact_group.dart'; import 'package:meshcore_sar_app/models/contact_group.dart';
import 'package:meshcore_sar_app/providers/connection_provider.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'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
String? clipboardText;
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
clipboardText = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
switch (call.method) {
case 'Clipboard.setData':
clipboardText =
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
return null;
case 'Clipboard.getData':
return <String, dynamic>{'text': clipboardText};
}
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
}); });
Contact buildChannel({required String name, required int channelIndex}) { Contact buildChannel({required String name, required int channelIndex}) {
@@ -123,6 +145,7 @@ void main() {
await tester.tap(find.text('Ops')); await tester.tap(find.text('Ops'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Export psk_base64'), findsNothing);
expect(find.text('Delete Channel'), findsOneWidget); expect(find.text('Delete Channel'), findsOneWidget);
await tester.tap(find.text('Delete Channel')); 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', ( testWidgets('repeaters show Others group when multiple groups exist', (
tester, tester,
) async { ) async {
@@ -218,7 +261,7 @@ void main() {
contacts: [buildSensor(seed: 60, name: 'WX Station')], contacts: [buildSensor(seed: 60, name: 'WX Station')],
); );
expect(find.text('Sensors'), findsOneWidget); expect(find.text('Sensors'), findsWidgets);
expect(find.text('WX Station'), findsOneWidget); expect(find.text('WX Station'), findsOneWidget);
}); });
} }

View File

@@ -11,6 +11,8 @@ void main() {
required String name, required String name,
required ContactType type, required ContactType type,
int secondByte = 0, int secondByte = 0,
int flags = 0,
int lastAdvert = 0,
}) { }) {
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[1] = secondByte; publicKey[1] = secondByte;
@@ -18,11 +20,11 @@ void main() {
return Contact( return Contact(
publicKey: publicKey, publicKey: publicKey,
type: type, type: type,
flags: 0, flags: flags,
outPathLen: 0, outPathLen: 0,
outPath: Uint8List(0), outPath: Uint8List(0),
advName: name, advName: name,
lastAdvert: 0, lastAdvert: lastAdvert,
advLat: 0, advLat: 0,
advLon: 0, advLon: 0,
lastMod: 0, lastMod: 0,
@@ -98,4 +100,54 @@ void main() {
expect(find.text('Show all'), findsNothing); expect(find.text('Show all'), findsNothing);
expect(find.text('John Smith'), findsOneWidget); 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));
});
} }