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(),

File diff suppressed because it is too large Load Diff

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,27 +300,27 @@ 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) if (normalizedQuery.isEmpty) return true;
.where((contact) { return contact.displayName.toLowerCase().contains(normalizedQuery);
if (normalizedQuery.isEmpty) return true; }).toList()..sort((a, b) {
return contact.displayName.toLowerCase().contains( final primary = compareContactsByFavouriteThenLastSeen(a, b);
normalizedQuery, if (primary != 0) {
); return primary;
}) }
.toList()
..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 = normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery); final bStarts =
final bStarts = normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery); 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,46 +213,73 @@ 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: [ children: [
Stack( SizedBox(
clipBehavior: Clip.none, width: 58,
children: [ child: Column(
ContactAvatar(contact: contact, radius: 24), mainAxisSize: MainAxisSize.min,
if (contact.isNew) children: [
Positioned( Stack(
top: -2, clipBehavior: Clip.none,
right: -2, children: [
child: Container( ContactAvatar(contact: contact, radius: 24),
width: 14, if (contact.isNew)
height: 14, Positioned(
decoration: BoxDecoration( top: -2,
color: Colors.blue, right: -2,
shape: BoxShape.circle, child: Container(
border: Border.all(color: Colors.white, width: 2), 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), const SizedBox(width: 10),
Expanded( Expanded(

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