fix: Preserve contacts and sensor updates

This commit is contained in:
Janez T
2026-04-01 13:48:40 +02:00
parent 043faea06a
commit 3dfdbd7700
4 changed files with 242 additions and 41 deletions

View File

@@ -275,10 +275,10 @@ class ContactsProvider with ChangeNotifier {
/// Clear runtime contact state before a live device contact sync begins. /// Clear runtime contact state before a live device contact sync begins.
/// ///
/// This intentionally does not touch persisted storage. It keeps any saved /// This intentionally does not touch persisted storage. It snapshots the
/// contact groups for the active profile, but removes stale in-memory device /// current in-memory contacts so sync updates can still merge against the
/// contacts and discovery state so a newly connected device starts from an /// latest local state, but keeps the visible list intact so reconnects do
/// empty list while sync is in progress. /// not blank the UI while the device is resyncing.
Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async { Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey); _setSelfDevicePublicKey(devicePublicKey);
if (!_isInitialized) { if (!_isInitialized) {
@@ -292,7 +292,7 @@ class ContactsProvider with ChangeNotifier {
} }
debugPrint( debugPrint(
'🧹 [ContactsProvider] Clearing runtime contacts before device sync', '🧹 [ContactsProvider] Preparing retained contact state for device sync',
); );
_retainedContactsForSync _retainedContactsForSync
..clear() ..clear()
@@ -301,12 +301,6 @@ class ContactsProvider with ChangeNotifier {
(contact) => MapEntry(contact.publicKeyHex, contact), (contact) => MapEntry(contact.publicKeyHex, contact),
), ),
); );
_contacts.clear();
_pendingAdverts.clear();
_estimatedLocations.clear();
_rssiObservations.clear();
_ensurePublicChannelExists();
notifyListeners();
} }
/// Remove self-contact from loaded contacts (called after BLE connection established) /// Remove self-contact from loaded contacts (called after BLE connection established)

View File

@@ -29,6 +29,31 @@ class SensorMetricOption {
}); });
} }
const Set<String> _imperialMeasurementCountries = <String>{
'US',
'LR',
'MM',
};
bool _usesImperialSpeedUnits() {
final countryCode =
WidgetsBinding.instance.platformDispatcher.locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return false;
}
return _imperialMeasurementCountries.contains(countryCode.toUpperCase());
}
String _formatPreviewSpeed(num metersPerSecond) {
if (_usesImperialSpeedUnits()) {
final milesPerHour = metersPerSecond * 2.2369362920544;
return '${_formatPreviewNumber(milesPerHour, maxFractionDigits: 2)} mph';
}
final kilometersPerHour = metersPerSecond * 3.6;
return '${_formatPreviewNumber(kilometersPerHour, maxFractionDigits: 2)} km/h';
}
List<SensorMetricOption> sensorMetricOptionsFor( List<SensorMetricOption> sensorMetricOptionsFor(
Contact? contact, { Contact? contact, {
Map<String, String> labelOverrides = const <String, String>{}, Map<String, String> labelOverrides = const <String, String>{},
@@ -795,17 +820,17 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) {
case 'speed': case 'speed':
final metersPerSecond = _previewAsDouble(value); final metersPerSecond = _previewAsDouble(value);
if (metersPerSecond == null) return null; if (metersPerSecond == null) return null;
return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; return _formatPreviewSpeed(metersPerSecond);
case 'signed_speed': case 'signed_speed':
final metersPerSecond = _previewAsDouble(value); final metersPerSecond = _previewAsDouble(value);
if (metersPerSecond == null) return null; if (metersPerSecond == null) return null;
return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; return _formatPreviewSpeed(metersPerSecond);
case 'gust': case 'gust':
final metersPerSecond = _previewAsDouble(value); final metersPerSecond = _previewAsDouble(value);
if (metersPerSecond == null) return null; if (metersPerSecond == null) return null;
return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; return _formatPreviewSpeed(metersPerSecond);
case 'dew': case 'dew':
final degreesCelsius = _previewAsDouble(value); final degreesCelsius = _previewAsDouble(value);
@@ -1066,6 +1091,16 @@ class SensorTelemetryCard extends StatelessWidget {
this.labelOverrides = const <String, String>{}, this.labelOverrides = const <String, String>{},
}); });
String _formatSpeed(num metersPerSecond) {
if (_usesImperialSpeedUnits()) {
final milesPerHour = metersPerSecond * 2.2369362920544;
return '${_formatNumber(milesPerHour, maxFractionDigits: 2)} mph';
}
final kilometersPerHour = metersPerSecond * 3.6;
return '${_formatNumber(kilometersPerHour, maxFractionDigits: 2)} km/h';
}
bool get _showsMenu => bool get _showsMenu =>
onRefresh != null || onRefresh != null ||
onCustomize != null || onCustomize != null ||
@@ -1759,7 +1794,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.air, icon: Icons.air,
label: label, label: label,
value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', value: _formatSpeed(metersPerSecond),
accent: const Color(0xFF2B78A0), accent: const Color(0xFF2B78A0),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -1771,7 +1806,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.air, icon: Icons.air,
label: label, label: label,
value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', value: _formatSpeed(metersPerSecond),
accent: const Color(0xFF2B78A0), accent: const Color(0xFF2B78A0),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -1783,7 +1818,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.air, icon: Icons.air,
label: label, label: label,
value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', value: _formatSpeed(metersPerSecond),
accent: const Color(0xFF1E88A8), accent: const Color(0xFF1E88A8),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -2252,7 +2287,7 @@ class _InlineAlertBadge extends StatelessWidget {
} }
} }
class SensorMetricTile extends StatelessWidget { class SensorMetricTile extends StatefulWidget {
final SensorMetricCardData data; final SensorMetricCardData data;
final double width; final double width;
final String keyPrefix; final String keyPrefix;
@@ -2268,8 +2303,43 @@ class SensorMetricTile extends StatelessWidget {
this.onLongPress, this.onLongPress,
}); });
@override
State<SensorMetricTile> createState() => _SensorMetricTileState();
}
class _SensorMetricTileState extends State<SensorMetricTile> {
final flutter_map.MapController _previewMapController =
flutter_map.MapController();
@override
void didUpdateWidget(covariant SensorMetricTile oldWidget) {
super.didUpdateWidget(oldWidget);
final previousLocation = oldWidget.data.mapLocation;
final nextLocation = widget.data.mapLocation;
if (!_sameMapLocation(previousLocation, nextLocation) &&
nextLocation != null &&
widget.allowMapPreview) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_previewMapController.move(
nextLocation,
_previewMapController.camera.zoom,
);
});
}
}
bool _sameMapLocation(LatLng? a, LatLng? b) {
if (a == null || b == null) {
return a == b;
}
return a.latitude == b.latitude && a.longitude == b.longitude;
}
Future<void> _showExpandedMap(BuildContext context) async { Future<void> _showExpandedMap(BuildContext context) async {
final location = data.mapLocation; final location = widget.data.mapLocation;
if (location == null) return; if (location == null) return;
await Navigator.of(context).push( await Navigator.of(context).push(
@@ -2280,9 +2350,9 @@ class SensorMetricTile extends StatelessWidget {
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(data.label), Text(widget.data.label),
Text( Text(
data.value, widget.data.value,
style: Theme.of(pageContext).textTheme.bodySmall, style: Theme.of(pageContext).textTheme.bodySmall,
), ),
], ],
@@ -2291,11 +2361,11 @@ class SensorMetricTile extends StatelessWidget {
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (data.secondaryValue != null) if (widget.data.secondaryValue != null)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Text( child: Text(
data.secondaryValue!, widget.data.secondaryValue!,
style: Theme.of(pageContext).textTheme.bodyMedium, style: Theme.of(pageContext).textTheme.bodyMedium,
), ),
), ),
@@ -2320,7 +2390,7 @@ class SensorMetricTile extends StatelessWidget {
height: 40, height: 40,
child: Icon( child: Icon(
Icons.location_on, Icons.location_on,
color: data.accent, color: widget.data.accent,
size: 34, size: 34,
), ),
), ),
@@ -2340,14 +2410,15 @@ class SensorMetricTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = widget.data;
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
key: ValueKey('${keyPrefix}_${data.fieldKey}'), key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
onLongPress: onLongPress, onLongPress: widget.onLongPress,
child: Container( child: Container(
width: width, width: widget.width,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.08), color: data.accent.withValues(alpha: 0.08),
@@ -2368,7 +2439,10 @@ class SensorMetricTile extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix), child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
), ),
], ],
), ),
@@ -2387,7 +2461,7 @@ class SensorMetricTile extends StatelessWidget {
), ),
], ],
) )
: data.mapLocation == null || !allowMapPreview : data.mapLocation == null || !widget.allowMapPreview
? Stack( ? Stack(
children: [ children: [
Row( Row(
@@ -2396,7 +2470,10 @@ class SensorMetricTile extends StatelessWidget {
_MetricIcon(accent: data.accent, icon: data.icon), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix), child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
), ),
], ],
), ),
@@ -2424,7 +2501,10 @@ class SensorMetricTile extends StatelessWidget {
_MetricIcon(accent: data.accent, icon: data.icon), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix), child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
), ),
], ],
), ),
@@ -2442,6 +2522,7 @@ class SensorMetricTile extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
flutter_map.FlutterMap( flutter_map.FlutterMap(
mapController: _previewMapController,
options: flutter_map.MapOptions( options: flutter_map.MapOptions(
initialCenter: data.mapLocation!, initialCenter: data.mapLocation!,
initialZoom: 14, initialZoom: 14,

View File

@@ -893,7 +893,7 @@ void main() {
}); });
test( test(
'clears runtime contacts before sync without erasing persisted contacts or saved groups', 'keeps runtime contacts visible during sync without erasing persisted contacts or saved groups',
() async { () async {
final key = createPublicKey(140); final key = createPublicKey(140);
final pendingKey = createPublicKey(180); final pendingKey = createPublicKey(180);
@@ -906,8 +906,11 @@ void main() {
await provider.prepareForDeviceContactSync(); await provider.prepareForDeviceContactSync();
expect(provider.chatContacts, isEmpty); expect(
expect(provider.pendingAdverts, isEmpty); provider.chatContacts.map((contact) => contact.advName),
contains('Synced Later'),
);
expect(provider.pendingAdverts, hasLength(1));
expect(provider.savedGroupsForSection('teamMembers'), hasLength(1)); expect(provider.savedGroupsForSection('teamMembers'), hasLength(1));
final restored = ContactsProvider(); final restored = ContactsProvider();

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart';
@@ -140,6 +142,8 @@ void main() {
}); });
testWidgets('renders MeshCore custom weather metrics', (tester) async { testWidgets('renders MeshCore custom weather metrics', (tester) async {
tester.platformDispatcher.localeTestValue = const Locale('sl', 'SI');
addTearDown(tester.platformDispatcher.clearLocaleTestValue);
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[0] = 0x46; publicKey[0] = 0x46;
final contact = Contact( final contact = Contact(
@@ -185,11 +189,130 @@ void main() {
expect(find.text('Wind gust'), findsOneWidget); expect(find.text('Wind gust'), findsOneWidget);
expect(find.text('Dew point'), findsOneWidget); expect(find.text('Dew point'), findsOneWidget);
expect(find.text('Rain'), findsOneWidget); expect(find.text('Rain'), findsOneWidget);
expect(find.text('3.7 m/s'), findsOneWidget); expect(find.textContaining('km/h'), findsOneWidget);
expect(find.textContaining('m/s'), findsNothing);
expect(find.text('2°C'), findsOneWidget); expect(find.text('2°C'), findsOneWidget);
expect(find.text('12.3 mm'), findsOneWidget); expect(find.text('12.3 mm'), findsOneWidget);
}); });
testWidgets('renders speed in mph for imperial system locale', (
tester,
) async {
tester.platformDispatcher.localeTestValue = const Locale('en', 'US');
addTearDown(tester.platformDispatcher.clearLocaleTestValue);
final publicKey = Uint8List(32);
publicKey[0] = 0x4A;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'speed_2': 3.7},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'extra:speed_2'},
fieldSpans: sensorFullWidthFieldSpans(const {'extra:speed_2'}),
),
),
),
);
expect(find.textContaining('mph'), findsOneWidget);
expect(find.textContaining('m/s'), findsNothing);
});
testWidgets('gps preview map recenters when telemetry location changes', (
tester,
) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x4B;
Contact buildGpsContact(double latitude, double longitude) => Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'GPS Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
gpsLocation: LatLng(latitude, longitude),
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
var contact = buildGpsContact(46.0569, 14.5058);
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Column(
children: [
ElevatedButton(
onPressed: () {
setState(() {
contact = buildGpsContact(46.1000, 14.6000);
});
},
child: const Text('Update'),
),
SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'gps'},
fieldSpans: sensorFullWidthFieldSpans(const {'gps'}),
),
],
),
),
);
},
),
);
final initialMap = tester.widget<flutter_map.FlutterMap>(
find.byType(flutter_map.FlutterMap).first,
);
final initialCenter = initialMap.mapController!.camera.center;
expect(initialCenter.latitude, closeTo(46.0569, 0.0001));
expect(initialCenter.longitude, closeTo(14.5058, 0.0001));
await tester.tap(find.text('Update'));
await tester.pump();
final updatedMap = tester.widget<flutter_map.FlutterMap>(
find.byType(flutter_map.FlutterMap).first,
);
final updatedCenter = updatedMap.mapController!.camera.center;
expect(updatedCenter.latitude, closeTo(46.1000, 0.0001));
expect(updatedCenter.longitude, closeTo(14.6000, 0.0001));
});
testWidgets('renders generic percentage separately from UV for weather payload', (tester) async { testWidgets('renders generic percentage separately from UV for weather payload', (tester) async {
tester.view.physicalSize = const Size(1600, 2600); tester.view.physicalSize = const Size(1600, 2600);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;