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,121 +722,131 @@ 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),
subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)), subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(), onTap: () => _showThemeDialog(),
), ),
SwitchListTile( ListTile(
secondary: const Icon(Icons.radar), leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.showRxTxIndicators), title: Text(AppLocalizations.of(context)!.language),
subtitle: Text(AppLocalizations.of(context)!.displayPacketActivity), subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
value: _showRxTxIndicators, trailing: const Icon(Icons.chevron_right),
onChanged: (value) async { onTap: () => _showLanguageDialog(),
setState(() { ),
_showRxTxIndicators = value; SwitchListTile(
}); secondary: const Icon(Icons.radar),
await _saveRxTxPreference(value); title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
},
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.simpleMode),
subtitle: Text( subtitle: Text(
AppLocalizations.of(context)!.simpleModeDescription, AppLocalizations.of(context)!.displayPacketActivity,
), ),
value: appProvider.isSimpleMode, value: _showRxTxIndicators,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleSimpleMode(value); setState(() {
_showRxTxIndicators = value;
});
await _saveRxTxPreference(value);
}, },
), ),
), ]),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( _buildSectionHeader('Navigation'),
secondary: const Icon(Icons.person_add_alt_1), _buildSettingsCard([
title: const Text('Auto-add discovered contacts'), Consumer<AppProvider>(
subtitle: const Text( builder: (context, appProvider, child) => SwitchListTile(
'Automatically fetch and add new contacts when they are discovered', secondary: const Icon(Icons.visibility_off),
), title: Text(AppLocalizations.of(context)!.simpleMode),
value: appProvider.autoAddDiscoveredContacts, subtitle: Text(
onChanged: (value) async { AppLocalizations.of(context)!.simpleModeDescription,
await appProvider.toggleAutoAddDiscoveredContacts(value); ),
}, value: appProvider.isSimpleMode,
), onChanged: (value) async {
), await appProvider.toggleSimpleMode(value);
Consumer<AppProvider>( },
builder: (context, appProvider, child) => SwitchListTile( ),
secondary: const Icon(Icons.map_outlined), ),
title: Text(AppLocalizations.of(context)!.disableMap), Consumer<AppProvider>(
subtitle: Text( builder: (context, appProvider, child) => SwitchListTile(
AppLocalizations.of(context)!.disableMapDescription, secondary: const Icon(Icons.map_outlined),
), title: Text(AppLocalizations.of(context)!.disableMap),
value: !appProvider.isMapEnabled, subtitle: Text(
onChanged: (value) async { AppLocalizations.of(context)!.disableMapDescription,
await appProvider.toggleMapEnabled(!value); ),
}, value: !appProvider.isMapEnabled,
), onChanged: (value) async {
), await appProvider.toggleMapEnabled(!value);
Consumer<AppProvider>( },
builder: (context, appProvider, child) => SwitchListTile( ),
secondary: const Icon(Icons.contacts_outlined), ),
title: const Text('Disable Contacts'), Consumer<AppProvider>(
subtitle: const Text( builder: (context, appProvider, child) => SwitchListTile(
'Hide the contacts tab to simplify navigation', secondary: const Icon(Icons.contacts_outlined),
), title: const Text('Disable Contacts'),
value: !appProvider.isContactsEnabled, subtitle: const Text(
onChanged: (value) async { 'Hide the contacts tab to simplify navigation',
await appProvider.toggleContactsEnabled(!value); ),
}, value: !appProvider.isContactsEnabled,
), onChanged: (value) async {
), await appProvider.toggleContactsEnabled(!value);
Consumer<AppProvider>( },
builder: (context, appProvider, child) => SwitchListTile( ),
secondary: const Icon(Icons.sensors), ),
title: const Text('Enable Sensors tab'), Consumer<AppProvider>(
subtitle: const Text( builder: (context, appProvider, child) => SwitchListTile(
'Show a dedicated tab for watched relay and node telemetry', secondary: const Icon(Icons.sensors),
), title: const Text('Enable Sensors tab'),
value: appProvider.isSensorsEnabled, subtitle: const Text(
onChanged: (value) async { 'Show a dedicated tab for watched relay and node telemetry',
await appProvider.toggleSensorsEnabled(value); ),
}, value: appProvider.isSensorsEnabled,
), onChanged: (value) async {
), await appProvider.toggleSensorsEnabled(value);
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([
ListTile( Consumer<AppProvider>(
leading: const Icon(Icons.alt_route), builder: (context, appProvider, child) => SwitchListTile(
title: const Text('Route path byte size'), secondary: const Icon(Icons.person_add_alt_1),
subtitle: Text( title: const Text('Auto-add discovered contacts'),
'$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes', subtitle: const Text(
), 'Automatically fetch and add new contacts when they are discovered',
trailing: const Icon(Icons.chevron_right), ),
onTap: _showRouteHashSizeDialog, value: appProvider.autoAddDiscoveredContacts,
), onChanged: (value) async {
ListTile( await appProvider.toggleAutoAddDiscoveredContacts(value);
leading: const Icon(Icons.delete_sweep, color: Colors.red), },
title: const Text( ),
'Clear Messages', ),
style: TextStyle(color: Colors.red), ListTile(
), leading: const Icon(Icons.alt_route),
subtitle: const Text('Delete all stored message history'), title: const Text('Route path byte size'),
onTap: _clearMessages, subtitle: Text(
), '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes',
const Divider(), ),
trailing: const Icon(Icons.chevron_right),
onTap: _showRouteHashSizeDialog,
),
ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(
'Clear Messages',
style: TextStyle(color: Colors.red),
),
subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages,
),
]),
// Voice Settings Section
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
@@ -849,232 +859,244 @@ class _SettingsScreenState extends State<SettingsScreen> {
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
), ),
), ),
ListTile( _buildSettingsCard([
leading: const Icon(Icons.graphic_eq), ListTile(
title: const Text('Voice bitrate'), leading: const Icon(Icons.graphic_eq),
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), title: const Text('Voice bitrate'),
trailing: const Icon(Icons.chevron_right), subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
onTap: _showVoiceBitrateDialog, trailing: const Icon(Icons.chevron_right),
), onTap: _showVoiceBitrateDialog,
Consumer<AppProvider>( ),
builder: (context, appProvider, child) => SwitchListTile( Consumer<AppProvider>(
secondary: const Icon(Icons.tune), builder: (context, appProvider, child) => SwitchListTile(
title: const Text('Band-pass filter voice'), secondary: const Icon(Icons.tune),
subtitle: const Text( title: const Text('Band-pass filter voice'),
'Keeps speech frequencies and cuts low/high noise', subtitle: const Text(
'Keeps speech frequencies and cuts low/high noise',
),
value: appProvider.isVoiceBandPassFilterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceBandPassFilterEnabled(value);
},
), ),
value: appProvider.isVoiceBandPassFilterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceBandPassFilterEnabled(value);
},
), ),
), Consumer<AppProvider>(
Consumer<AppProvider>( builder: (context, appProvider, child) => SwitchListTile(
builder: (context, appProvider, child) => SwitchListTile( secondary: const Icon(Icons.compress),
secondary: const Icon(Icons.compress), title: const Text('Voice compressor'),
title: const Text('Voice compressor'), subtitle: const Text('Balances quiet and loud speech levels'),
subtitle: const Text('Balances quiet and loud speech levels'), value: appProvider.isVoiceCompressorEnabled,
value: appProvider.isVoiceCompressorEnabled, onChanged: (value) async {
onChanged: (value) async { await appProvider.toggleVoiceCompressorEnabled(value);
await appProvider.toggleVoiceCompressorEnabled(value); },
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.speed),
title: const Text('Voice limiter'),
subtitle: const Text('Prevents clipping peaks before encoding'),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
subtitle: const Text(
'Removes long silent parts before sending voice',
), ),
value: appProvider.isVoiceSilenceTrimmingEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
},
), ),
), Consumer<AppProvider>(
const Divider(), builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.speed),
title: const Text('Voice limiter'),
subtitle: const Text('Prevents clipping peaks before encoding'),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
subtitle: const Text(
'Removes long silent parts before sending voice',
),
value: appProvider.isVoiceSilenceTrimmingEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
},
),
),
]),
// 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'),
subtitle: Text('$_imageMaxSize×$_imageMaxSize px'), subtitle: Text('$_imageMaxSize×$_imageMaxSize px'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _showImageMaxSizeDialog, onTap: _showImageMaxSizeDialog,
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Image compression'),
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Slider(
value: _imageCompression.toDouble(),
min: 10,
max: 90,
divisions: 8,
label: '$_imageCompression',
onChanged: (v) => setState(() => _imageCompression = v.round()),
onChangeEnd: (v) => _saveImageCompression(v.round()),
), ),
), ListTile(
SwitchListTile( leading: const Icon(Icons.tune),
secondary: const Icon(Icons.invert_colors), title: const Text('Image compression'),
title: const Text('Grayscale'), subtitle: Text(
subtitle: const Text( '$_imageCompression / 90 (higher = smaller file)',
'Converts image to grayscale for smaller file size', ),
), ),
value: _imageGrayscale, Padding(
onChanged: (value) async { padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
await ImagePreferences.setGrayscale(value); child: Slider(
setState(() => _imageGrayscale = value); value: _imageCompression.toDouble(),
await _refreshImageModePreview(); min: 10,
}, max: 90,
), divisions: 8,
SwitchListTile( label: '$_imageCompression',
secondary: const Icon(Icons.compress), onChanged: (v) => setState(() => _imageCompression = v.round()),
title: const Text('Ultra mode'), onChangeEnd: (v) => _saveImageCompression(v.round()),
subtitle: const Text( ),
'Extra-aggressive compression with stronger AVIF settings',
), ),
value: _imageUltraMode, SwitchListTile(
onChanged: (value) async { secondary: const Icon(Icons.invert_colors),
await ImagePreferences.setUltraMode(value); title: const Text('Grayscale'),
setState(() { subtitle: const Text(
_imageUltraMode = value; 'Converts image to grayscale for smaller file size',
}); ),
await _refreshImageModePreview(); value: _imageGrayscale,
}, onChanged: (value) async {
), await ImagePreferences.setGrayscale(value);
setState(() => _imageGrayscale = value);
await _refreshImageModePreview();
},
),
SwitchListTile(
secondary: const Icon(Icons.compress),
title: const Text('Ultra mode'),
subtitle: const Text(
'Extra-aggressive compression with stronger AVIF settings',
),
value: _imageUltraMode,
onChanged: (value) async {
await ImagePreferences.setUltraMode(value);
setState(() {
_imageUltraMode = value;
});
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 ListTile(
_buildSectionHeader('Templates'), leading: const Icon(Icons.location_searching),
ListTile( title: Text(AppLocalizations.of(context)!.sarTemplates),
leading: const Icon(Icons.location_searching), subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates),
title: Text(AppLocalizations.of(context)!.sarTemplates), trailing: const Icon(Icons.chevron_right),
subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates), onTap: () {
trailing: const Icon(Icons.chevron_right), Navigator.push(
onTap: () { context,
Navigator.push( MaterialPageRoute(
context, builder: (context) => const SarTemplateManagementScreen(),
MaterialPageRoute(
builder: (context) => const SarTemplateManagementScreen(),
),
);
},
),
ListTile(
leading: const Icon(Icons.school),
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();
},
), ),
), );
);
},
),
const Divider(),
// Network Sharing Section
const ConnectionModeSelector(),
const Divider(),
// Permissions Section
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
ListTile(
leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
subtitle: FutureBuilder<LocationPermission>(
future: Geolocator.checkPermission(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text(AppLocalizations.of(context)!.checking);
}
final permission = snapshot.data!;
String statusText;
Color statusColor;
switch (permission) {
case LocationPermission.always:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedAlways;
statusColor = Colors.green;
break;
case LocationPermission.whileInUse:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedWhileInUse;
statusColor = Colors.green;
break;
case LocationPermission.denied:
statusText = AppLocalizations.of(
context,
)!.locationPermissionDeniedTapToRequest;
statusColor = Colors.orange;
break;
case LocationPermission.deniedForever:
statusText = AppLocalizations.of(
context,
)!.locationPermissionPermanentlyDeniedOpenSettings;
statusColor = Colors.red;
break;
default:
statusText = AppLocalizations.of(context)!.unknown;
statusColor = Colors.grey;
}
return Text(statusText, style: TextStyle(color: statusColor));
}, },
), ),
trailing: const Icon(Icons.chevron_right), ListTile(
onTap: () => _handleLocationPermissionTap(), leading: const Icon(Icons.school),
), title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
const Divider(), trailing: const Icon(Icons.chevron_right),
onTap: () async {
// About Section await Navigator.push(
_buildSectionHeader(AppLocalizations.of(context)!.about), context,
ListTile( MaterialPageRoute(
leading: const Icon(Icons.info), builder: (context) => WelcomeWizardScreen(
title: Text(AppLocalizations.of(context)!.appVersion), onCompleted: () {
subtitle: Text( Navigator.of(context).pop();
_packageInfo != null },
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' ),
: 'Loading...', ),
);
},
), ),
), ]),
// Check for Updates button (Android only)
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
subtitle: FutureBuilder<LocationPermission>(
future: Geolocator.checkPermission(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text(AppLocalizations.of(context)!.checking);
}
final permission = snapshot.data!;
String statusText;
Color statusColor;
switch (permission) {
case LocationPermission.always:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedAlways;
statusColor = Colors.green;
break;
case LocationPermission.whileInUse:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedWhileInUse;
statusColor = Colors.green;
break;
case LocationPermission.denied:
statusText = AppLocalizations.of(
context,
)!.locationPermissionDeniedTapToRequest;
statusColor = Colors.orange;
break;
case LocationPermission.deniedForever:
statusText = AppLocalizations.of(
context,
)!.locationPermissionPermanentlyDeniedOpenSettings;
statusColor = Colors.red;
break;
default:
statusText = AppLocalizations.of(context)!.unknown;
statusColor = Colors.grey;
}
return Text(statusText, style: TextStyle(color: statusColor));
},
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _handleLocationPermissionTap(),
),
]),
_buildSectionHeader(AppLocalizations.of(context)!.about),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.info),
title: Text(AppLocalizations.of(context)!.appVersion),
subtitle: Text(
_packageInfo != null
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...',
),
),
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,78 +1117,67 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
), ),
ListTile( _buildSectionHeader('Developer & Data'),
leading: const Icon(Icons.badge), _buildSettingsCard([
title: Text(AppLocalizations.of(context)!.appName), ListTile(
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'), leading: const Icon(Icons.bug_report),
), title: Text(AppLocalizations.of(context)!.packageName),
ListTile( subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
leading: const Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
), ),
onTap: () => _showAboutDialog(), Padding(
), padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
const Divider(), child: Text(
AppLocalizations.of(context)!.sampleData,
// Developer Section style: Theme.of(context).textTheme.titleSmall,
_buildSectionHeader(AppLocalizations.of(context)!.developer),
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),
child: Text(
AppLocalizations.of(context)!.sampleDataDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
), ),
), ),
), Padding(
Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text(
child: Row( AppLocalizations.of(context)!.sampleDataDescription,
children: [ style: Theme.of(context).textTheme.bodySmall?.copyWith(
Expanded( color: Theme.of(
child: ElevatedButton.icon( context,
onPressed: _isLoadingSampleData ? null : _loadSampleData, ).colorScheme.onSurface.withValues(alpha: 0.6),
icon: _isLoadingSampleData
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_circle_outline),
label: Text(AppLocalizations.of(context)!.loadSampleData),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
), ),
const SizedBox(width: 12), ),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoadingSampleData ? null : _clearSampleData,
icon: const Icon(Icons.delete_outline),
label: Text(AppLocalizations.of(context)!.clearAllData),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
), ),
), Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoadingSampleData ? null : _loadSampleData,
icon: _isLoadingSampleData
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_circle_outline),
label: Text(AppLocalizations.of(context)!.loadSampleData),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoadingSampleData ? null : _clearSampleData,
icon: const Icon(Icons.delete_outline),
label: Text(AppLocalizations.of(context)!.clearAllData),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
]),
], ],
), ),
); );
@@ -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,10 +821,11 @@ 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
contactPublicKey: contact.publicKey, .smartPing(
hasPath: contact.routeHasPath, contactPublicKey: contact.publicKey,
); hasPath: contact.routeHasPath,
);
if (!context.mounted || result.success) { if (!context.mounted || result.success) {
return; return;
@@ -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,25 +279,28 @@ 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(
final telemetryData = CayenneLppParser.createGpsData( 'persists last valid telemetry gps on the contact across reloads',
latitude: 45.0001, () async {
longitude: 13.9999, final telemetryData = CayenneLppParser.createGpsData(
); latitude: 45.0001,
longitude: 13.9999,
);
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData); provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final reloadedProvider = ContactsProvider(); final reloadedProvider = ContactsProvider();
await reloadedProvider.initializeEarly(); await reloadedProvider.initializeEarly();
final reloaded = reloadedProvider.findContactByKey(publicKey)!; final reloaded = reloadedProvider.findContactByKey(publicKey)!;
expect(reloaded.advLat, equals((45.0001 * 1e6).round())); expect(reloaded.advLat, equals((45.0001 * 1e6).round()));
expect(reloaded.advLon, equals((13.9999 * 1e6).round())); expect(reloaded.advLon, equals((13.9999 * 1e6).round()));
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', () {