diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e0378fc..f8d79fc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e28e34f..e36755b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 98 + 100 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index fba986a..fed4b04 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,17 @@ - + - + - - - - - - + diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 225beb4..0813545 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -416,19 +416,20 @@ class ContactsProvider with ChangeNotifier { 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 mergedTelemetry = _mergeTelemetryForContact( - existingTelemetry: previousTelemetry, - incomingTelemetry: telemetry, - ); - if (mergedTelemetry != null) { - telemetry = mergedTelemetry; + final mergedTelemetry = _mergeTelemetryForContact( + existingTelemetry: previousTelemetry, + incomingTelemetry: telemetry, + ); + if (mergedTelemetry != null) { + if (_shouldRetainLastValidGps( + previousTelemetry, + telemetry.gpsLocation, + )) { + debugPrint( + ' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps', + ); } + telemetry = mergedTelemetry; } // Update contact with new telemetry AND last seen time @@ -504,24 +505,28 @@ class ContactsProvider with ChangeNotifier { } final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation); - if (incomingGps != null) { - return incomingTelemetry; - } - final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation); - if (previousGps == null) { - return incomingTelemetry; - } + final mergedExtraSensorData = { + ...?existingTelemetry?.extraSensorData, + ...?incomingTelemetry.extraSensorData, + }; return ContactTelemetry( - gpsLocation: previousGps, - batteryPercentage: incomingTelemetry.batteryPercentage, - batteryMilliVolts: incomingTelemetry.batteryMilliVolts, - temperature: incomingTelemetry.temperature, + gpsLocation: incomingGps ?? previousGps, + batteryPercentage: + incomingTelemetry.batteryPercentage ?? + existingTelemetry?.batteryPercentage, + batteryMilliVolts: + incomingTelemetry.batteryMilliVolts ?? + existingTelemetry?.batteryMilliVolts, + temperature: + incomingTelemetry.temperature ?? existingTelemetry?.temperature, timestamp: incomingTelemetry.timestamp, - humidity: incomingTelemetry.humidity, - pressure: incomingTelemetry.pressure, - extraSensorData: incomingTelemetry.extraSensorData, + humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity, + pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure, + extraSensorData: mergedExtraSensorData.isEmpty + ? null + : mergedExtraSensorData, ); } diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 95dacfc..87ffa87 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -10,36 +10,26 @@ import 'contacts_provider.dart'; enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } -enum SensorMetric { - lastSeen, - voltage, - battery, - temperature, - humidity, - pressure, - gps, - updated, -} - class SensorsProvider with ChangeNotifier { static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; - static const Set _defaultVisibleMetrics = { - SensorMetric.lastSeen, - SensorMetric.voltage, - SensorMetric.battery, - SensorMetric.temperature, - SensorMetric.humidity, - SensorMetric.pressure, - SensorMetric.gps, - SensorMetric.updated, + static const String _fieldSpanKey = 'sensor_field_spans'; + static const Set _defaultVisibleFields = { + 'voltage', + 'battery', + 'temperature', + 'humidity', + 'pressure', + 'gps', }; final List _watchedSensorKeys = []; final Map _refreshStates = {}; - final Map> _visibleMetricsBySensor = - >{}; + final Map> _visibleFieldsBySensor = + >{}; + final Map> _fieldSpansBySensor = + >{}; bool _isLoaded = false; bool _isRefreshingAll = false; @@ -59,25 +49,33 @@ class SensorsProvider with ChangeNotifier { final prefs = await SharedPreferences.getInstance(); final stored = prefs.getStringList(_watchedSensorsKey) ?? []; final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey); + final storedSpansJson = prefs.getString(_fieldSpanKey); _watchedSensorKeys ..clear() ..addAll(stored); - _visibleMetricsBySensor.clear(); + _visibleFieldsBySensor.clear(); + _fieldSpansBySensor.clear(); if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) { final decoded = jsonDecode(storedMetricsJson) as Map; for (final entry in decoded.entries) { - final metricNames = (entry.value as List).cast(); - _visibleMetricsBySensor[entry.key] = metricNames - .map(_metricFromName) - .whereType() + _visibleFieldsBySensor[entry.key] = (entry.value as List) + .cast() .toSet(); } } + if (storedSpansJson != null && storedSpansJson.isNotEmpty) { + final decoded = jsonDecode(storedSpansJson) as Map; + for (final entry in decoded.entries) { + _fieldSpansBySensor[entry.key] = (entry.value as Map) + .map((key, value) => MapEntry(key, value as int)); + } + } for (final key in _watchedSensorKeys) { - _visibleMetricsBySensor.putIfAbsent( + _visibleFieldsBySensor.putIfAbsent( key, - () => Set.from(_defaultVisibleMetrics), + () => Set.from(_defaultVisibleFields), ); + _fieldSpansBySensor.putIfAbsent(key, () => {}); } } catch (e) { debugPrint('Error loading watched sensors: $e'); @@ -100,8 +98,8 @@ class SensorsProvider with ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); final encoded = >{}; - for (final entry in _visibleMetricsBySensor.entries) { - encoded[entry.key] = entry.value.map((metric) => metric.name).toList(); + for (final entry in _visibleFieldsBySensor.entries) { + encoded[entry.key] = entry.value.toList(); } await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded)); } catch (e) { @@ -109,35 +107,63 @@ class SensorsProvider with ChangeNotifier { } } - Set visibleMetricsFor(String publicKeyHex) => - Set.unmodifiable( - _visibleMetricsBySensor[publicKeyHex] ?? _defaultVisibleMetrics, - ); + Future _persistFieldSpans() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_fieldSpanKey, jsonEncode(_fieldSpansBySensor)); + } catch (e) { + debugPrint('Error saving sensor field spans: $e'); + } + } - bool showsMetric(String publicKeyHex, SensorMetric metric) => - visibleMetricsFor(publicKeyHex).contains(metric); + Set visibleFieldsFor(String publicKeyHex) => Set.unmodifiable( + _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields, + ); + + bool showsField(String publicKeyHex, String fieldKey) => + visibleFieldsFor(publicKeyHex).contains(fieldKey); + + int fieldSpanFor(String publicKeyHex, String fieldKey) { + final sensorSpans = _fieldSpansBySensor[publicKeyHex]; + final span = sensorSpans?[fieldKey] ?? 1; + return span == 2 ? 2 : 1; + } Future toggleMetric( String publicKeyHex, - SensorMetric metric, + String fieldKey, bool visible, ) async { - final visibleMetrics = _visibleMetricsBySensor.putIfAbsent( + final visibleFields = _visibleFieldsBySensor.putIfAbsent( publicKeyHex, - () => Set.from(_defaultVisibleMetrics), + () => Set.from(_defaultVisibleFields), ); if (visible) { - visibleMetrics.add(metric); + visibleFields.add(fieldKey); } else { - if (visibleMetrics.length == 1 && visibleMetrics.contains(metric)) { + if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) { return; } - visibleMetrics.remove(metric); + visibleFields.remove(fieldKey); } await _persistVisibleMetrics(); notifyListeners(); } + Future setFieldSpan( + String publicKeyHex, + String fieldKey, + int span, + ) async { + final sensorSpans = _fieldSpansBySensor.putIfAbsent( + publicKeyHex, + () => {}, + ); + sensorSpans[fieldKey] = span == 2 ? 2 : 1; + await _persistFieldSpans(); + notifyListeners(); + } + bool isWatched(String publicKeyHex) => _watchedSensorKeys.contains(publicKeyHex); @@ -151,19 +177,23 @@ class SensorsProvider with ChangeNotifier { _watchedSensorKeys.add(contact.publicKeyHex); await _persistWatchedSensors(); - _visibleMetricsBySensor[contact.publicKeyHex] = Set.from( - _defaultVisibleMetrics, + _visibleFieldsBySensor[contact.publicKeyHex] = Set.from( + _defaultVisibleFields, ); + _fieldSpansBySensor[contact.publicKeyHex] = {'gps': 2}; await _persistVisibleMetrics(); + await _persistFieldSpans(); notifyListeners(); } Future removeSensor(String publicKeyHex) async { _watchedSensorKeys.remove(publicKeyHex); _refreshStates.remove(publicKeyHex); - _visibleMetricsBySensor.remove(publicKeyHex); + _visibleFieldsBySensor.remove(publicKeyHex); + _fieldSpansBySensor.remove(publicKeyHex); await _persistWatchedSensors(); await _persistVisibleMetrics(); + await _persistFieldSpans(); notifyListeners(); } @@ -221,13 +251,4 @@ class SensorsProvider with ChangeNotifier { notifyListeners(); } } - - SensorMetric? _metricFromName(String name) { - for (final metric in SensorMetric.values) { - if (metric.name == name) { - return metric; - } - } - return null; - } } diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index dd8ed3e..0dc32b9 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; @@ -6,6 +8,7 @@ import '../models/contact.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/sensors_provider.dart'; +import '../utils/location_formats.dart'; class SensorsTab extends StatelessWidget { const SensorsTab({super.key}); @@ -83,15 +86,15 @@ class SensorsTab extends StatelessWidget { Future _showMetricSelector( BuildContext context, String publicKeyHex, + Contact? contact, ) async { await showModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => Consumer( builder: (context, sensorsProvider, child) { - final visibleMetrics = sensorsProvider.visibleMetricsFor( - publicKeyHex, - ); + final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex); + final options = _fieldOptionsFor(contact); return SafeArea( child: ListView( shrinkWrap: true, @@ -107,24 +110,48 @@ class SensorsTab extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 20), - Wrap( - spacing: 10, - runSpacing: 10, - children: SensorMetric.values.map((metric) { - final visible = visibleMetrics.contains(metric); - return FilterChip( - selected: visible, - label: Text(_metricLabel(metric)), - onSelected: (value) { - sensorsProvider.toggleMetric( - publicKeyHex, - metric, - value, - ); - }, - ); - }).toList(), - ), + ...options.map((option) { + final visible = visibleFields.contains(option.key); + final span = sensorsProvider.fieldSpanFor( + publicKeyHex, + option.key, + ); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: FilterChip( + selected: visible, + label: Text(option.label), + onSelected: (value) { + sensorsProvider.toggleMetric( + publicKeyHex, + option.key, + value, + ); + }, + ), + ), + const SizedBox(width: 10), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('1x')), + ButtonSegment(value: 2, label: Text('2x')), + ], + selected: {span}, + onSelectionChanged: (selection) { + sensorsProvider.setFieldSpan( + publicKeyHex, + option.key, + selection.first, + ); + }, + ), + ], + ), + ); + }), ], ), ); @@ -170,11 +197,18 @@ class SensorsTab extends StatelessWidget { return _SensorCard( contact: contact, state: sensorsProvider.stateFor(key), - visibleMetrics: sensorsProvider.visibleMetricsFor(key), + visibleFields: sensorsProvider.visibleFieldsFor(key), + fieldSpans: { + for (final field in sensorsProvider.visibleFieldsFor( + key, + )) + field: sensorsProvider.fieldSpanFor(key, field), + }, onRemove: () async { await sensorsProvider.removeSensor(key); }, - onCustomize: () => _showMetricSelector(context, key), + onCustomize: () => + _showMetricSelector(context, key, contact), ); }), ], @@ -265,14 +299,16 @@ class _EmptySensorsState extends StatelessWidget { class _SensorCard extends StatelessWidget { final Contact? contact; final SensorRefreshState state; - final Set visibleMetrics; + final Set visibleFields; + final Map fieldSpans; final Future Function() onRemove; final VoidCallback onCustomize; const _SensorCard({ required this.contact, required this.state, - required this.visibleMetrics, + required this.visibleFields, + required this.fieldSpans, required this.onRemove, required this.onCustomize, }); @@ -303,7 +339,7 @@ class _SensorCard extends StatelessWidget { ], ), child: Padding( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -331,11 +367,36 @@ class _SensorCard extends StatelessWidget { ), if (telemetry != null) ...[ const SizedBox(height: 2), - Text( - '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (state == SensorRefreshState.refreshing) + const _InlineStateMeta( + label: 'Refreshing', + color: Color(0xFF266AC2), + spinning: true, + ), + if (state == SensorRefreshState.success) + const _InlineStateMeta( + label: 'Updated', + color: Color(0xFF218B63), + icon: Icons.check_circle, + ), + if (state == SensorRefreshState.unavailable) + const _InlineStateMeta( + label: 'Unavailable', + color: Color(0xFFB13B55), + icon: Icons.error_outline, + ), + ], ), ], ], @@ -363,30 +424,6 @@ class _SensorCard extends StatelessWidget { ], ), const SizedBox(height: 12), - if (((state != SensorRefreshState.idle && - state != SensorRefreshState.timeout) || - state == SensorRefreshState.unavailable) || - (contact != null && - visibleMetrics.contains(SensorMetric.lastSeen))) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Wrap( - spacing: 10, - runSpacing: 10, - children: [ - if (state != SensorRefreshState.idle && - state != SensorRefreshState.timeout) - _StatusPill(state: state), - if (contact != null && - visibleMetrics.contains(SensorMetric.lastSeen)) - _InfoPill( - icon: Icons.schedule, - label: - '${l10n.lastSeen}: ${contact!.timeSinceLastSeen}', - ), - ], - ), - ), if (contact == null) const Text( 'This node is no longer available in the contact list.', @@ -398,12 +435,28 @@ class _SensorCard extends StatelessWidget { 'All fields are hidden. Use Visible fields to choose what to show.', ) else - Wrap( - spacing: 12, - runSpacing: 12, - children: metrics - .map((metric) => _MetricTile(data: metric)) - .toList(), + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final compactWidth = (constraints.maxWidth - spacing) / 2; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: metrics + .map( + (metric) => _MetricTile( + data: metric, + width: + (fieldSpans[metric.fieldKey] == 2 || + metric.wide) + ? constraints.maxWidth + : compactWidth, + ), + ) + .toList(), + ); + }, ), ], ), @@ -418,10 +471,11 @@ class _SensorCard extends StatelessWidget { ) { final items = <_MetricCardData>[]; - if (visibleMetrics.contains(SensorMetric.voltage) && + if (visibleFields.contains('voltage') && telemetry.batteryMilliVolts != null) { items.add( _MetricCardData( + fieldKey: 'voltage', icon: Icons.bolt, label: l10n.voltage, value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', @@ -429,10 +483,11 @@ class _SensorCard extends StatelessWidget { ), ); } - if (visibleMetrics.contains(SensorMetric.battery) && + if (visibleFields.contains('battery') && telemetry.batteryPercentage != null) { items.add( _MetricCardData( + fieldKey: 'battery', icon: Icons.battery_5_bar, label: l10n.battery, value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', @@ -440,10 +495,11 @@ class _SensorCard extends StatelessWidget { ), ); } - if (visibleMetrics.contains(SensorMetric.temperature) && + if (visibleFields.contains('temperature') && telemetry.temperature != null) { items.add( _MetricCardData( + fieldKey: 'temperature', icon: Icons.thermostat, label: l10n.temperature, value: '${telemetry.temperature!.toStringAsFixed(1)}°C', @@ -451,10 +507,10 @@ class _SensorCard extends StatelessWidget { ), ); } - if (visibleMetrics.contains(SensorMetric.humidity) && - telemetry.humidity != null) { + if (visibleFields.contains('humidity') && telemetry.humidity != null) { items.add( _MetricCardData( + fieldKey: 'humidity', icon: Icons.water_drop, label: l10n.humidity, value: '${telemetry.humidity!.toStringAsFixed(1)}%', @@ -462,10 +518,10 @@ class _SensorCard extends StatelessWidget { ), ); } - if (visibleMetrics.contains(SensorMetric.pressure) && - telemetry.pressure != null) { + if (visibleFields.contains('pressure') && telemetry.pressure != null) { items.add( _MetricCardData( + fieldKey: 'pressure', icon: Icons.compress, label: l10n.pressure, value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', @@ -473,35 +529,38 @@ class _SensorCard extends StatelessWidget { ), ); } - if (visibleMetrics.contains(SensorMetric.gps) && - telemetry.gpsLocation != null) { + if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { items.add( _MetricCardData( + fieldKey: 'gps', icon: Icons.place, label: l10n.gpsTelemetry, value: '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', accent: const Color(0xFFAA3F57), wide: true, - ), - ); - } - if (visibleMetrics.contains(SensorMetric.updated)) { - items.add( - _MetricCardData( - icon: Icons.update, - label: l10n.updated, - value: _formatTelemetryTime(telemetry.timestamp), - accent: const Color(0xFF6C727F), + mapLocation: LatLng( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), + secondaryValue: formatPlusCode( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), ), ); } if (telemetry.extraSensorData != null) { for (final entry in telemetry.extraSensorData!.entries) { + final fieldKey = _extraFieldKey(entry.key); + if (!visibleFields.contains(fieldKey)) { + continue; + } items.add( _MetricCardData( + fieldKey: fieldKey, icon: Icons.sensors, - label: entry.key, + label: _formatExtraFieldLabel(entry.key), value: '${entry.value}', accent: const Color(0xFF3E657C), ), @@ -531,81 +590,48 @@ class _SensorCard extends StatelessWidget { } } -class _StatusPill extends StatelessWidget { - final SensorRefreshState state; - - const _StatusPill({required this.state}); - - @override - Widget build(BuildContext context) { - final (label, color, icon) = switch (state) { - SensorRefreshState.idle => ( - 'Idle', - const Color(0xFF5D7185), - Icons.sensors, - ), - SensorRefreshState.refreshing => ( - 'Refreshing', - const Color(0xFF266AC2), - Icons.sync, - ), - SensorRefreshState.success => ( - 'Updated', - const Color(0xFF218B63), - Icons.check_circle, - ), - SensorRefreshState.timeout => ( - 'No response', - const Color(0xFFC17B1D), - Icons.schedule, - ), - SensorRefreshState.unavailable => ( - 'Unavailable', - const Color(0xFFB13B55), - Icons.error_outline, - ), - }; - - return _InfoPill( - icon: icon, - label: label, - foreground: color, - background: color.withValues(alpha: 0.10), - ); - } -} - -class _InfoPill extends StatelessWidget { - final IconData icon; +class _InlineStateMeta extends StatelessWidget { final String label; - final Color? foreground; - final Color? background; + final Color color; + final IconData? icon; + final bool spinning; - const _InfoPill({ - required this.icon, + const _InlineStateMeta({ required this.label, - this.foreground, - this.background, + required this.color, + this.icon, + this.spinning = false, }); @override Widget build(BuildContext context) { - final color = foreground ?? Theme.of(context).colorScheme.onSurfaceVariant; return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: - background ?? Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(18), + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 18, color: color), - const SizedBox(width: 8), + if (spinning) + SizedBox( + width: 11, + height: 11, + child: CircularProgressIndicator( + strokeWidth: 1.7, + valueColor: AlwaysStoppedAnimation(color), + ), + ) + else if (icon != null) + Icon(icon, size: 11, color: color), + const SizedBox(width: 4), Text( label, - style: TextStyle(color: color, fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), ), ], ), @@ -639,89 +665,389 @@ class _InlineAlertBadge extends StatelessWidget { class _MetricTile extends StatelessWidget { final _MetricCardData data; + final double width; - const _MetricTile({required this.data}); + const _MetricTile({required this.data, required this.width}); - @override - Widget build(BuildContext context) { - final width = data.wide ? 320.0 : 168.0; + Future _showExpandedMap(BuildContext context) async { + final location = data.mapLocation; + if (location == null) return; - return Container( - width: width, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: data.accent.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(22), - border: Border.all(color: data.accent.withValues(alpha: 0.14)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: data.accent.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(data.icon, color: data.accent, size: 20), - ), - const SizedBox(width: 12), - Expanded( + await showDialog( + context: context, + builder: (dialogContext) { + return Dialog( + insetPadding: const EdgeInsets.all(16), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: 420, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - data.label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: data.accent, - fontWeight: FontWeight.w700, + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + data.value, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (data.secondaryValue != null) + Text( + data.secondaryValue!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(dialogContext).pop(), + icon: const Icon(Icons.close), + ), + ], ), ), - const SizedBox(height: 6), - Text( - data.value, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - height: 1.1, + Expanded( + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: location, + initialZoom: 15, + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: location, + width: 40, + height: 40, + child: Icon( + Icons.location_on, + color: data.accent, + size: 34, + ), + ), + ], + ), + ], ), ), ], ), ), - ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Container( + width: width, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: data.accent.withValues(alpha: 0.14)), ), + child: data.mapLocation == null + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded(child: _MetricText(data: data)), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded(child: _MetricText(data: data)), + ], + ), + const SizedBox(height: 10), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => _showExpandedMap(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + height: 104, + width: double.infinity, + child: Stack( + children: [ + flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: data.mapLocation!, + initialZoom: 14, + interactionOptions: + const flutter_map.InteractionOptions( + flags: flutter_map.InteractiveFlag.none, + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: data.mapLocation!, + width: 32, + height: 32, + child: Icon( + Icons.location_on, + color: data.accent, + size: 28, + ), + ), + ], + ), + ], + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_full, + size: 12, + color: Colors.white, + ), + SizedBox(width: 4), + Text( + 'Open map', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _MetricIcon extends StatelessWidget { + final Color accent; + final IconData icon; + + const _MetricIcon({required this.accent, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: accent, size: 18), + ); + } +} + +class _MetricText extends StatelessWidget { + final _MetricCardData data; + + const _MetricText({required this.data}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + if (data.secondaryValue != null) ...[ + const SizedBox(height: 4), + Text( + data.secondaryValue!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], ); } } class _MetricCardData { + final String fieldKey; final IconData icon; final String label; final String value; + final String? secondaryValue; final Color accent; final bool wide; + final LatLng? mapLocation; const _MetricCardData({ + required this.fieldKey, required this.icon, required this.label, required this.value, + this.secondaryValue, required this.accent, this.wide = false, + this.mapLocation, }); } -String _metricLabel(SensorMetric metric) { - return switch (metric) { - SensorMetric.lastSeen => 'Last seen', - SensorMetric.voltage => 'Voltage', - SensorMetric.battery => 'Battery', - SensorMetric.temperature => 'Temperature', - SensorMetric.humidity => 'Humidity', - SensorMetric.pressure => 'Pressure', - SensorMetric.gps => 'GPS', - SensorMetric.updated => 'Updated', +class _FieldOption { + final String key; + final String label; + + const _FieldOption({required this.key, required this.label}); +} + +List<_FieldOption> _fieldOptionsFor(Contact? contact) { + final telemetry = contact?.telemetry; + final options = <_FieldOption>[ + if (telemetry?.batteryMilliVolts != null) + const _FieldOption(key: 'voltage', label: 'Voltage'), + if (telemetry?.batteryPercentage != null) + const _FieldOption(key: 'battery', label: 'Battery'), + if (telemetry?.temperature != null) + const _FieldOption(key: 'temperature', label: 'Temperature'), + if (telemetry?.humidity != null) + const _FieldOption(key: 'humidity', label: 'Humidity'), + if (telemetry?.pressure != null) + const _FieldOption(key: 'pressure', label: 'Pressure'), + if (telemetry?.gpsLocation != null) + const _FieldOption(key: 'gps', label: 'GPS'), + ]; + + final extraSensorData = telemetry?.extraSensorData; + if (extraSensorData != null) { + for (final key in extraSensorData.keys) { + options.add( + _FieldOption( + key: _extraFieldKey(key), + label: _formatExtraFieldLabel(key), + ), + ); + } + } + + return options; +} + +String _extraFieldKey(String label) { + return 'extra:$label'; +} + +String _formatExtraFieldLabel(String rawKey) { + final knownPrefixes = { + 'altitude': 'Altitude', + 'illuminance': 'Illuminance', + 'presence': 'Presence', + 'digital_input': 'Digital input', + 'digital_output': 'Digital output', + 'analog_input': 'Analog input', + 'analog_output': 'Analog output', + 'accelerometer': 'Accelerometer', + 'gyrometer': 'Gyrometer', }; + + for (final entry in knownPrefixes.entries) { + final prefix = '${entry.key}_'; + if (rawKey == entry.key) { + return entry.value; + } + if (rawKey.startsWith(prefix)) { + final suffix = rawKey.substring(prefix.length); + final channel = int.tryParse(suffix); + if (channel != null) { + return '${entry.value} (ch $channel)'; + } + return entry.value; + } + } + + final parts = rawKey.split('_'); + if (parts.isEmpty) return rawKey; + final channel = parts.length > 1 ? parts.last : null; + final base = parts.length > 1 + ? parts.sublist(0, parts.length - 1).join(' ') + : rawKey; + final title = base + .split(' ') + .where((part) => part.isNotEmpty) + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '); + if (channel != null && int.tryParse(channel) != null) { + return '$title (ch $channel)'; + } + return title; } IconData _typeIcon(Contact contact) { diff --git a/pubspec.yaml b/pubspec.yaml index ff659e1..e2c1d44 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0307.4+17 +version: 2026.0307.6+19 environment: sdk: ^3.9.2