Compare commits

...

16 Commits

Author SHA1 Message Date
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
38 changed files with 1708 additions and 895 deletions

View File

@@ -280,7 +280,7 @@ jobs:
run: flutter pub get run: flutter pub get
- name: Build web release - name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/ run: flutter build web --release --base-href /meshcore-sar/
- name: Upload pages artifact - name: Upload pages artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3

View File

@@ -16,7 +16,7 @@
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app. MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication. It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp `iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
## Highlights ## Highlights

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 106; CURRENT_PROJECT_VERSION = 107;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 106; CURRENT_PROJECT_VERSION = 107;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 106; CURRENT_PROJECT_VERSION = 107;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 106; CURRENT_PROJECT_VERSION = 107;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 106; CURRENT_PROJECT_VERSION = 107;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 106; CURRENT_PROJECT_VERSION = 107;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -1,5 +1,6 @@
import UIKit import UIKit
import Flutter import Flutter
import UserNotifications
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,7 @@ import Flutter
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
} }

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>106</string> <string>107</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -1,4 +1,7 @@
import 'dart:io' show Platform; import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -19,6 +22,7 @@ import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart'; import 'services/voice_player_service.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/locale_preferences.dart'; import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart'; import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart'; import 'services/wizard_preferences.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available // Shows notification if update is available
_checkForUpdates(); _checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() { setState(() {
_wizardCompleted = wizardCompleted; _wizardCompleted = wizardCompleted;
_isInitialized = true; _isInitialized = true;
@@ -158,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available /// Shows notification if update is available
Future<void> _checkForUpdates() async { Future<void> _checkForUpdates() async {
// Only check for updates on Android // Only check for updates on Android
if (!Platform.isAndroid) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)'); debugPrint('[UpdateChecker] Skipping update check (not Android)');
return; return;
} }
@@ -324,7 +331,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36) // Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom) // iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea( return SafeArea(
left: false, left: false,
right: false, right: false,

View File

@@ -240,6 +240,5 @@ extension ContactLocalization on Contact {
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes'; return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
} }
bool get routeSupportsLegacyRawTransport => bool get routeSupportsLegacyRawTransport => routeHasPath;
routeHasPath && routeSignedPathLen >= 0;
} }

View File

@@ -29,6 +29,7 @@ import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart'; import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart'; import '../utils/message_airtime_estimator.dart';
import '../utils/fast_gps_packet.dart'; import '../utils/fast_gps_packet.dart';
import '../utils/log_rx_route_decoder.dart';
class _DirectMessageRouteSession { class _DirectMessageRouteSession {
final PathSelection currentSelection; final PathSelection currentSelection;
@@ -1973,14 +1974,13 @@ class AppProvider with ChangeNotifier {
if (requester == null || if (requester == null ||
!requester.routeHasPath || !requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops || requester.routeHopCount > _maxDirectPayloadHops ||
!requester.routeSupportsLegacyRawTransport ||
requester.outPath.isEmpty) { requester.outPath.isEmpty) {
return; return;
} }
unawaited( unawaited(
connectionProvider.sendRawVoicePacket( connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen, contactPathLen: requester.routeEncodedPathLen,
payload: availability.encodeBinary(), payload: availability.encodeBinary(),
), ),
); );
@@ -2050,7 +2050,7 @@ class AppProvider with ChangeNotifier {
for (final peer in peers) { for (final peer in peers) {
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: peer.outPath, contactPath: peer.outPath,
contactPathLen: peer.routeSignedPathLen, contactPathLen: peer.routeEncodedPathLen,
payload: request.encodeBinary(), payload: request.encodeBinary(),
); );
} }
@@ -2073,7 +2073,6 @@ class AppProvider with ChangeNotifier {
if (responder == null || if (responder == null ||
!responder.routeHasPath || !responder.routeHasPath ||
responder.routeHopCount > _maxDirectPayloadHops || responder.routeHopCount > _maxDirectPayloadHops ||
!responder.routeSupportsLegacyRawTransport ||
responder.outPath.isEmpty) { responder.outPath.isEmpty) {
continue; continue;
} }
@@ -2165,7 +2164,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath, contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen, contactPathLen: target.routeEncodedPathLen,
payload: payload, payload: payload,
); );
return true; return true;
@@ -2406,9 +2405,6 @@ class AppProvider with ChangeNotifier {
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) { if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
return false; return false;
} }
if (!target.routeSupportsLegacyRawTransport) {
return false;
}
if (target.outPath.isEmpty) { if (target.outPath.isEmpty) {
return false; return false;
} }
@@ -2441,7 +2437,7 @@ class AppProvider with ChangeNotifier {
); );
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath, contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen, contactPathLen: target.routeEncodedPathLen,
payload: RawRouteProbeRequest( payload: RawRouteProbeRequest(
nonce: nonce, nonce: nonce,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
@@ -2469,7 +2465,7 @@ class AppProvider with ChangeNotifier {
if (target.publicKeyHex.isNotEmpty) { if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}'; return 'pk:${target.publicKeyHex}';
} }
return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
} }
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
@@ -2487,9 +2483,6 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (!requester.routeSupportsLegacyRawTransport) {
return;
}
if (requester.outPath.isEmpty) { if (requester.outPath.isEmpty) {
return; return;
} }
@@ -2499,7 +2492,7 @@ class AppProvider with ChangeNotifier {
unawaited( unawaited(
connectionProvider.sendRawVoicePacket( connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen, contactPathLen: requester.routeEncodedPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
), ),
); );
@@ -2573,12 +2566,10 @@ class AppProvider with ChangeNotifier {
if (log.responseCode != 0x88) continue; if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue; if (log.rawData.length < 6) continue;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final payloadType = (raw[3] >> 2) & 0x0F; if (decoded == null) continue;
final pathLen = raw[4]; if (decoded.payloadType != expectedPayloadType) continue;
if (payloadType != expectedPayloadType) continue; if (decoded.hopCount != message.pathLen) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -2594,11 +2585,9 @@ class AppProvider with ChangeNotifier {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) { List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null; if (log == null) return null;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
if (raw.length < 6) return null; if (decoded == null || decoded.pathBytes.isEmpty) return null;
final pathLen = raw[4]; return decoded.pathBytes;
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
} }
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events

View File

@@ -486,6 +486,14 @@ class ConnectionProvider with ChangeNotifier {
/// Start scanning for MeshCore devices /// Start scanning for MeshCore devices
Future<void> startScan() async { Future<void> startScan() async {
debugPrint('🔍 [Provider] startScan() called'); debugPrint('🔍 [Provider] startScan() called');
if (_deviceInfo.connectionState == ConnectionState.connecting ||
_deviceInfo.connectionState == ConnectionState.connected) {
debugPrint(
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
);
return;
}
_isScanning = true; _isScanning = true;
_scannedDevices.clear(); _scannedDevices.clear();
_error = null; _error = null;
@@ -559,6 +567,11 @@ class ConnectionProvider with ChangeNotifier {
'🔵 [Provider] connect() called for device: ${device.platformName}', '🔵 [Provider] connect() called for device: ${device.platformName}',
); );
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(), deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty deviceName: device.platformName.isNotEmpty

View File

@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
); );
return false; return false;
} }
if (!requester.routeSupportsLegacyRawTransport) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
);
return false;
}
if (requester.outPath.isEmpty) { if (requester.outPath.isEmpty) {
debugPrint( debugPrint(
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload', '⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
try { try {
await sendRawPacket( await sendRawPacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen, contactPathLen: requester.routeEncodedPathLen,
payload: encodeBinary(fragment), payload: encodeBinary(fragment),
); );
servedCount++; servedCount++;

View File

@@ -765,14 +765,14 @@ class MessagesProvider with ChangeNotifier {
final seconds = (voiceEnvelope.durationMs / 1000).ceil(); final seconds = (voiceEnvelope.durationMs / 1000).ceil();
final summary = final summary =
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets'; 'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
return isChannelMessage ? '$senderName\n$summary' : summary; return summary;
} }
final imageEnvelope = ImageEnvelope.tryParse(message.text); final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) { if (imageEnvelope != null) {
final summary = final summary =
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}'; 'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
return isChannelMessage ? '$senderName\n$summary' : summary; return summary;
} }
if (!isChannelMessage && message.recipientPublicKey != null) { if (!isChannelMessage && message.recipientPublicKey != null) {
@@ -785,10 +785,6 @@ class MessagesProvider with ChangeNotifier {
} }
} }
if (isChannelMessage) {
return '$senderName\n${message.text}';
}
return message.text; return message.text;
} }
@@ -889,6 +885,54 @@ class MessagesProvider with ChangeNotifier {
} }
} }
/// Mark unread messages for a specific destination as read.
void markDestinationAsRead({
required String destinationType,
Contact? contact,
}) {
if (destinationType == 'all') {
markAllAsRead();
return;
}
bool hasChanges = false;
for (int i = 0; i < _messages.length; i++) {
final message = _messages[i];
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
continue;
}
final matchesDestination = switch (destinationType) {
'channel' => _isChannelMessageForContact(message, contact),
'contact' || 'room' => contact != null && _isMessageForDestination(message, contact),
_ => false,
};
if (!matchesDestination) {
continue;
}
_messages[i] = message.copyWith(isRead: true);
hasChanges = true;
}
if (hasChanges) {
_persistMessages();
notifyListeners();
}
}
bool _isChannelMessageForContact(Message message, Contact? contact) {
if (!message.isChannelMessage) {
return false;
}
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
? contact.publicKey[1]
: 0;
return (message.channelIdx ?? 0) == selectedChannelIdx;
}
/// Mark a specific message as read /// Mark a specific message as read
void markAsRead(String messageId) { void markAsRead(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId); final index = _messages.indexWhere((m) => m.id == messageId);

View File

@@ -7,6 +7,11 @@ import '../models/contact.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/messages_provider.dart';
import '../services/message_destination_preferences.dart';
import '../utils/contact_grouping.dart';
import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart'; import '../widgets/contacts/add_channel_dialog.dart';
@@ -27,6 +32,11 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> { class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition; Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{}; final Set<String> _resolvingAdvertKeys = <String>{};
final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen,
ContactSection.rooms: ContactSortMode.lastSeen,
};
@override @override
void initState() { void initState() {
@@ -125,20 +135,31 @@ class _ContactsTabState extends State<ContactsTab> {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
List<Contact> _sortContactsByDistance(List<Contact> contacts) { List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts); final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) {
sorted.sort(
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
return sorted;
}
final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen;
sorted.sort((a, b) { sorted.sort((a, b) {
final distanceA = _distanceFromCurrentPosition(a); if (sortMode == ContactSortMode.distance) {
final distanceB = _distanceFromCurrentPosition(b); final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (distanceA != null && distanceB != null) { if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB); final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare; if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) { } else if (distanceA != null) {
return -1; return -1;
} else if (distanceB != null) { } else if (distanceB != null) {
return 1; return 1;
}
} }
return b.lastSeenTime.compareTo(a.lastSeenTime); return b.lastSeenTime.compareTo(a.lastSeenTime);
@@ -208,12 +229,23 @@ class _ContactsTabState extends State<ContactsTab> {
return Scaffold( return Scaffold(
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final chatContacts = _sortContactsByDistance( final messagesProvider = context.watch<MessagesProvider>();
final chatContacts = _sortContacts(
contactsProvider.chatContacts, contactsProvider.chatContacts,
ContactSection.teamMembers,
);
final repeaters = _sortContacts(
contactsProvider.repeaters,
ContactSection.repeaters,
);
final rooms = _sortContacts(
contactsProvider.rooms,
ContactSection.rooms,
);
final channels = _sortContacts(
contactsProvider.channels,
ContactSection.channels,
); );
final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
final rooms = _sortContactsByDistance(contactsProvider.rooms);
final channels = _sortContactsByDistance(contactsProvider.channels);
final pendingAdverts = contactsProvider.pendingAdverts; final pendingAdverts = contactsProvider.pendingAdverts;
// Check if there are any displayable contacts // Check if there are any displayable contacts
@@ -282,17 +314,12 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.teamMembers, title: l10n.teamMembers,
count: chatContacts.length, count: chatContacts.length,
icon: Icons.people, icon: Icons.people,
), trailing: _buildSortMenu(
...chatContacts.map( context,
(contact) => ContactTile( ContactSection.teamMembers,
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
), ),
), ),
..._buildContactSectionItems(chatContacts),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -302,17 +329,9 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.repeaters, title: l10n.repeaters,
count: repeaters.length, count: repeaters.length,
icon: Icons.router, icon: Icons.router,
trailing: _buildSortMenu(context, ContactSection.repeaters),
), ),
...repeaters.map( ..._buildContactSectionItems(repeaters),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -322,17 +341,9 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.rooms, title: l10n.rooms,
count: rooms.length, count: rooms.length,
icon: Icons.tag, icon: Icons.tag,
trailing: _buildSortMenu(context, ContactSection.rooms),
), ),
...rooms.map( ..._buildContactSectionItems(rooms),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -344,12 +355,10 @@ class _ContactsTabState extends State<ContactsTab> {
), ),
if (channels.isNotEmpty) ...[ if (channels.isNotEmpty) ...[
...channels.map( ...channels.map(
(contact) => ContactTile( (channel) => _ChannelActivityCard(
contact: contact, channel: channel,
currentPosition: _currentPosition, messagesProvider: messagesProvider,
calculateDistance: _calculateDistanceInMeters, contactsProvider: contactsProvider,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages, onNavigateToMessages: widget.onNavigateToMessages,
), ),
), ),
@@ -381,8 +390,97 @@ class _ContactsTabState extends State<ContactsTab> {
), ),
); );
} }
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
final items = ContactGrouping.buildItemsFromSorted(contacts);
return items.map((item) {
if (item.isGroup) {
return _InferredContactGroupCard(
label: item.group!.label,
contacts: item.group!.contacts,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}
return ContactTile(
contact: item.contact!,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}).toList();
}
Widget _buildSortMenu(BuildContext context, ContactSection section) {
final l10n = AppLocalizations.of(context)!;
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<ContactSortMode>(
tooltip: 'Sort',
initialValue: selectedMode,
onSelected: (sortMode) {
setState(() {
_sortModes[section] = sortMode;
});
},
itemBuilder: (context) => [
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.lastSeen,
child: Row(
children: [
Icon(
Icons.schedule,
size: 18,
color: selectedMode == ContactSortMode.lastSeen
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text(l10n.lastSeen),
],
),
),
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.distance,
child: Row(
children: [
Icon(
Icons.near_me,
size: 18,
color: selectedMode == ContactSortMode.distance
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text(l10n.distance),
],
),
),
],
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.more_horiz,
size: 18,
color: colorScheme.onSurfaceVariant,
),
),
);
}
} }
enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, rooms, channels }
class _PendingAdvertTile extends StatelessWidget { class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert; final PendingAdvert advert;
final String subtitle; final String subtitle;
@@ -427,11 +525,13 @@ class _SectionHeader extends StatelessWidget {
final String title; final String title;
final int count; final int count;
final IconData icon; final IconData icon;
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,
this.trailing,
}); });
@override @override
@@ -460,6 +560,415 @@ class _SectionHeader extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall,
), ),
), ),
if (trailing != null) ...[const Spacer(), trailing!],
],
),
);
}
}
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance;
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
const _InferredContactGroupCard({
required this.label,
required this.contacts,
required this.currentPosition,
required this.calculateDistance,
required this.formatDistance,
required this.onNavigateToMap,
required this.onNavigateToMessages,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
initiallyExpanded: false,
leading: Icon(
Icons.folder_copy_outlined,
size: 18,
color: colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
contacts.length.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
children: [
...contacts.map(
(contact) => ContactTile(
contact: contact,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
onNavigateToMap: onNavigateToMap,
onNavigateToMessages: onNavigateToMessages,
),
),
],
),
),
);
}
}
class _ChannelActivityCard extends StatelessWidget {
final Contact channel;
final MessagesProvider messagesProvider;
final ContactsProvider contactsProvider;
final VoidCallback? onNavigateToMessages;
const _ChannelActivityCard({
required this.channel,
required this.messagesProvider,
required this.contactsProvider,
required this.onNavigateToMessages,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final participantNames = <String>[];
for (final message in channelMessages) {
final senderName = message.senderName?.trim();
if (senderName == null || senderName.isEmpty) continue;
if (!participantNames.contains(senderName)) {
participantNames.add(senderName);
}
}
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.tertiaryContainer.withValues(alpha: 0.45),
],
),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () async {
await MessageDestinationPreferences.setDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKey: channel.publicKeyHex,
);
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channel.publicKeyHex,
);
onNavigateToMessages?.call();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ContactAvatar(contact: channel, radius: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
channel.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 15,
height: 1.05,
),
),
const SizedBox(height: 8),
if (participantNames.isNotEmpty)
_ExpandableParticipantStack(
names: participantNames,
contactForName: _findParticipantContact,
)
else
Text(
'No recent chatters',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_MetricChip(
icon: Icons.forum_outlined,
label: '${channelMessages.length}',
helper: 'messages',
),
_MetricChip(
icon: Icons.group_outlined,
label: '${participantNames.length}',
helper: 'active',
),
],
),
],
),
),
],
),
),
),
),
);
}
Contact? _findParticipantContact(String name) {
for (final contact in contactsProvider.contacts) {
if (!contact.isChannel && contact.advName == name) {
return contact;
}
}
return null;
}
}
class _ExpandableParticipantStack extends StatefulWidget {
final List<String> names;
final Contact? Function(String name) contactForName;
const _ExpandableParticipantStack({
required this.names,
required this.contactForName,
});
@override
State<_ExpandableParticipantStack> createState() =>
_ExpandableParticipantStackState();
}
class _ExpandableParticipantStackState
extends State<_ExpandableParticipantStack> {
bool _expanded = false;
static const int _collapsedVisibleCount = 4;
@override
Widget build(BuildContext context) {
final hasOverflow = widget.names.length > _collapsedVisibleCount;
final visibleNames = _expanded
? widget.names
: widget.names.take(_collapsedVisibleCount).toList();
final overflowCount = _expanded
? 0
: widget.names.length - visibleNames.length;
final spacing = _expanded ? 20.0 : 16.0;
const avatarSize = 24.0;
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
return GestureDetector(
onTap: hasOverflow
? () {
setState(() {
_expanded = !_expanded;
});
}
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
width: width,
height: avatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < visibleNames.length; i++)
Positioned(
left: i * spacing,
top: 0,
child: _ParticipantAvatar(
name: visibleNames[i],
contact: widget.contactForName(visibleNames[i]),
),
),
if (overflowCount > 0)
Positioned(
left: visibleNames.length * spacing,
top: 0,
child: _OverflowAvatar(count: overflowCount),
),
],
),
),
);
}
}
class _OverflowAvatar extends StatelessWidget {
final int count;
const _OverflowAvatar({required this.count});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
'+$count',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
fontSize: 9,
),
),
);
}
}
class _ParticipantAvatar extends StatelessWidget {
final String name;
final Contact? contact;
static const double _size = 24;
const _ParticipantAvatar({required this.name, required this.contact});
@override
Widget build(BuildContext context) {
if (contact != null) {
final surfaceColor = Theme.of(context).colorScheme.surface;
return SizedBox(
width: _size,
height: _size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: surfaceColor, width: 2),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ClipOval(child: ContactAvatar(contact: contact!, radius: 8)),
),
),
);
}
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: _size,
height: _size,
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
AvatarLabelHelper.buildLabel(name),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onTertiaryContainer,
fontSize: 9,
),
),
);
}
}
class _MetricChip extends StatelessWidget {
final IconData icon;
final String label;
final String helper;
const _MetricChip({
required this.icon,
required this.label,
required this.helper,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 5),
Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(width: 4),
Text(
helper,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
], ],
), ),
); );

View File

@@ -24,6 +24,7 @@ import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart'; import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
enum _HomeTab { messages, contacts, sensors, map } enum _HomeTab { messages, contacts, sensors, map }
@@ -93,6 +94,7 @@ class _HomeScreenState extends State<HomeScreen>
_initTabController(); _initTabController();
_loadRxTxPreference(); _loadRxTxPreference();
_loadDeveloperModePreference(); _loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) { if (widget.shouldShowPermissionDialog) {
@@ -195,7 +197,6 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) { switch (tab) {
case _HomeTab.messages: case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break; break;
case _HomeTab.contacts: case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed(); context.read<ContactsProvider>().markAllAsViewed();
@@ -220,6 +221,9 @@ class _HomeScreenState extends State<HomeScreen>
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state; _lifecycleState = state;
_syncFastLocationUiState(); _syncFastLocationUiState();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
} }
Future<void> _loadRxTxPreference() async { Future<void> _loadRxTxPreference() async {
@@ -389,10 +393,14 @@ class _HomeScreenState extends State<HomeScreen>
required int count, required int count,
required bool isActive, required bool isActive,
required Color activeColor, required Color activeColor,
bool compact = false,
}) { }) {
final color = isActive ? activeColor : Colors.grey; final color = isActive ? activeColor : Colors.grey;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 4 : 5,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.12), color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -401,15 +409,15 @@ class _HomeScreenState extends State<HomeScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 7, width: compact ? 6 : 7,
height: 7, height: compact ? 6 : 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color), decoration: BoxDecoration(shape: BoxShape.circle, color: color),
), ),
const SizedBox(width: 6), SizedBox(width: compact ? 5 : 6),
Text( Text(
'$label:$count', '$label:$count',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: color, color: color,
), ),
@@ -1037,17 +1045,19 @@ class _HomeScreenState extends State<HomeScreen>
txActive: provider.txActivity, txActive: provider.txActivity,
) )
: Container( : Container(
constraints: const BoxConstraints(minHeight: 48),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 8,
vertical: 8, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85), .withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(22),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
_buildActivityBadge( _buildActivityBadge(
@@ -1055,13 +1065,15 @@ class _HomeScreenState extends State<HomeScreen>
count: provider.rxPacketCount, count: provider.rxPacketCount,
isActive: provider.rxActivity, isActive: provider.rxActivity,
activeColor: Colors.green, activeColor: Colors.green,
compact: true,
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
_buildActivityBadge( _buildActivityBadge(
label: 'TX', label: 'TX',
count: provider.txPacketCount, count: provider.txPacketCount,
isActive: provider.txActivity, isActive: provider.txActivity,
activeColor: Colors.blue, activeColor: Colors.blue,
compact: true,
), ),
], ],
), ),

View File

@@ -89,9 +89,7 @@ class _MessagesTabState extends State<MessagesTab> {
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceBitrate();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
@@ -309,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient; _selectedRecipient = recipient;
}); });
_markCurrentDestinationAsRead();
_enforceMessageByteLimit(); _enforceMessageByteLimit();
// Save to preferences // Save to preferences
@@ -465,6 +465,7 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.clear(); _textController.clear();
_focusNode.unfocus(); _focusNode.unfocus();
_markCurrentDestinationAsRead();
if (!mounted) return; if (!mounted) return;
} catch (e) { } catch (e) {
@@ -1641,6 +1642,13 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _markCurrentDestinationAsRead() {
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) { List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);

View File

@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
@@ -746,22 +747,31 @@ class _DecodedRouteSection extends StatelessWidget {
connectionProvider.deviceInfo.selfName ?? connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName; connectionProvider.deviceInfo.displayName;
return FutureBuilder<int>( return FutureBuilder<List<dynamic>>(
future: RouteHashPreferences.getHashSize(), future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
]),
builder: (context, snapshot) { builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode( final decodedRoute = LogRxRouteDecoder.decode(
log.rawData, log.rawData,
preferredHashSize: snapshot.data, preferredHashSize: snapshot.data?.first as int?,
); );
if (decodedRoute == null) { if (decodedRoute == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final cachedNodes = snapshot.hasData
? snapshot.data![1] as List<MeshMapNode>
: const <MeshMapNode>[];
final resolvedPath = decodedRoute.hopHashes final resolvedPath = decodedRoute.hopHashes
.map( .map(
(hashHex) => LogRxRouteDecoder.resolveHash( (hashHex) => _resolveHashWithFallback(
hashHex, hashHex,
contacts: contacts, contacts: contacts,
cachedNodes: cachedNodes,
ownPublicKey: ownPublicKey, ownPublicKey: ownPublicKey,
ownName: ownName, ownName: ownName,
), ),
@@ -777,6 +787,41 @@ class _DecodedRouteSection extends StatelessWidget {
}, },
); );
} }
ResolvedNodeHash _resolveHashWithFallback(
String hashHex, {
required List<Contact> contacts,
required List<MeshMapNode> cachedNodes,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
final localResolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
);
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
return localResolved;
}
final normalizedHashHex = hashHex.toLowerCase();
final matches = cachedNodes
.where((node) => node.publicKey.startsWith(normalizedHashHex))
.toList();
if (matches.isEmpty) {
return localResolved;
}
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.name,
isOwnNode: false,
isUniqueMatch: matches.length == 1,
matchCount: matches.length,
);
}
} }
class _RouteSection extends StatelessWidget { class _RouteSection extends StatelessWidget {

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -15,6 +16,7 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
@@ -72,6 +74,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
double _fastLocationMovementThresholdMeters = 10.0; double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10; int _fastLocationActiveCadenceSeconds = 10;
bool _isDeveloperModeEnabled = false; bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0; int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService(); final LocationTrackingService _locationService = LocationTrackingService();
@@ -89,6 +93,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
_loadDeveloperMode(); _loadDeveloperMode();
_loadOnlineTraceCacheStatus();
} }
@override @override
@@ -126,6 +131,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _handleVersionTap() async { Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) { if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false); await DeveloperModeService.setEnabled(false);
@@ -470,7 +483,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// Check for app updates and show notification or dialog /// Check for app updates and show notification or dialog
Future<void> _checkForUpdates() async { Future<void> _checkForUpdates() async {
// Only on Android // Only on Android
if (!Platform.isAndroid) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -829,6 +842,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Future<void> _clearOnlineTraceCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear online trace database'),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_isClearingOnlineTraceCache = true;
});
await MeshMapNodesService.clearCache();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = null;
_isClearingOnlineTraceCache = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Online trace database cleared'),
backgroundColor: Colors.orange,
),
);
}
String _onlineTraceCacheSubtitle() {
final cachedAt = _onlineTraceCacheUpdatedAt;
if (cachedAt == null) {
return 'No cached online database. Refresh runs in background when internet is available.';
}
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int part) => part.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _showRouteHashSizeDialog() async { Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>( final selected = await showDialog<int>(
context: context, context: context,
@@ -1047,6 +1120,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Tracing'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Online trace database'),
subtitle: Text(_onlineTraceCacheSubtitle()),
),
ListTile(
leading: Icon(
Icons.delete_sweep,
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
title: Text(
'Clear online trace database',
style: TextStyle(
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
),
subtitle: const Text(
'Remove the 24-hour cached fallback used when local route matches are incomplete',
),
enabled: !_isClearingOnlineTraceCache,
trailing: _isClearingOnlineTraceCache
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: _isClearingOnlineTraceCache
? null
: _clearOnlineTraceCache,
),
]),
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
@@ -1213,8 +1321,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Network Sharing'), if (!kIsWeb) ...[
const ConnectionModeSelector(), _buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([ _buildSettingsCard([
@@ -1320,7 +1430,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showAboutDialog(), onTap: () => _showAboutDialog(),
), ),
]), ]),
if (Platform.isAndroid) if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon( child: FilledButton.icon(

View File

@@ -1,6 +1,5 @@
import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
/// Service for accessing build information from native platform code /// Service for accessing build information from native platform code
/// Currently supports Android only - returns "unknown" for other platforms /// Currently supports Android only - returns "unknown" for other platforms
@@ -25,7 +24,7 @@ class BuildInfoService {
} }
// Only Android has the platform channel implementation // Only Android has the platform channel implementation
if (!Platform.isAndroid) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"'); debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
_cachedCommitHash = 'unknown'; _cachedCommitHash = 'unknown';
return _cachedCommitHash!; return _cachedCommitHash!;

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class MeshMapNode { class MeshMapNode {
final int type; final int type;
@@ -33,25 +35,35 @@ class MeshMapNode {
class MeshMapNodesService { class MeshMapNodesService {
static const String _nodesEndpoint = static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes'; 'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2); static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = Duration(minutes: 10); static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30); static const Duration traceTimeout = Duration(seconds: 30);
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
static List<MeshMapNode>? _cachedNodes; static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt; static DateTime? _cachedAt;
static Future<void>? _ongoingSync;
static Future<List<MeshMapNode>> fetchNodes({ static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false, bool forceRefresh = false,
Duration cacheTtl = _cacheTtl, Duration cacheTtl = _cacheTtl,
bool allowNetwork = true,
http.Client? client,
}) async { }) async {
final now = DateTime.now(); final now = DateTime.now();
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (!forceRefresh && if (!forceRefresh &&
_cachedNodes != null && cached.isNotEmpty &&
_cachedAt != null && _cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) { now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!; return cached;
} }
final response = await http if (!allowNetwork) {
return cached;
}
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint)) .get(Uri.parse(_nodesEndpoint))
.timeout(traceTimeout); .timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
@@ -69,8 +81,131 @@ class MeshMapNodesService {
) )
.toList(); .toList();
_cachedNodes = nodes; await _storeCache(nodes, cachedAt: now);
_cachedAt = now;
return nodes; return nodes;
} }
static Future<List<MeshMapNode>> loadCachedNodes({
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now();
if (_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
}
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
final cachedJson = prefs.getString(_cacheKey);
if (cachedAtMs == null || cachedJson == null) {
_cachedNodes = null;
_cachedAt = null;
return const [];
}
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
if (now.difference(cachedAt) >= cacheTtl) {
_cachedNodes = null;
_cachedAt = cachedAt;
return const [];
}
final decoded = jsonDecode(cachedJson) as List<dynamic>;
final nodes = decoded
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.toList();
_cachedNodes = nodes;
_cachedAt = cachedAt;
return nodes;
}
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
return nodes.isNotEmpty;
}
static Future<void> syncInBackgroundIfStale({
Duration cacheTtl = _cacheTtl,
http.Client? client,
}) async {
final now = DateTime.now();
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
return;
}
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (cached.isNotEmpty && _cachedAt != null) {
return;
}
if (_ongoingSync != null) {
return _ongoingSync!;
}
_ongoingSync = () async {
try {
await fetchNodes(
forceRefresh: true,
cacheTtl: cacheTtl,
allowNetwork: true,
client: client,
);
} catch (_) {
// Background refresh is best-effort only.
} finally {
_ongoingSync = null;
}
}();
return _ongoingSync!;
}
static Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
await prefs.remove(_cacheTimestampKey);
_cachedNodes = null;
_cachedAt = null;
}
static Future<DateTime?> cachedAt() async {
if (_cachedAt != null) return _cachedAt;
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
if (cachedAtMs == null) return null;
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
return _cachedAt;
}
static Future<void> _storeCache(
List<MeshMapNode> nodes, {
required DateTime cachedAt,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_cacheKey,
jsonEncode(
nodes
.map(
(node) => {
'type': node.type,
'name': node.name,
'public_key': node.publicKey,
'latitude': node.latitude,
'longitude': node.longitude,
'updated_at': node.updatedAtMs,
},
)
.toList(),
),
);
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
_cachedNodes = nodes;
_cachedAt = cachedAt;
}
} }

View File

@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
File? _file; File? _file;
Future<File> _resolveFile() async { Future<File> _resolveFile() async {
if (kIsWeb) {
throw UnsupportedError('Packet capture file storage is not supported on web');
}
if (_file != null) return _file!; if (_file != null) return _file!;
final dir = await getApplicationSupportDirectory(); final dir = await getApplicationSupportDirectory();
final file = File('${dir.path}/$_fileName'); final file = File('${dir.path}/$_fileName');
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
Future<void> appendLogs(List<BlePacketLog> logs) async { Future<void> appendLogs(List<BlePacketLog> logs) async {
if (logs.isEmpty) return; if (logs.isEmpty) return;
if (kIsWeb) return;
try { try {
final file = await _resolveFile(); final file = await _resolveFile();
final sink = file.openWrite(mode: FileMode.append); final sink = file.openWrite(mode: FileMode.append);
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
} }
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async { Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
if (kIsWeb) return const [];
try { try {
final file = await _resolveFile(); final file = await _resolveFile();
if (!await file.exists()) return const []; if (!await file.exists()) return const [];
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
} }
Future<int> count() async { Future<int> count() async {
if (kIsWeb) return 0;
try { try {
final file = await _resolveFile(); final file = await _resolveFile();
if (!await file.exists()) return 0; if (!await file.exists()) return 0;
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
} }
Future<void> clear() async { Future<void> clear() async {
if (kIsWeb) return;
try { try {
final file = await _resolveFile(); final file = await _resolveFile();
if (await file.exists()) { if (await file.exists()) {

View File

@@ -1,4 +1,6 @@
class AvatarLabelHelper { class AvatarLabelHelper {
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
static String buildLabel(String name) { static String buildLabel(String name) {
final trimmed = name.trim(); final trimmed = name.trim();
if (trimmed.isEmpty) return '?'; if (trimmed.isEmpty) return '?';
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
return '#${_take(hashBody, 2)}'.toUpperCase(); return '#${_take(hashBody, 2)}'.toUpperCase();
} }
final parts = trimmed final parts = _alnumChunks
.split(RegExp(r'[\s_-]+')) .allMatches(trimmed)
.map((match) => match.group(0)!)
.where((part) => part.isNotEmpty) .where((part) => part.isNotEmpty)
.toList(); .toList();
if (parts.isEmpty) {
return '?';
}
if (parts.length >= 2) { if (parts.length >= 2) {
final first = _take(parts[0], 1); final first = _take(parts[0], 1);
final second = _take(parts[1], 1); final second = _take(parts[1], 1);
return '$first$second'.toUpperCase(); return '$first$second'.toUpperCase();
} }
return _take(trimmed, 2).toUpperCase(); return _take(parts.first, 2).toUpperCase();
} }
static String _take(String value, int count) { static String _take(String value, int count) {

View File

@@ -0,0 +1,116 @@
import '../models/contact.dart';
class InferredContactGroup {
final String key;
final String label;
final List<Contact> contacts;
const InferredContactGroup({
required this.key,
required this.label,
required this.contacts,
});
DateTime get latestSeen => contacts.first.lastSeenTime;
}
class ContactListItem {
final Contact? contact;
final InferredContactGroup? group;
const ContactListItem._({this.contact, this.group});
const ContactListItem.contact(Contact contact) : this._(contact: contact);
const ContactListItem.group(InferredContactGroup group)
: this._(group: group);
bool get isGroup => group != null;
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
}
class ContactGrouping {
static final RegExp _prefixedNamePattern = RegExp(
r'^([A-Za-z0-9]{2,})([-_/:])',
);
static List<Contact> sortByLastSeen(List<Contact> contacts) {
return List<Contact>.from(contacts)
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
}
static List<ContactListItem> buildItems(
List<Contact> contacts, {
int minGroupSize = 4,
}) {
final sortedContacts = sortByLastSeen(contacts);
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
}
static List<ContactListItem> buildItemsFromSorted(
List<Contact> sortedContacts, {
int minGroupSize = 4,
}) {
final groupedContacts = <String, List<Contact>>{};
final groupLabels = <String, String>{};
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
if (prefix == null) continue;
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
}
final eligibleGroups = <String, InferredContactGroup>{};
for (final entry in groupedContacts.entries) {
if (entry.value.length < minGroupSize) continue;
eligibleGroups[entry.key] = InferredContactGroup(
key: entry.key,
label: groupLabels[entry.key] ?? entry.key,
contacts: entry.value,
);
}
final emittedGroups = <String>{};
final items = <ContactListItem>[];
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
final group = prefix == null ? null : eligibleGroups[prefix.key];
if (group == null) {
items.add(ContactListItem.contact(contact));
continue;
}
if (emittedGroups.add(group.key)) {
items.add(ContactListItem.group(group));
}
}
return items;
}
static _GroupPrefix? _extractPrefix(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return null;
final match = _prefixedNamePattern.firstMatch(trimmed);
if (match == null) return null;
final rawPrefix = match.group(1);
final separator = match.group(2);
if (rawPrefix == null || separator == null) return null;
return _GroupPrefix(
key: rawPrefix.toUpperCase(),
label: '$rawPrefix$separator',
);
}
}
class _GroupPrefix {
final String key;
final String label;
const _GroupPrefix({required this.key, required this.label});
}

View File

@@ -4,11 +4,13 @@ import '../models/contact.dart';
class DecodedLogRxRoute { class DecodedLogRxRoute {
final int payloadType; final int payloadType;
final int pathDescriptor;
final List<int> pathBytes; final List<int> pathBytes;
final int hashSize; final int hashSize;
const DecodedLogRxRoute({ const DecodedLogRxRoute({
required this.payloadType, required this.payloadType,
required this.pathDescriptor,
required this.pathBytes, required this.pathBytes,
required this.hashSize, required this.hashSize,
}); });
@@ -63,21 +65,49 @@ class LogRxRouteDecoder {
} }
if (rawPacketData.length <= index) return null; if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++]; final pathDescriptor = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null; final pathMode = (pathDescriptor & 0xFF) >> 6;
final pathBytes = rawPacketData.sublist(index, index + pathLen); final pathByteLen = pathMode == 0
final hashSize = inferHashSize( ? pathDescriptor
pathBytes, : descriptorByteLength(pathDescriptor);
preferredHashSize: preferredHashSize, if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
); return null;
}
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
final hashSize = pathMode == 0
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
: (descriptorHashSize(pathDescriptor) ??
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
return DecodedLogRxRoute( return DecodedLogRxRoute(
payloadType: payloadType, payloadType: payloadType,
pathDescriptor: pathDescriptor,
pathBytes: pathBytes, pathBytes: pathBytes,
hashSize: hashSize, hashSize: hashSize,
); );
} }
static int? descriptorHashSize(int pathDescriptor) {
final normalized = pathDescriptor & 0xFF;
final mode = normalized >> 6;
if (mode == 3) return null;
return mode + 1;
}
static int? descriptorHopCount(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
if (hashSize == null) return null;
return (pathDescriptor & 0xFF) & 0x3F;
}
static int? descriptorByteLength(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
final hopCount = descriptorHopCount(pathDescriptor);
if (hashSize == null || hopCount == null) return null;
final byteLen = hopCount * hashSize;
return byteLen <= 64 ? byteLen : null;
}
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) { static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
if (pathBytes.isEmpty) return 1; if (pathBytes.isEmpty) return 1;

View File

@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
final foregroundColor = _getForegroundColor(backgroundColor); final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji; final emoji = contact.roleEmoji;
if (emoji != null && emoji.isNotEmpty) { if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame( return _buildAvatarFrame(
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)), child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
bool get _usesSquareShape => bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room; contact.type == ContactType.channel || contact.type == ContactType.room;
bool get _showsLeadingEmoji {
final emoji = contact.roleEmoji;
if (emoji == null || emoji.isEmpty) return false;
final effectiveName = (displayName ?? contact.displayName).trimLeft();
return effectiveName.startsWith(emoji);
}
Color _getBackgroundColor(BuildContext context) { Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) { if (_shouldUseLabelFallback || _showsLeadingEmoji) {
return TrailColorService.getTrailColor(contact); return TrailColorService.getTrailColor(contact);
} }

View File

@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
} }
Future<void> _loadLocalIPs() async { Future<void> _loadLocalIPs() async {
if (kIsWeb) {
return;
}
final Set<String> ipsSet = {}; final Set<String> ipsSet = {};
try { try {

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data';
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';
@@ -9,11 +10,11 @@ import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
import '../../utils/location_formats.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -48,6 +49,7 @@ class ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel;
final location = contact.displayLocation; final location = contact.displayLocation;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
@@ -114,33 +116,33 @@ class ContactTile extends StatelessWidget {
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final subtitleWidget = Column( final Widget? subtitleWidget = isChannel
crossAxisAlignment: CrossAxisAlignment.start, ? null
children: [ : Column(
if (location != null) ...[ crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 2), children: [
_buildLocationLine( if (location != null) ...[
context, const SizedBox(height: 2),
latitude: location.latitude, _buildLocationLine(
longitude: location.longitude, context,
distanceText: distanceText, latitude: location.latitude,
), longitude: location.longitude,
if (contact.type != ContactType.channel) ...[ distanceText: distanceText,
const SizedBox(height: 6), ),
Row(children: [_buildRoutePill(context, contact)]), const SizedBox(height: 6),
], Row(children: [_buildRoutePill(context, contact)]),
] else ] else
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
AppLocalizations.of(context)!.noGpsData, AppLocalizations.of(context)!.noGpsData,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelSmall?.copyWith(color: Colors.grey), ).textTheme.labelSmall?.copyWith(color: Colors.grey),
), ),
), ),
], ],
); );
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -237,7 +239,8 @@ class ContactTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(timeAgoText, style: timeAgoStyle), if (!isChannel)
Text(timeAgoText, style: timeAgoStyle),
if (isPingInProgress) ...[ if (isPingInProgress) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
SizedBox( SizedBox(
@@ -251,7 +254,7 @@ class ContactTile extends StatelessWidget {
], ],
], ],
), ),
subtitleWidget, ?subtitleWidget,
], ],
), ),
), ),
@@ -264,11 +267,6 @@ class ContactTile extends StatelessWidget {
} }
void _handlePrimaryTap(BuildContext context, Contact contact) { void _handlePrimaryTap(BuildContext context, Contact contact) {
if (contact.type == ContactType.repeater) {
_showContactDetails(context, contact);
return;
}
_showContactActionSheet(context, contact); _showContactActionSheet(context, contact);
} }
@@ -280,6 +278,11 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.channel; contact.type == ContactType.channel;
final canSetPath = final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room; contact.type == ContactType.chat || contact.type == ContactType.room;
final canAddToSensors =
contact.type == ContactType.chat ||
contact.type == ContactType.repeater;
final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -301,6 +304,46 @@ class ContactTile extends StatelessWidget {
await _openMessagesForContact(context, contact); await _openMessagesForContact(context, contact);
}, },
), ),
if (contact.displayLocation != null)
ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
_showContactOnMap(context, contact);
},
),
if (contact.type == ContactType.room && !contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.login),
title: Text(
context
.read<ConnectionProvider>()
.getRoomLoginState(contact.publicKeyPrefix)
?.isLoggedIn ==
true
? AppLocalizations.of(context)!.reLoginToRoom
: AppLocalizations.of(context)!.loginToRoom,
),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLoginDialog(context, contact);
},
),
if (canAddToSensors)
ListTile(
leading: Icon(
isInSensors ? Icons.sensors : Icons.sensors_outlined,
),
title: Text(isInSensors ? 'In Sensors' : 'Add to Sensors'),
enabled: !isInSensors,
onTap: isInSensors
? null
: () async {
Navigator.pop(sheetContext);
await _addContactToSensors(context, contact);
},
),
if (canSetPath) if (canSetPath)
ListTile( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.alt_route),
@@ -332,6 +375,32 @@ class ContactTile extends StatelessWidget {
); );
} }
void _showContactOnMap(BuildContext context, Contact contact) {
final location = contact.displayLocation;
if (location == null) {
return;
}
context.read<MapProvider>().navigateToLocation(
location: LatLng(location.latitude, location.longitude),
);
onNavigateToMap?.call();
}
Future<void> _addContactToSensors(
BuildContext context,
Contact contact,
) async {
await context.read<SensorsProvider>().addSensor(contact);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${contact.displayName} added to Sensors')),
);
}
Future<void> _openMessagesForContact( Future<void> _openMessagesForContact(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -422,505 +491,6 @@ class ContactTile extends StatelessWidget {
} }
} }
void _showContactDetails(BuildContext context, Contact contact) {
final l10n = AppLocalizations.of(context)!;
// Get room login state
final connectionProvider = context.read<ConnectionProvider>();
final roomLoginState = contact.type == ContactType.room
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
: null;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
final contactsProvider = context.watch<ContactsProvider>();
final currentContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final isPingInProgress = context
.watch<ConnectionProvider>()
.isPingInProgress(contact.publicKey);
return Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ContactAvatar(contact: contact, radius: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
contact.displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(),
// Content
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
_detailRow(l10n.type, contact.type.displayName),
if (contact.isChannel) ...[
_detailRow(
l10n.channel,
contact.getLocalizedDisplayName(context),
),
if (!contact.isPublicChannel)
_detailRow(
'Slot',
'${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}',
),
] else
// Public Key with copy button
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 100,
child: Text(
'${l10n.publicKey}:',
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
Expanded(child: Text(contact.publicKeyShort)),
const SizedBox(width: 8),
InkWell(
onTap: () {
Clipboard.setData(
ClipboardData(text: contact.publicKeyHex),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.publicKeyCopied),
duration: const Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.copy,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
),
_detailRow(
l10n.lastSeen,
_getLocalizedTimeSinceLastSeen(context),
),
const SizedBox(height: 16),
// Room Login Status
if (roomLoginState != null) ...[
Text(
'${AppLocalizations.of(context)!.roomStatus}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
_detailRow(
AppLocalizations.of(context)!.loginStatus,
roomLoginState.isLoggedIn
? AppLocalizations.of(context)!.loggedIn
: AppLocalizations.of(context)!.notLoggedIn,
),
if (roomLoginState.isLoggedIn) ...[
_detailRow(
AppLocalizations.of(context)!.adminAccess,
roomLoginState.isAdmin
? AppLocalizations.of(context)!.yes
: AppLocalizations.of(context)!.no,
),
_detailRow(
AppLocalizations.of(context)!.permissions,
roomLoginState.permissions.toString(),
),
if (roomLoginState.loginDurationFormatted != null)
_detailRow(
AppLocalizations.of(context)!.loggedIn,
roomLoginState.loginDurationFormatted!,
),
],
_detailRow(
AppLocalizations.of(context)!.passwordSaved,
roomLoginState.hasPassword
? AppLocalizations.of(context)!.yes
: AppLocalizations.of(context)!.no,
),
const SizedBox(height: 16),
],
if (contact.displayLocation != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.locationColon,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
TextButton.icon(
onPressed: () {
// Navigate to map and close modal
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
);
Navigator.pop(context);
// Switch to map tab using callback
onNavigateToMap?.call();
},
icon: const Icon(Icons.map, size: 18),
label: Text(
AppLocalizations.of(context)!.viewOnMap,
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
const SizedBox(height: 8),
// Decimal Degrees (DD)
_detailRowWithCopy(
context,
'DD',
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
),
// Degrees Minutes Seconds (DMS)
_detailRowWithCopy(
context,
'DMS',
_convertToDMS(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// Degrees Decimal Minutes (DDM)
_detailRowWithCopy(
context,
'DDM',
_convertToDDM(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// MGRS (Military Grid Reference System)
_detailRowWithCopy(
context,
'MGRS',
_convertToMGRS(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// Google Plus Code
_detailRowWithCopy(
context,
'Plus Code',
formatPlusCode(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
const SizedBox(height: 16),
],
if (contact.telemetry != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${AppLocalizations.of(context)!.telemetry}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
TextButton.icon(
onPressed: isPingInProgress
? null
: () async {
final connectionProvider = context
.read<ConnectionProvider>();
final result = await connectionProvider
.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.routeHasPath,
);
if (!context.mounted || result.success) {
return;
}
ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.pingFailed(contact.displayName),
);
},
icon: isPingInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.refresh),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
const SizedBox(height: 8),
if (contact.telemetry!.batteryMilliVolts != null)
_detailRow(
AppLocalizations.of(context)!.voltage,
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
)
else if (contact.telemetry!.batteryPercentage != null)
_detailRow(
AppLocalizations.of(context)!.battery,
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
),
if (contact.telemetry!.temperature != null)
_detailRow(
AppLocalizations.of(context)!.temperature,
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
),
if (contact.telemetry!.humidity != null)
_detailRow(
AppLocalizations.of(context)!.humidity,
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
),
if (contact.telemetry!.pressure != null)
_detailRow(
AppLocalizations.of(context)!.pressure,
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
),
if (contact.telemetry!.gpsLocation != null)
_detailRow(
AppLocalizations.of(context)!.gpsTelemetry,
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
),
_detailRow(
AppLocalizations.of(context)!.updated,
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
),
],
if (!currentContact.isChannel) ...[
const SizedBox(height: 16),
Text(
'Route',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
_detailRow('Mode', currentContact.routeSummary),
if (currentContact.routeHopCount > 0)
_detailRow('Route', currentContact.routeCanonicalText),
if (currentContact.routeHopCount > 0)
_detailRow(
'Descriptor',
'0x${currentContact.routeEncodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
_showSetRouteDialog(context, currentContact),
icon: const Icon(Icons.route),
label: const Text('Set Route'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: currentContact.isPublicChannel
? null
: () async {
contactsProvider.resetContactRouteLocal(
currentContact.publicKey,
);
try {
await connectionProvider.resetPath(
currentContact.publicKey,
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.pathResetInfo(
currentContact.displayName,
),
),
),
);
}
} catch (_) {
contactsProvider.setContactRouteLocal(
currentContact.publicKey,
signedEncodedPathLen:
currentContact.routeSignedPathLen,
paddedPathBytes:
currentContact.outPath,
);
if (context.mounted) {
ToastLogger.error(
context,
'Failed to reset route.',
);
}
}
},
icon: const Icon(Icons.refresh),
label: Text(
AppLocalizations.of(context)!.resetPath,
),
),
),
],
),
],
// Room Login button for room contacts (except Public Channel)
if (contact.type == ContactType.room &&
!contact.isPublicChannel) ...[
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context); // Close details first
_showRoomLoginDialog(context, contact);
},
icon: const Icon(Icons.login),
label: Text(
roomLoginState?.isLoggedIn == true
? AppLocalizations.of(context)!.reLoginToRoom
: AppLocalizations.of(context)!.loginToRoom,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: _getTypeColor(
contact.type,
context,
),
foregroundColor: Colors.white,
),
),
),
],
// Delete Contact button (for all contact types except Public Channel)
if (!contact.isPublicChannel) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
if (contact.isChannel) {
_showDeleteChannelDialog(
context,
contact,
closeDetailsSheetOnDelete: true,
);
return;
}
_showDeleteConfirmation(
context,
contact,
closeDetailsSheetOnDelete: true,
);
},
icon: const Icon(Icons.delete_outline),
label: Text(
contact.isChannel
? AppLocalizations.of(context)!.deleteChannel
: AppLocalizations.of(context)!.deleteContact,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: Colors.red),
foregroundColor: Colors.red,
),
),
),
],
],
),
),
],
);
},
),
);
}
Future<void> _showSetRouteDialog( Future<void> _showSetRouteDialog(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -996,67 +566,6 @@ class ContactTile extends StatelessWidget {
} }
} }
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(child: Text(value)),
],
),
);
}
Widget _detailRowWithCopy(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(child: Text(value)),
const SizedBox(width: 8),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.copiedToClipboard(label),
),
duration: const Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.copy,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
);
}
Widget _buildLocationMeta( Widget _buildLocationMeta(
BuildContext context, BuildContext context,
double latitude, double latitude,
@@ -1124,75 +633,6 @@ class ContactTile extends StatelessWidget {
); );
} }
/// Convert to Degrees Minutes Seconds (DMS) format
String _convertToDMS(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMinDec = (lat - latDeg) * 60;
int latMin = latMinDec.floor();
double latSec = (latMinDec - latMin) * 60;
int lonDeg = lon.floor();
double lonMinDec = (lon - lonDeg) * 60;
int lonMin = lonMinDec.floor();
double lonSec = (lonMinDec - lonMin) * 60;
return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir';
}
/// Convert to Degrees Decimal Minutes (DDM) format
String _convertToDDM(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMin = (lat - latDeg) * 60;
int lonDeg = lon.floor();
double lonMin = (lon - lonDeg) * 60;
return '$latDeg° ${latMin.toStringAsFixed(4)}\'$latDir, $lonDeg° ${lonMin.toStringAsFixed(4)}\'$lonDir';
}
/// Convert to MGRS (Military Grid Reference System) format
/// Simplified implementation - returns approximate grid zone
String _convertToMGRS(double lat, double lon) {
// Zone number (1-60)
int zone = ((lon + 180) / 6).floor() + 1;
// Zone letter (C-X, excluding I and O)
const letters = 'CDEFGHJKLMNPQRSTUVWX';
int letterIndex = ((lat + 80) / 8).floor();
if (letterIndex < 0) letterIndex = 0;
if (letterIndex >= letters.length) letterIndex = letters.length - 1;
String letter = letters[letterIndex];
// Simplified - just show zone designation
// Full MGRS would require UTM conversion library
return '$zone$letter (approximate)';
}
Color _getTypeColor(ContactType type, BuildContext context) {
switch (type) {
case ContactType.chat:
return Theme.of(context).colorScheme.primary;
case ContactType.repeater:
return Colors.green;
case ContactType.room:
return Colors.orange;
default:
return Colors.grey;
}
}
/// Get room login status color /// Get room login status color
Color _getRoomStatusColor(RoomLoginState state) { Color _getRoomStatusColor(RoomLoginState state) {
if (!state.isLoggedIn) { if (!state.isLoggedIn) {
@@ -1215,41 +655,6 @@ class ContactTile extends StatelessWidget {
return Icons.check; // Check for logged in (non-admin) return Icons.check; // Check for logged in (non-admin)
} }
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final timestampDate = DateTime(
timestamp.year,
timestamp.month,
timestamp.day,
);
if (timestampDate == today) {
// Today - show time only
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
} else {
// Another day - show date and time
return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')} ${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}';
}
}
String _formatTimeAgo(DateTime timestamp) {
final now = DateTime.now();
final diff = now.difference(timestamp);
if (diff.inSeconds < 60) {
return '${diff.inSeconds}s ago';
} else if (diff.inMinutes < 60) {
return '${diff.inMinutes}m ago';
} else if (diff.inHours < 24) {
return '${diff.inHours}h ago';
} else if (diff.inDays == 1) {
return 'yesterday';
} else {
return '${diff.inDays}d ago';
}
}
/// Show delete channel confirmation dialog /// Show delete channel confirmation dialog
void _showDeleteChannelDialog( void _showDeleteChannelDialog(
BuildContext context, BuildContext context,

View File

@@ -403,15 +403,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
} }
} }
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) { if (sender.routeHopCount >= 2) {
_showToast( _showToast(
'Image fetch over ${sender.routeHopCount} hops may take a while.', 'Image fetch over ${sender.routeHopCount} hops may take a while.',
@@ -462,7 +453,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
); );
await conn.sendRawVoicePacket( await conn.sendRawVoicePacket(
contactPath: sender.outPath, contactPath: sender.outPath,
contactPathLen: sender.routeSignedPathLen, contactPathLen: sender.routeEncodedPathLen,
payload: payload, payload: payload,
); );
} catch (_) { } catch (_) {

View File

@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart'; import '../../models/message_transfer_details.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
@@ -1269,12 +1270,10 @@ class _MessageBubbleState extends State<MessageBubble> {
// Logged frame format: // Logged frame format:
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen // [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final payloadType = (raw[3] >> 2) & 0x0F; if (decoded == null) continue;
final pathLen = raw[4]; if (decoded.payloadType != expectedPayloadType) continue;
if (payloadType != expectedPayloadType) continue; if (decoded.hopCount != message.pathLen) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -1290,11 +1289,9 @@ class _MessageBubbleState extends State<MessageBubble> {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) { List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null; if (log == null) return null;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
if (raw.length < 6) return null; if (decoded == null || decoded.pathBytes.isEmpty) return null;
final pathLen = raw[4]; return decoded.pathBytes;
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
} }
void _showDeleteConfirmation(BuildContext context) { void _showDeleteConfirmation(BuildContext context) {

View File

@@ -1,6 +1,7 @@
// ignore_for_file: use_null_aware_elements // ignore_for_file: use_null_aware_elements
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -60,7 +61,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return trace; return trace;
} }
final remoteNodes = await MeshMapNodesService.fetchNodes( unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl, cacheTtl: MeshMapNodesService.traceCacheTtl,
); );
trace = _buildTraceResult( trace = _buildTraceResult(
@@ -516,13 +523,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
for (final log in logs) { for (final log in logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue; if (log.rawData.length < 6) continue;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final header = raw[3]; if (decoded == null) continue;
final payloadType = (header >> 2) & 0x0F; if (decoded.payloadType != expectedPayloadType) continue;
final pathLen = raw[4]; if (decoded.hopCount != message.pathLen) continue;
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -533,9 +537,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
if (bestLog == null || bestDeltaMs > 30000) return null; if (bestLog == null || bestDeltaMs > 30000) return null;
final raw = bestLog.rawData; final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
final pathLen = raw[4]; if (decoded == null || decoded.pathBytes.isEmpty) {
return raw.sublist(5, 5 + pathLen); return null;
}
return decoded.pathBytes;
} }
List<MeshMapNode?> _matchNodesFromPathHashes({ List<MeshMapNode?> _matchNodesFromPathHashes({

View File

@@ -366,15 +366,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
} }
} }
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) { if (sender.routeHopCount >= 2) {
_showToast( _showToast(
'Voice fetch over ${sender.routeHopCount} hops may take a while.', 'Voice fetch over ${sender.routeHopCount} hops may take a while.',
@@ -415,10 +406,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
) )
: VoiceFetchRequest( : VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6);
sessionId: sessionId,
requesterKey6: requesterKey6,
);
try { try {
debugPrint( debugPrint(
@@ -426,7 +414,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath, contactPath: sender.outPath,
contactPathLen: sender.routeSignedPathLen, contactPathLen: sender.routeEncodedPathLen,
payload: request.encodeBinary(), payload: request.encodeBinary(),
); );
} catch (_) { } catch (_) {

View File

@@ -796,7 +796,7 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9 resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"
@@ -1265,10 +1265,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqflite_android name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2+2" version: "2.4.2+3"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:

View File

@@ -104,7 +104,7 @@ void main() {
expect(contact.routeHashSize, 3); expect(contact.routeHashSize, 3);
expect(contact.routeHopCount, 2); expect(contact.routeHopCount, 2);
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF'); expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
expect(contact.routeSupportsLegacyRawTransport, isFalse); expect(contact.routeSupportsLegacyRawTransport, isTrue);
}); });
test('treats -1 as unknown route', () { test('treats -1 as unknown route', () {

View File

@@ -47,6 +47,63 @@ void main() {
expect(ok, isFalse); expect(ok, isFalse);
}); });
test('returns false when requester has no learned path', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: -1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
);
expect(ok, isFalse);
});
test('returns false when requester path payload is empty', () async {
final requester = Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List(0),
advName: 'Requester',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: requester,
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
);
expect(ok, isFalse);
});
test('sends only requested indices', () async { test('sends only requested indices', () async {
final sent = <Uint8List>[]; final sent = <Uint8List>[];
final ok = await serveCachedSessionFragments<_Fragment>( final ok = await serveCachedSessionFragments<_Fragment>(

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -11,6 +12,30 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
Contact _buildContact({
required List<int> prefix,
required ContactType type,
String name = 'Test Contact',
}) {
final key = Uint8List.fromList([
...prefix,
...List<int>.generate(26, (index) => index),
]);
return Contact(
publicKey: key,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -190,5 +215,60 @@ void main() {
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566'); expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
expect(restoredImage?.downloaders.single.requesterName, equals('Bob')); expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
}); });
test('marks only the selected contact destination as read', () {
final provider = MessagesProvider();
final alice = _buildContact(
prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
type: ContactType.chat,
name: 'Alice',
);
final bob = _buildContact(
prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25],
type: ContactType.chat,
name: 'Bob',
);
provider.addMessage(
Message(
id: 'alice-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Alice unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: alice.publicKey.sublist(0, 6),
),
);
provider.addMessage(
Message(
id: 'bob-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000101,
text: 'Bob unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: bob.publicKey.sublist(0, 6),
),
);
provider.markDestinationAsRead(
destinationType: 'contact',
contact: alice,
);
final aliceMessage = provider.messages.firstWhere(
(message) => message.id == 'alice-incoming',
);
final bobMessage = provider.messages.firstWhere(
(message) => message.id == 'bob-incoming',
);
expect(aliceMessage.isRead, isTrue);
expect(bobMessage.isRead, isFalse);
expect(provider.unreadCount, equals(1));
});
}); });
} }

View File

@@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await MeshMapNodesService.clearCache();
});
test('fetchNodes persists nodes and cached lookup works offline', () async {
final client = MockClient((request) async {
expect(
request.url.toString(),
'https://api.meshcore.nz/api/v1/map/nodes',
);
return http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
);
});
final nodes = await MeshMapNodesService.fetchNodes(client: client);
final cached = await MeshMapNodesService.fetchNodes(allowNetwork: false);
expect(nodes, hasLength(1));
expect(cached, hasLength(1));
expect(cached.first.name, 'Alpha');
expect(await MeshMapNodesService.hasFreshCache(), isTrue);
});
test('loadCachedNodes ignores stale persisted cache', () async {
SharedPreferences.setMockInitialValues({
'mesh_map_nodes_cache_v1': jsonEncode([
{
'type': 1,
'name': 'Old node',
'public_key': 'bb22cc33',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
]),
'mesh_map_nodes_cache_timestamp_v1': DateTime.now()
.subtract(const Duration(hours: 25))
.millisecondsSinceEpoch,
});
final nodes = await MeshMapNodesService.loadCachedNodes();
expect(nodes, isEmpty);
expect(await MeshMapNodesService.hasFreshCache(), isFalse);
});
test('clearCache removes persisted online trace database', () async {
final client = MockClient(
(_) async => http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
),
);
await MeshMapNodesService.fetchNodes(client: client);
await MeshMapNodesService.clearCache();
expect(await MeshMapNodesService.loadCachedNodes(), isEmpty);
expect(await MeshMapNodesService.cachedAt(), isNull);
});
}

View File

@@ -22,5 +22,13 @@ void main() {
test('keeps non-hash labels at two characters', () { test('keeps non-hash labels at two characters', () {
expect(AvatarLabelHelper.buildLabel('abc'), 'AB'); expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
}); });
test('strips emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD');
});
test('strips leading emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH');
});
}); });
} }

View File

@@ -0,0 +1,120 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
void main() {
Contact buildContact({
required int seed,
required String name,
required DateTime lastSeen,
}) {
return Contact(
publicKey: Uint8List.fromList(
List<int>.generate(32, (index) => (seed + index) % 255),
),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: lastSeen.millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: lastSeen.millisecondsSinceEpoch ~/ 1000,
);
}
group('ContactGrouping.buildItems', () {
test(
'groups prefixed contacts only when at least four share the prefix',
() {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'OTHER',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.first.group!.label, 'SI-');
expect(
items.first.group!.contacts.map((contact) => contact.displayName),
['SI-1', 'SI-2', 'SI-3', 'SI-4'],
);
expect(items.last.contact!.displayName, 'OTHER');
},
);
test('does not group when only three contacts share a prefix', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
]);
expect(items, hasLength(3));
expect(items.every((item) => !item.isGroup), isTrue);
});
test('orders groups and ungrouped contacts by latest last seen', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(
seed: 1,
name: 'Lone',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
buildContact(
seed: 3,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.last.contact!.displayName, 'Lone');
});
});
}

View File

@@ -24,12 +24,37 @@ void main() {
expect(decoded, isNotNull); expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01); expect(decoded!.payloadType, 0x01);
expect(decoded.pathDescriptor, 0x04);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]); expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2); expect(decoded.hashSize, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']); expect(decoded.hopHashes, ['c2ba', '5fde']);
expect(decoded.originalSenderHashHex, 'c2ba'); expect(decoded.originalSenderHashHex, 'c2ba');
}); });
test('parses encoded descriptor with 2-byte hashes', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x42,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.pathDescriptor, 0x42);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2);
expect(decoded.hopCount, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']);
});
test('uses preferred hash size when packet length is ambiguous', () { test('uses preferred hash size when packet length is ambiguous', () {
final packet = Uint8List.fromList([ final packet = Uint8List.fromList([
0x88, 0x88,

View File

@@ -52,7 +52,7 @@ void main() {
contact.publicKeyHex: 3, contact.publicKeyHex: 3,
}, },
currentDestinationType: 'all', currentDestinationType: 'all',
onSelect: (_, __) {}, onSelect: (selectedContact, destinationType) {},
), ),
), ),
), ),