diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index e200322..2072988 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -275,10 +275,10 @@ class ContactsProvider with ChangeNotifier { /// Clear runtime contact state before a live device contact sync begins. /// - /// This intentionally does not touch persisted storage. It keeps any saved - /// contact groups for the active profile, but removes stale in-memory device - /// contacts and discovery state so a newly connected device starts from an - /// empty list while sync is in progress. + /// This intentionally does not touch persisted storage. It snapshots the + /// current in-memory contacts so sync updates can still merge against the + /// latest local state, but keeps the visible list intact so reconnects do + /// not blank the UI while the device is resyncing. Future prepareForDeviceContactSync({Uint8List? devicePublicKey}) async { _setSelfDevicePublicKey(devicePublicKey); if (!_isInitialized) { @@ -292,7 +292,7 @@ class ContactsProvider with ChangeNotifier { } debugPrint( - '🧹 [ContactsProvider] Clearing runtime contacts before device sync', + '🧹 [ContactsProvider] Preparing retained contact state for device sync', ); _retainedContactsForSync ..clear() @@ -301,12 +301,6 @@ class ContactsProvider with ChangeNotifier { (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) diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 86ec616..0330364 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -29,6 +29,31 @@ class SensorMetricOption { }); } +const Set _imperialMeasurementCountries = { + '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 sensorMetricOptionsFor( Contact? contact, { Map labelOverrides = const {}, @@ -795,17 +820,17 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { case 'speed': final metersPerSecond = _previewAsDouble(value); if (metersPerSecond == null) return null; - return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + return _formatPreviewSpeed(metersPerSecond); case 'signed_speed': final metersPerSecond = _previewAsDouble(value); if (metersPerSecond == null) return null; - return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + return _formatPreviewSpeed(metersPerSecond); case 'gust': final metersPerSecond = _previewAsDouble(value); if (metersPerSecond == null) return null; - return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + return _formatPreviewSpeed(metersPerSecond); case 'dew': final degreesCelsius = _previewAsDouble(value); @@ -1066,6 +1091,16 @@ class SensorTelemetryCard extends StatelessWidget { this.labelOverrides = const {}, }); + 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 => onRefresh != null || onCustomize != null || @@ -1759,7 +1794,7 @@ class SensorTelemetryCard extends StatelessWidget { fieldKey: _extraFieldKey(rawKey), icon: Icons.air, label: label, - value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + value: _formatSpeed(metersPerSecond), accent: const Color(0xFF2B78A0), channel: metricKey.channel, ); @@ -1771,7 +1806,7 @@ class SensorTelemetryCard extends StatelessWidget { fieldKey: _extraFieldKey(rawKey), icon: Icons.air, label: label, - value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + value: _formatSpeed(metersPerSecond), accent: const Color(0xFF2B78A0), channel: metricKey.channel, ); @@ -1783,7 +1818,7 @@ class SensorTelemetryCard extends StatelessWidget { fieldKey: _extraFieldKey(rawKey), icon: Icons.air, label: label, - value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + value: _formatSpeed(metersPerSecond), accent: const Color(0xFF1E88A8), channel: metricKey.channel, ); @@ -2252,7 +2287,7 @@ class _InlineAlertBadge extends StatelessWidget { } } -class SensorMetricTile extends StatelessWidget { +class SensorMetricTile extends StatefulWidget { final SensorMetricCardData data; final double width; final String keyPrefix; @@ -2268,8 +2303,43 @@ class SensorMetricTile extends StatelessWidget { this.onLongPress, }); + @override + State createState() => _SensorMetricTileState(); +} + +class _SensorMetricTileState extends State { + 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 _showExpandedMap(BuildContext context) async { - final location = data.mapLocation; + final location = widget.data.mapLocation; if (location == null) return; await Navigator.of(context).push( @@ -2280,9 +2350,9 @@ class SensorMetricTile extends StatelessWidget { title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(data.label), + Text(widget.data.label), Text( - data.value, + widget.data.value, style: Theme.of(pageContext).textTheme.bodySmall, ), ], @@ -2291,11 +2361,11 @@ class SensorMetricTile extends StatelessWidget { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (data.secondaryValue != null) + if (widget.data.secondaryValue != null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Text( - data.secondaryValue!, + widget.data.secondaryValue!, style: Theme.of(pageContext).textTheme.bodyMedium, ), ), @@ -2320,7 +2390,7 @@ class SensorMetricTile extends StatelessWidget { height: 40, child: Icon( Icons.location_on, - color: data.accent, + color: widget.data.accent, size: 34, ), ), @@ -2340,14 +2410,15 @@ class SensorMetricTile extends StatelessWidget { @override Widget build(BuildContext context) { + final data = widget.data; return Material( color: Colors.transparent, child: InkWell( - key: ValueKey('${keyPrefix}_${data.fieldKey}'), + key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'), borderRadius: BorderRadius.circular(22), - onLongPress: onLongPress, + onLongPress: widget.onLongPress, child: Container( - width: width, + width: widget.width, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: data.accent.withValues(alpha: 0.08), @@ -2368,7 +2439,10 @@ class SensorMetricTile extends StatelessWidget { ), const SizedBox(width: 10), 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( children: [ Row( @@ -2396,7 +2470,10 @@ class SensorMetricTile extends StatelessWidget { _MetricIcon(accent: data.accent, icon: data.icon), const SizedBox(width: 10), 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), const SizedBox(width: 10), Expanded( - child: _MetricText(data: data, keyPrefix: keyPrefix), + child: _MetricText( + data: data, + keyPrefix: widget.keyPrefix, + ), ), ], ), @@ -2439,12 +2519,13 @@ class SensorMetricTile extends StatelessWidget { child: SizedBox( height: 104, width: double.infinity, - child: Stack( - children: [ - flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCenter: data.mapLocation!, - initialZoom: 14, + child: Stack( + children: [ + flutter_map.FlutterMap( + mapController: _previewMapController, + options: flutter_map.MapOptions( + initialCenter: data.mapLocation!, + initialZoom: 14, interactionOptions: const flutter_map.InteractionOptions( flags: diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 475bc63..ba56624 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -893,7 +893,7 @@ void main() { }); 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 { final key = createPublicKey(140); final pendingKey = createPublicKey(180); @@ -906,8 +906,11 @@ void main() { await provider.prepareForDeviceContactSync(); - expect(provider.chatContacts, isEmpty); - expect(provider.pendingAdverts, isEmpty); + expect( + provider.chatContacts.map((contact) => contact.advName), + contains('Synced Later'), + ); + expect(provider.pendingAdverts, hasLength(1)); expect(provider.savedGroupsForSection('teamMembers'), hasLength(1)); final restored = ContactsProvider(); diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart index 9942cc4..9c9f2a6 100644 --- a/test/widgets/sensor_telemetry_card_test.dart +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.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:latlong2/latlong.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart'; @@ -140,6 +142,8 @@ void main() { }); testWidgets('renders MeshCore custom weather metrics', (tester) async { + tester.platformDispatcher.localeTestValue = const Locale('sl', 'SI'); + addTearDown(tester.platformDispatcher.clearLocaleTestValue); final publicKey = Uint8List(32); publicKey[0] = 0x46; final contact = Contact( @@ -185,11 +189,130 @@ void main() { expect(find.text('Wind gust'), findsOneWidget); expect(find.text('Dew point'), 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('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( + 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( + 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 { tester.view.physicalSize = const Size(1600, 2600); tester.view.devicePixelRatio = 1.0;