From 4010680da05b8498e8375c2a2850392cc220f72b Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 7 Mar 2026 18:36:38 +0100 Subject: [PATCH] Fix contact route parse usage --- lib/providers/contacts_provider.dart | 84 ++- lib/screens/settings_screen.dart | 792 +++++++++++---------- lib/widgets/contacts/contact_tile.dart | 189 +++-- test/providers/contacts_provider_test.dart | 81 ++- 4 files changed, 645 insertions(+), 501 deletions(-) diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index f657cb0..225beb4 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -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(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e3e82c9..2c5f374 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -722,121 +722,131 @@ class _SettingsScreenState extends State { 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), - ListTile( - leading: const Icon(Icons.palette), - title: Text(AppLocalizations.of(context)!.theme), - subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showThemeDialog(), - ), - SwitchListTile( - secondary: const Icon(Icons.radar), - title: Text(AppLocalizations.of(context)!.showRxTxIndicators), - subtitle: Text(AppLocalizations.of(context)!.displayPacketActivity), - value: _showRxTxIndicators, - onChanged: (value) async { - setState(() { - _showRxTxIndicators = value; - }); - await _saveRxTxPreference(value); - }, - ), - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: const Icon(Icons.visibility_off), - title: Text(AppLocalizations.of(context)!.simpleMode), + _buildSectionHeader('Appearance'), + _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.palette), + title: Text(AppLocalizations.of(context)!.theme), + subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)), + 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)!.simpleModeDescription, + AppLocalizations.of(context)!.displayPacketActivity, ), - value: appProvider.isSimpleMode, + value: _showRxTxIndicators, onChanged: (value) async { - await appProvider.toggleSimpleMode(value); + setState(() { + _showRxTxIndicators = value; + }); + await _saveRxTxPreference(value); }, ), - ), - Consumer( - 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( - builder: (context, appProvider, child) => SwitchListTile( - secondary: const Icon(Icons.map_outlined), - title: Text(AppLocalizations.of(context)!.disableMap), - subtitle: Text( - AppLocalizations.of(context)!.disableMapDescription, - ), - value: !appProvider.isMapEnabled, - onChanged: (value) async { - await appProvider.toggleMapEnabled(!value); - }, - ), - ), - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: const Icon(Icons.contacts_outlined), - title: const Text('Disable Contacts'), - subtitle: const Text( - 'Hide the contacts tab to simplify navigation', - ), - value: !appProvider.isContactsEnabled, - onChanged: (value) async { - await appProvider.toggleContactsEnabled(!value); - }, - ), - ), - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: const Icon(Icons.sensors), - title: const Text('Enable Sensors tab'), - subtitle: const Text( - 'Show a dedicated tab for watched relay and node telemetry', - ), - 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(), - ), - ListTile( - leading: const Icon(Icons.alt_route), - title: const Text('Route path byte size'), - subtitle: Text( - '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes', - ), - 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, - ), - const Divider(), + ]), + + _buildSectionHeader('Navigation'), + _buildSettingsCard([ + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: Text(AppLocalizations.of(context)!.simpleMode), + subtitle: Text( + AppLocalizations.of(context)!.simpleModeDescription, + ), + value: appProvider.isSimpleMode, + onChanged: (value) async { + await appProvider.toggleSimpleMode(value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.map_outlined), + title: Text(AppLocalizations.of(context)!.disableMap), + subtitle: Text( + AppLocalizations.of(context)!.disableMapDescription, + ), + value: !appProvider.isMapEnabled, + onChanged: (value) async { + await appProvider.toggleMapEnabled(!value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.contacts_outlined), + title: const Text('Disable Contacts'), + subtitle: const Text( + 'Hide the contacts tab to simplify navigation', + ), + value: !appProvider.isContactsEnabled, + onChanged: (value) async { + await appProvider.toggleContactsEnabled(!value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.sensors), + title: const Text('Enable Sensors tab'), + subtitle: const Text( + 'Show a dedicated tab for watched relay and node telemetry', + ), + value: appProvider.isSensorsEnabled, + onChanged: (value) async { + await appProvider.toggleSensorsEnabled(value); + }, + ), + ), + ]), + + _buildSectionHeader('Messaging'), + _buildSettingsCard([ + Consumer( + 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), + title: const Text('Route path byte size'), + subtitle: Text( + '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes', + ), + 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'), Consumer2( builder: (context, appProvider, connectionProvider, child) => @@ -849,232 +859,244 @@ class _SettingsScreenState extends State { silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, ), ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: const Text('Voice bitrate'), - subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), - trailing: const Icon(Icons.chevron_right), - onTap: _showVoiceBitrateDialog, - ), - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: const Icon(Icons.tune), - title: const Text('Band-pass filter voice'), - subtitle: const Text( - 'Keeps speech frequencies and cuts low/high noise', + _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.graphic_eq), + title: const Text('Voice bitrate'), + subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), + trailing: const Icon(Icons.chevron_right), + onTap: _showVoiceBitrateDialog, + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.tune), + title: const Text('Band-pass filter voice'), + 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( - builder: (context, appProvider, child) => SwitchListTile( - secondary: const Icon(Icons.compress), - title: const Text('Voice compressor'), - subtitle: const Text('Balances quiet and loud speech levels'), - value: appProvider.isVoiceCompressorEnabled, - onChanged: (value) async { - await appProvider.toggleVoiceCompressorEnabled(value); - }, - ), - ), - Consumer( - 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( - 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', + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.compress), + title: const Text('Voice compressor'), + subtitle: const Text('Balances quiet and loud speech levels'), + value: appProvider.isVoiceCompressorEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceCompressorEnabled(value); + }, ), - value: appProvider.isVoiceSilenceTrimmingEnabled, - onChanged: (value) async { - await appProvider.toggleVoiceSilenceTrimmingEnabled(value); - }, ), - ), - const Divider(), + Consumer( + 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( + 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('Image'), - ListTile( - leading: const Icon(Icons.photo_size_select_large), - title: const Text('Max image size'), - subtitle: Text('$_imageMaxSize×$_imageMaxSize px'), - trailing: const Icon(Icons.chevron_right), - 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()), + _buildSectionHeader('Images'), + _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.photo_size_select_large), + title: const Text('Max image size'), + subtitle: Text('$_imageMaxSize×$_imageMaxSize px'), + trailing: const Icon(Icons.chevron_right), + onTap: _showImageMaxSizeDialog, ), - ), - SwitchListTile( - secondary: const Icon(Icons.invert_colors), - title: const Text('Grayscale'), - subtitle: const Text( - 'Converts image to grayscale for smaller file size', + ListTile( + leading: const Icon(Icons.tune), + title: const Text('Image compression'), + subtitle: Text( + '$_imageCompression / 90 (higher = smaller file)', + ), ), - 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', + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Slider( + value: _imageCompression.toDouble(), + min: 10, + max: 90, + divisions: 8, + label: '$_imageCompression', + onChanged: (v) => setState(() => _imageCompression = v.round()), + onChangeEnd: (v) => _saveImageCompression(v.round()), + ), ), - value: _imageUltraMode, - onChanged: (value) async { - await ImagePreferences.setUltraMode(value); - setState(() { - _imageUltraMode = value; - }); - await _refreshImageModePreview(); - }, - ), + SwitchListTile( + secondary: const Icon(Icons.invert_colors), + title: const Text('Grayscale'), + subtitle: const Text( + 'Converts image to grayscale for smaller file size', + ), + 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( builder: (context, connectionProvider, child) => _buildImageModePreviewCard(connectionProvider), ), - const Divider(), - - // Templates Section - _buildSectionHeader('Templates'), - ListTile( - leading: const Icon(Icons.location_searching), - title: Text(AppLocalizations.of(context)!.sarTemplates), - subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - 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(); - }, + _buildSectionHeader('Templates & Help'), + _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.location_searching), + title: Text(AppLocalizations.of(context)!.sarTemplates), + subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SarTemplateManagementScreen(), ), - ), - ); - }, - ), - 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( - 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(), - ), - const Divider(), - - // About Section - _buildSectionHeader(AppLocalizations.of(context)!.about), - 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.school), + title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WelcomeWizardScreen( + onCompleted: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, ), - ), - // 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( + 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) 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,78 +1117,67 @@ class _SettingsScreenState extends State { ), ), ), - 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], + _buildSectionHeader('Developer & Data'), + _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.bug_report), + title: Text(AppLocalizations.of(context)!.packageName), + subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'), ), - onTap: () => _showAboutDialog(), - ), - const Divider(), - - // Developer Section - _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: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + AppLocalizations.of(context)!.sampleData, + style: Theme.of(context).textTheme.titleSmall, ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - 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), - ), - ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + AppLocalizations.of(context)!.sampleDataDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), ), - 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 { 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 { ); } + Widget _buildSettingsCard(List 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'; diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 884729c..285f6a6 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -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,10 +821,11 @@ class ContactTile extends StatelessWidget { : () async { final connectionProvider = context .read(); - final result = await connectionProvider.smartPing( - contactPublicKey: contact.publicKey, - hasPath: contact.routeHasPath, - ); + final result = await connectionProvider + .smartPing( + contactPublicKey: contact.publicKey, + hasPath: contact.routeHasPath, + ); if (!context.mounted || result.success) { 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, + ), + ), + ], + ), + ); + } } diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 48e2300..d2c5107 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -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.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,25 +279,28 @@ void main() { expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); }); - test('persists last valid telemetry gps on the contact across reloads', () async { - final telemetryData = CayenneLppParser.createGpsData( - latitude: 45.0001, - longitude: 13.9999, - ); + test( + 'persists last valid telemetry gps on the contact across reloads', + () async { + final telemetryData = CayenneLppParser.createGpsData( + latitude: 45.0001, + longitude: 13.9999, + ); - provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData); - await Future.delayed(Duration.zero); + provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData); + await Future.delayed(Duration.zero); - final reloadedProvider = ContactsProvider(); - await reloadedProvider.initializeEarly(); + final reloadedProvider = ContactsProvider(); + await reloadedProvider.initializeEarly(); - final reloaded = reloadedProvider.findContactByKey(publicKey)!; - expect(reloaded.advLat, equals((45.0001 * 1e6).round())); - expect(reloaded.advLon, equals((13.9999 * 1e6).round())); - expect(reloaded.advertLocation, isNotNull); - expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001)); - expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001)); - }); + final reloaded = reloadedProvider.findContactByKey(publicKey)!; + expect(reloaded.advLat, equals((45.0001 * 1e6).round())); + expect(reloaded.advLon, equals((13.9999 * 1e6).round())); + 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', () {