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

View File

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

View File

@@ -52,8 +52,6 @@ class ContactTile extends StatelessWidget {
contact.telemetry != null && contact.telemetry!.isRecent; contact.telemetry != null && contact.telemetry!.isRecent;
final battery = contact.displayBattery; final battery = contact.displayBattery;
final location = contact.displayLocation; final location = contact.displayLocation;
final routeHasPath = contact.routeHasPath;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
if (location != null && 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) ...[ if (isPingInProgress) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
SizedBox( SizedBox(
@@ -227,7 +187,6 @@ class ContactTile extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 4),
// Simple mode: Show location and distance // Simple mode: Show location and distance
if (location != null) ...[ if (location != null) ...[
Row( Row(
@@ -247,24 +206,18 @@ class ContactTile extends StatelessWidget {
), ),
], ],
), ),
if (distanceText != null) ...[ if (contact.type != ContactType.channel ||
const SizedBox(height: 4), distanceText != null) ...[
Row( const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
const Icon( if (contact.type != ContactType.channel)
Icons.straighten, _buildRoutePill(context, contact),
size: 12, if (distanceText != null)
color: Colors.blue, _buildDistancePill(context, distanceText),
),
const SizedBox(width: 4),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
], ],
), ),
], ],
@@ -410,24 +363,18 @@ class ContactTile extends StatelessWidget {
], ],
), ),
// Distance info (new row) // Distance info (new row)
if (distanceText != null) ...[ if (contact.type != ContactType.channel ||
const SizedBox(height: 4), distanceText != null) ...[
Row( const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
const Icon( if (contact.type != ContactType.channel)
Icons.straighten, _buildRoutePill(context, contact),
size: 12, if (distanceText != null)
color: Colors.blue, _buildDistancePill(context, distanceText),
),
const SizedBox(width: 4),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
], ],
), ),
], ],
@@ -874,7 +821,8 @@ class ContactTile extends StatelessWidget {
: () async { : () async {
final connectionProvider = context final connectionProvider = context
.read<ConnectionProvider>(); .read<ConnectionProvider>();
final result = await connectionProvider.smartPing( final result = await connectionProvider
.smartPing(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
hasPath: contact.routeHasPath, 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( 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>[ final contactTypes = <ContactType>[
ContactType.chat, ContactType.chat,
ContactType.repeater, ContactType.repeater,
ContactType.room, ContactType.room,
ContactType.channel,
]; ];
for (var i = 0; i < contactTypes.length; i++) { 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', () { test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData( final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001, latitude: 45.0001,
@@ -235,7 +279,9 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); 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( final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001, latitude: 45.0001,
longitude: 13.9999, longitude: 13.9999,
@@ -253,7 +299,8 @@ void main() {
expect(reloaded.advertLocation, isNotNull); expect(reloaded.advertLocation, isNotNull);
expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001)); expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001));
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001)); expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
}); },
);
}); });
group('ContactsProvider route updates', () { group('ContactsProvider route updates', () {