Fix contact route parse usage

This commit is contained in:
Janez T
2026-03-07 18:36:38 +01:00
parent 6bfc08f3ae
commit 4010680da0
4 changed files with 645 additions and 501 deletions

View File

@@ -303,11 +303,28 @@ class ContactsProvider with ChangeNotifier {
} else {
// Existing contact - preserve history and isNew status
final existingContact = _contacts[contact.publicKeyHex]!;
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: contact.telemetry,
);
final incomingAdvertLocation = contact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation;
// Start with existing contact
updatedContact = contact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry,
advLat: incomingAdvertLocation != null
? contact.advLat
: existingAdvertLocation != null
? existingContact.advLat
: contact.advLat,
advLon: incomingAdvertLocation != null
? contact.advLon
: existingAdvertLocation != null
? existingContact.advLon
: contact.advLon,
);
// Add new location to history if location has changed
@@ -397,23 +414,21 @@ class ContactsProvider with ChangeNotifier {
);
}
// Keep last valid GPS for router/chat/room contacts when current
// telemetry does not provide a valid GPS fix.
if (_shouldRetainLastValidGps(contact, telemetry.gpsLocation)) {
final previousTelemetry = contact.telemetry;
// Keep last valid GPS whenever current telemetry does not provide a
// valid GPS fix.
if (_shouldRetainLastValidGps(previousTelemetry, telemetry.gpsLocation)) {
debugPrint(
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
);
final previousGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
telemetry = ContactTelemetry(
gpsLocation: previousGps,
batteryPercentage: telemetry.batteryPercentage,
batteryMilliVolts: telemetry.batteryMilliVolts,
temperature: telemetry.temperature,
timestamp: telemetry.timestamp,
humidity: telemetry.humidity,
pressure: telemetry.pressure,
extraSensorData: telemetry.extraSensorData,
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: previousTelemetry,
incomingTelemetry: telemetry,
);
if (mergedTelemetry != null) {
telemetry = mergedTelemetry;
}
}
// Update contact with new telemetry AND last seen time
@@ -467,15 +482,12 @@ class ContactsProvider with ChangeNotifier {
return location;
}
bool _shouldRetainLastValidGps(Contact contact, LatLng? incomingGps) {
final isSupportedType =
contact.isChat || contact.isRepeater || contact.isRoom;
if (!isSupportedType) {
return false;
}
bool _shouldRetainLastValidGps(
ContactTelemetry? existingTelemetry,
LatLng? incomingGps,
) {
final hasPreviousValidGps =
_getValidGpsOrNull(contact.telemetry?.gpsLocation) != null;
_getValidGpsOrNull(existingTelemetry?.gpsLocation) != null;
if (!hasPreviousValidGps) {
return false;
}
@@ -483,6 +495,36 @@ class ContactsProvider with ChangeNotifier {
return incomingGps == null;
}
ContactTelemetry? _mergeTelemetryForContact({
ContactTelemetry? existingTelemetry,
ContactTelemetry? incomingTelemetry,
}) {
if (incomingTelemetry == null) {
return existingTelemetry;
}
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
if (incomingGps != null) {
return incomingTelemetry;
}
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
if (previousGps == null) {
return incomingTelemetry;
}
return ContactTelemetry(
gpsLocation: previousGps,
batteryPercentage: incomingTelemetry.batteryPercentage,
batteryMilliVolts: incomingTelemetry.batteryMilliVolts,
temperature: incomingTelemetry.temperature,
timestamp: incomingTelemetry.timestamp,
humidity: incomingTelemetry.humidity,
pressure: incomingTelemetry.pressure,
extraSensorData: incomingTelemetry.extraSensorData,
);
}
int _coordinateToAdvertMicrodegrees(double coordinate) {
return (coordinate * 1e6).round();
}

View File

@@ -722,9 +722,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: ListView(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
children: [
// General Settings Section
_buildSectionHeader(AppLocalizations.of(context)!.general),
_buildSectionHeader('Appearance'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.palette),
title: Text(AppLocalizations.of(context)!.theme),
@@ -732,10 +733,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language),
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
SwitchListTile(
secondary: const Icon(Icons.radar),
title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
subtitle: Text(AppLocalizations.of(context)!.displayPacketActivity),
subtitle: Text(
AppLocalizations.of(context)!.displayPacketActivity,
),
value: _showRxTxIndicators,
onChanged: (value) async {
setState(() {
@@ -744,6 +754,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _saveRxTxPreference(value);
},
),
]),
_buildSectionHeader('Navigation'),
_buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.visibility_off),
@@ -757,19 +771,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.map_outlined),
@@ -809,12 +810,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language),
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
]),
_buildSectionHeader('Messaging'),
_buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile(
leading: const Icon(Icons.alt_route),
@@ -834,9 +845,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages,
),
const Divider(),
]),
// Voice Settings Section
_buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) =>
@@ -849,6 +859,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Voice bitrate'),
@@ -904,10 +915,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
const Divider(),
]),
// Image Settings Section
_buildSectionHeader('Image'),
_buildSectionHeader('Images'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.photo_size_select_large),
title: const Text('Max image size'),
@@ -918,10 +929,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Image compression'),
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
subtitle: Text(
'$_imageCompression / 90 (higher = smaller file)',
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Slider(
value: _imageCompression.toDouble(),
min: 10,
@@ -960,14 +973,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _refreshImageModePreview();
},
),
]),
Consumer<ConnectionProvider>(
builder: (context, connectionProvider, child) =>
_buildImageModePreviewCard(connectionProvider),
),
const Divider(),
// Templates Section
_buildSectionHeader('Templates'),
_buildSectionHeader('Templates & Help'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.location_searching),
title: Text(AppLocalizations.of(context)!.sarTemplates),
@@ -987,13 +999,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
// Show wizard without resetting state - just as a modal
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WelcomeWizardScreen(
onCompleted: () {
// Just pop back to settings when done
Navigator.of(context).pop();
},
),
@@ -1001,14 +1011,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
),
const Divider(),
]),
// Network Sharing Section
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
const Divider(),
// Permissions Section
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
@@ -1058,10 +1067,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _handleLocationPermissionTap(),
),
const Divider(),
]),
// About Section
_buildSectionHeader(AppLocalizations.of(context)!.about),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.info),
title: Text(AppLocalizations.of(context)!.appVersion),
@@ -1071,10 +1080,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
: 'Loading...',
),
),
// Check for Updates button (Android only)
ListTile(
leading: const Icon(Icons.badge),
title: Text(AppLocalizations.of(context)!.appName),
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
),
ListTile(
leading: const Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
),
onTap: () => _showAboutDialog(),
),
]),
if (Platform.isAndroid)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon(
onPressed: _isCheckingForUpdates ? null : _checkForUpdates,
icon: _isCheckingForUpdates
@@ -1095,34 +1117,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
),
ListTile(
leading: const Icon(Icons.badge),
title: Text(AppLocalizations.of(context)!.appName),
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
),
ListTile(
leading: const Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
),
onTap: () => _showAboutDialog(),
),
const Divider(),
// Developer Section
_buildSectionHeader(AppLocalizations.of(context)!.developer),
_buildSectionHeader('Developer & Data'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.bug_report),
title: Text(AppLocalizations.of(context)!.packageName),
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
),
const Divider(),
// Sample Data Section
_buildSectionHeader(AppLocalizations.of(context)!.sampleData),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
AppLocalizations.of(context)!.sampleData,
style: Theme.of(context).textTheme.titleSmall,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
AppLocalizations.of(context)!.sampleDataDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -1133,7 +1143,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
@@ -1167,6 +1177,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
]),
],
),
);
@@ -1174,7 +1185,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -1185,6 +1196,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildSettingsCard(List<Widget> children) {
return Card(
margin: EdgeInsets.zero,
child: Column(
children: ListTile.divideTiles(
context: context,
tiles: children,
color: Theme.of(context).dividerColor.withValues(alpha: 0.4),
).toList(),
),
);
}
Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) {
final sourceBytes = _previewSourceBytes;
final fileName = _previewSourceName ?? 'No image selected';

View File

@@ -52,8 +52,6 @@ class ContactTile extends StatelessWidget {
contact.telemetry != null && contact.telemetry!.isRecent;
final battery = contact.displayBattery;
final location = contact.displayLocation;
final routeHasPath = contact.routeHasPath;
// Calculate distance if both positions are available
String? distanceText;
if (location != null &&
@@ -154,44 +152,6 @@ class ContactTile extends StatelessWidget {
),
),
],
// Connection type indicator (direct/flood) - hidden in simple mode
if (!isSimpleMode && contact.type != ContactType.channel) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: routeHasPath
? Colors.green.withValues(alpha: 0.15)
: Colors.orange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(3),
border: Border.all(
color: routeHasPath ? Colors.green : Colors.orange,
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
routeHasPath ? Icons.route : Icons.waves,
size: 10,
color: routeHasPath ? Colors.green : Colors.orange,
),
const SizedBox(width: 2),
Text(
routeHasPath
? AppLocalizations.of(context)!.direct
: AppLocalizations.of(context)!.flood,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: routeHasPath ? Colors.green : Colors.orange,
),
),
],
),
),
],
if (isPingInProgress) ...[
const SizedBox(width: 6),
SizedBox(
@@ -227,7 +187,6 @@ class ContactTile extends StatelessWidget {
),
],
),
const SizedBox(height: 4),
// Simple mode: Show location and distance
if (location != null) ...[
Row(
@@ -247,24 +206,18 @@ class ContactTile extends StatelessWidget {
),
],
),
if (distanceText != null) ...[
const SizedBox(height: 4),
Row(
if (contact.type != ContactType.channel ||
distanceText != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Icon(
Icons.straighten,
size: 12,
color: Colors.blue,
),
const SizedBox(width: 4),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
if (contact.type != ContactType.channel)
_buildRoutePill(context, contact),
if (distanceText != null)
_buildDistancePill(context, distanceText),
],
),
],
@@ -410,24 +363,18 @@ class ContactTile extends StatelessWidget {
],
),
// Distance info (new row)
if (distanceText != null) ...[
const SizedBox(height: 4),
Row(
if (contact.type != ContactType.channel ||
distanceText != null) ...[
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Icon(
Icons.straighten,
size: 12,
color: Colors.blue,
),
const SizedBox(width: 4),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
if (contact.type != ContactType.channel)
_buildRoutePill(context, contact),
if (distanceText != null)
_buildDistancePill(context, distanceText),
],
),
],
@@ -874,7 +821,8 @@ class ContactTile extends StatelessWidget {
: () async {
final connectionProvider = context
.read<ConnectionProvider>();
final result = await connectionProvider.smartPing(
final result = await connectionProvider
.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.routeHasPath,
);
@@ -1388,4 +1336,87 @@ class ContactTile extends StatelessWidget {
),
);
}
Widget _buildRoutePill(BuildContext context, Contact contact) {
final hasPath = contact.routeHasPath;
final scheme = Theme.of(context).colorScheme;
final textColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
final iconColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7);
final label = !hasPath
? AppLocalizations.of(context)!.flood
: contact.routeHopCount <= 0
? AppLocalizations.of(context)!.direct
: contact.routeCanonicalText;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
hasPath ? Icons.alt_route : Icons.waves,
size: 11,
color: iconColor,
),
const SizedBox(width: 5),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: textColor,
fontWeight: FontWeight.w600,
fontFamily: hasPath && contact.routeHopCount > 0
? 'monospace'
: null,
),
),
),
],
),
);
}
Widget _buildDistancePill(BuildContext context, String distanceText) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.straighten,
size: 11,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
),
const SizedBox(width: 5),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -133,12 +133,13 @@ void main() {
});
test(
'retains last valid gps for chat/repeater/room when telemetry gps is invalid or missing',
'retains last valid gps for any contact when telemetry gps is invalid or missing',
() {
final contactTypes = <ContactType>[
ContactType.chat,
ContactType.repeater,
ContactType.room,
ContactType.channel,
];
for (var i = 0; i < contactTypes.length; i++) {
@@ -197,6 +198,49 @@ void main() {
},
);
test(
'retains last known gps when a contact refresh arrives without location',
() {
final firstFix = CayenneLppParser.createGpsData(
latitude: 45.1234,
longitude: 13.8765,
);
provider.updateTelemetry(publicKey.sublist(0, 6), firstFix);
provider.addOrUpdateContact(
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Test Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull);
expect(
updated.telemetry!.gpsLocation!.latitude,
closeTo(45.1234, 0.0001),
);
expect(
updated.telemetry!.gpsLocation!.longitude,
closeTo(13.8765, 0.0001),
);
expect(updated.advLat, equals((45.1234 * 1e6).round()));
expect(updated.advLon, equals((13.8765 * 1e6).round()));
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
},
);
test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001,
@@ -235,7 +279,9 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
test('persists last valid telemetry gps on the contact across reloads', () async {
test(
'persists last valid telemetry gps on the contact across reloads',
() async {
final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001,
longitude: 13.9999,
@@ -253,7 +299,8 @@ void main() {
expect(reloaded.advertLocation, isNotNull);
expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001));
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
});
},
);
});
group('ContactsProvider route updates', () {