mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
Fix contact route parse usage
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user