From cf70aa2bfb4838436b9df0d7698dc90add7f02c2 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 7 Mar 2026 14:53:22 +0100 Subject: [PATCH] Reduce header space and tweak chat --- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/Info.plist | 2 +- ios/fastlane/report.xml | 8 +- lib/main.dart | 2 + lib/providers/app_provider.dart | 26 + lib/providers/sensors_provider.dart | 233 +++++++++ lib/screens/home_screen.dart | 22 +- lib/screens/map_tab.dart | 5 +- lib/screens/sensors_tab.dart | 735 +++++++++++++++++++++++++++ lib/screens/settings_screen.dart | 13 + pubspec.yaml | 2 +- 11 files changed, 1044 insertions(+), 16 deletions(-) create mode 100644 lib/providers/sensors_provider.dart create mode 100644 lib/screens/sensors_tab.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bc81839..e0378fc 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 = 97; + CURRENT_PROJECT_VERSION = 98; 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 = 97; + CURRENT_PROJECT_VERSION = 98; 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 = 97; + CURRENT_PROJECT_VERSION = 98; 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 = 97; + CURRENT_PROJECT_VERSION = 98; 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 = 97; + CURRENT_PROJECT_VERSION = 98; 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 = 97; + CURRENT_PROJECT_VERSION = 98; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 59969a1..e28e34f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 97 + 98 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index ecfd512..fdb44e7 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/main.dart b/lib/main.dart index bab8edb..15d6e99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'providers/channels_provider.dart'; import 'providers/voice_provider.dart'; import 'providers/image_provider.dart' as ip; import 'providers/app_provider.dart'; +import 'providers/sensors_provider.dart'; import 'services/voice_codec_service.dart'; import 'services/voice_player_service.dart'; import 'services/tile_cache_service.dart'; @@ -234,6 +235,7 @@ class _MeshCoreSarAppState extends State { }, ), ChangeNotifierProvider(create: (_) => ChannelsProvider()), + ChangeNotifierProvider(create: (_) => SensorsProvider()), // Voice provider (packet reassembly + playback) ChangeNotifierProvider( diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index d1c76aa..00fe3bb 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -51,6 +51,8 @@ class AppProvider with ChangeNotifier { bool get isMapEnabled => _isMapEnabled; bool _isContactsEnabled = true; bool get isContactsEnabled => _isContactsEnabled; + bool _isSensorsEnabled = true; + bool get isSensorsEnabled => _isSensorsEnabled; bool _isVoiceSilenceTrimmingEnabled = true; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; @@ -97,6 +99,7 @@ class AppProvider with ChangeNotifier { _loadSimpleMode(); _loadMapEnabled(); _loadContactsEnabled(); + _loadSensorsEnabled(); _loadVoiceSilenceTrimmingEnabled(); _loadVoiceBandPassFilterEnabled(); _loadVoiceCompressorEnabled(); @@ -291,6 +294,29 @@ class AppProvider with ChangeNotifier { } } + /// Load sensors enabled setting from shared preferences + Future _loadSensorsEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading sensors enabled setting: $e'); + } + } + + /// Toggle sensors tab on/off + Future toggleSensorsEnabled(bool enabled) async { + try { + _isSensorsEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('sensors_enabled', enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving sensors enabled setting: $e'); + } + } + /// Load voice silence trimming setting from shared preferences. Future _loadVoiceSilenceTrimmingEnabled() async { try { diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart new file mode 100644 index 0000000..95dacfc --- /dev/null +++ b/lib/providers/sensors_provider.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/contact.dart'; +import 'connection_provider.dart'; +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, + }; + + final List _watchedSensorKeys = []; + final Map _refreshStates = + {}; + final Map> _visibleMetricsBySensor = + >{}; + bool _isLoaded = false; + bool _isRefreshingAll = false; + + SensorsProvider() { + unawaited(_loadWatchedSensors()); + } + + List get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys); + bool get isLoaded => _isLoaded; + bool get isRefreshingAll => _isRefreshingAll; + + SensorRefreshState stateFor(String publicKeyHex) => + _refreshStates[publicKeyHex] ?? SensorRefreshState.idle; + + Future _loadWatchedSensors() async { + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getStringList(_watchedSensorsKey) ?? []; + final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey); + _watchedSensorKeys + ..clear() + ..addAll(stored); + _visibleMetricsBySensor.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() + .toSet(); + } + } + for (final key in _watchedSensorKeys) { + _visibleMetricsBySensor.putIfAbsent( + key, + () => Set.from(_defaultVisibleMetrics), + ); + } + } catch (e) { + debugPrint('Error loading watched sensors: $e'); + } finally { + _isLoaded = true; + notifyListeners(); + } + } + + Future _persistWatchedSensors() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys); + } catch (e) { + debugPrint('Error saving watched sensors: $e'); + } + } + + Future _persistVisibleMetrics() async { + try { + final prefs = await SharedPreferences.getInstance(); + final encoded = >{}; + for (final entry in _visibleMetricsBySensor.entries) { + encoded[entry.key] = entry.value.map((metric) => metric.name).toList(); + } + await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded)); + } catch (e) { + debugPrint('Error saving visible sensor metrics: $e'); + } + } + + Set visibleMetricsFor(String publicKeyHex) => + Set.unmodifiable( + _visibleMetricsBySensor[publicKeyHex] ?? _defaultVisibleMetrics, + ); + + bool showsMetric(String publicKeyHex, SensorMetric metric) => + visibleMetricsFor(publicKeyHex).contains(metric); + + Future toggleMetric( + String publicKeyHex, + SensorMetric metric, + bool visible, + ) async { + final visibleMetrics = _visibleMetricsBySensor.putIfAbsent( + publicKeyHex, + () => Set.from(_defaultVisibleMetrics), + ); + if (visible) { + visibleMetrics.add(metric); + } else { + if (visibleMetrics.length == 1 && visibleMetrics.contains(metric)) { + return; + } + visibleMetrics.remove(metric); + } + await _persistVisibleMetrics(); + notifyListeners(); + } + + bool isWatched(String publicKeyHex) => + _watchedSensorKeys.contains(publicKeyHex); + + Future addSensor(Contact contact) async { + if (!contact.isChat && !contact.isRepeater) { + return; + } + if (_watchedSensorKeys.contains(contact.publicKeyHex)) { + return; + } + + _watchedSensorKeys.add(contact.publicKeyHex); + await _persistWatchedSensors(); + _visibleMetricsBySensor[contact.publicKeyHex] = Set.from( + _defaultVisibleMetrics, + ); + await _persistVisibleMetrics(); + notifyListeners(); + } + + Future removeSensor(String publicKeyHex) async { + _watchedSensorKeys.remove(publicKeyHex); + _refreshStates.remove(publicKeyHex); + _visibleMetricsBySensor.remove(publicKeyHex); + await _persistWatchedSensors(); + await _persistVisibleMetrics(); + notifyListeners(); + } + + List availableCandidates(ContactsProvider contactsProvider) { + final candidates = [ + ...contactsProvider.chatContacts, + ...contactsProvider.repeaters, + ]; + candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); + candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); + return candidates; + } + + Future refreshAll({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) async { + if (_isRefreshingAll || _watchedSensorKeys.isEmpty) { + return; + } + + _isRefreshingAll = true; + notifyListeners(); + + try { + for (final key in _watchedSensorKeys) { + Contact? contact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == key) { + contact = entry; + break; + } + } + if (contact == null) { + _refreshStates[key] = SensorRefreshState.unavailable; + notifyListeners(); + continue; + } + + _refreshStates[key] = SensorRefreshState.refreshing; + notifyListeners(); + + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: contact.hasPath, + ); + + _refreshStates[key] = result.success + ? SensorRefreshState.success + : SensorRefreshState.timeout; + notifyListeners(); + } + } finally { + _isRefreshingAll = false; + notifyListeners(); + } + } + + SensorMetric? _metricFromName(String name) { + for (final metric in SensorMetric.values) { + if (metric.name == name) { + return metric; + } + } + return null; + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0cbb4df..8b28e10 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart'; import '../theme/app_theme.dart'; import 'messages_tab.dart'; import 'contacts_tab.dart'; +import 'sensors_tab.dart'; import 'map_tab.dart'; import 'map_management_screen.dart'; import 'settings_screen.dart'; @@ -24,7 +25,7 @@ import '../widgets/permission_request_dialog.dart'; import '../widgets/connection_dialog.dart'; import '../utils/battery_display_helper.dart'; -enum _HomeTab { messages, contacts, map } +enum _HomeTab { messages, contacts, sensors, map } class HomeScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; @@ -54,11 +55,13 @@ class _HomeScreenState extends State with TickerProviderStateMixin { bool _showRxTxIndicators = true; bool _isMapEnabled = true; bool _isContactsEnabled = true; + bool _isSensorsEnabled = false; List<_HomeTab> get _enabledTabs { return [ _HomeTab.messages, if (_isContactsEnabled) _HomeTab.contacts, + if (_isSensorsEnabled) _HomeTab.sensors, if (_isMapEnabled) _HomeTab.map, ]; } @@ -77,6 +80,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _appProvider = context.read(); _isMapEnabled = _appProvider.isMapEnabled; _isContactsEnabled = _appProvider.isContactsEnabled; + _isSensorsEnabled = _appProvider.isSensorsEnabled; _appProvider.addListener(_handleAppProviderChanged); // Initialize synchronously so first build always has a valid controller. @@ -106,6 +110,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _updateTabController( mapEnabled: _appProvider.isMapEnabled, contactsEnabled: _appProvider.isContactsEnabled, + sensorsEnabled: _appProvider.isSensorsEnabled, ); } @@ -129,8 +134,11 @@ class _HomeScreenState extends State with TickerProviderStateMixin { void _updateTabController({ required bool mapEnabled, required bool contactsEnabled, + required bool sensorsEnabled, }) { - if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) { + if (_isMapEnabled == mapEnabled && + _isContactsEnabled == contactsEnabled && + _isSensorsEnabled == sensorsEnabled) { return; } @@ -147,6 +155,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Update state _isMapEnabled = mapEnabled; _isContactsEnabled = contactsEnabled; + _isSensorsEnabled = sensorsEnabled; if (!_isMapEnabled) { _isMapFullscreen = false; } @@ -183,6 +192,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { case _HomeTab.contacts: context.read().markAllAsViewed(); break; + case _HomeTab.sensors: + break; case _HomeTab.map: break; } @@ -471,6 +482,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ? () => _navigateToTab(_HomeTab.map) : null, ); + case _HomeTab.sensors: + return const SensorsTab(); case _HomeTab.map: return MapTab( onFullscreenChanged: (isFullscreen) { @@ -532,6 +545,11 @@ class _HomeScreenState extends State with TickerProviderStateMixin { icon: const Icon(Icons.map), text: AppLocalizations.of(context)!.map, ); + case _HomeTab.sensors: + return const Tab( + icon: Icon(Icons.sensors), + text: 'Sensors', + ); } }).toList(), ), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index edf86a0..be80c18 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -76,6 +76,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final BackgroundLocationService _backgroundLocationService = BackgroundLocationService(); bool _isDisposing = false; // Flag to prevent updates during disposal + MapProvider? _mapProvider; // MBTiles layers List _mbtilesLayers = []; @@ -129,6 +130,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; // Check if widget is still mounted final mapProvider = context.read(); + _mapProvider = mapProvider; mapProvider.addListener(_handleMapNavigation); // Load WMS overlay state mapProvider.loadOverlayState(); @@ -438,8 +440,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Save map position before disposing _saveMapPosition(); - final mapProvider = context.read(); - mapProvider.removeListener(_handleMapNavigation); + _mapProvider?.removeListener(_handleMapNavigation); // DO NOT stop location tracking - it's managed by AppProvider // Restore the original callback instead of setting to null diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart new file mode 100644 index 0000000..dd8ed3e --- /dev/null +++ b/lib/screens/sensors_tab.dart @@ -0,0 +1,735 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/contact.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/sensors_provider.dart'; + +class SensorsTab extends StatelessWidget { + const SensorsTab({super.key}); + + Future _showAddSensorSheet(BuildContext context) async { + final sensorsProvider = context.read(); + final contactsProvider = context.read(); + final candidates = sensorsProvider.availableCandidates(contactsProvider); + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + if (candidates.isEmpty) { + return const SafeArea( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No eligible nodes available. Discover a relay or node first.', + ), + ), + ); + } + + return SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 20), + children: [ + const ListTile( + title: Text( + 'Add sensor node', + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('Pick a relay or node to watch in Sensors.'), + ), + ...candidates.map( + (contact) => ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + leading: CircleAvatar( + radius: 24, + backgroundColor: const Color(0xFFDDEAF8), + child: Icon( + _typeIcon(contact), + color: const Color(0xFF1E4F7A), + ), + ), + title: Text(contact.displayName), + subtitle: _SensorCandidatePreview(contact: contact), + isThreeLine: true, + onTap: () async { + await sensorsProvider.addSensor(contact); + if (!sheetContext.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${contact.displayName} added to Sensors', + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Future _showMetricSelector( + BuildContext context, + String publicKeyHex, + ) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => Consumer( + builder: (context, sensorsProvider, child) { + final visibleMetrics = sensorsProvider.visibleMetricsFor( + publicKeyHex, + ); + return SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + children: [ + Text( + 'Visible fields', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Choose which values appear on sensor cards.', + 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(), + ), + ], + ), + ); + }, + ), + ); + } + + Future _refreshAll(BuildContext context) async { + await context.read().refreshAll( + contactsProvider: context.read(), + connectionProvider: context.read(), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddSensorSheet(context), + child: const Icon(Icons.add), + ), + body: Consumer2( + builder: (context, sensorsProvider, contactsProvider, child) { + final watchedKeys = sensorsProvider.watchedSensorKeys; + + return RefreshIndicator( + onRefresh: () => _refreshAll(context), + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + if (watchedKeys.isEmpty) + const _EmptySensorsState() + else + ...watchedKeys.map((key) { + Contact? contact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == key) { + contact = entry; + break; + } + } + return _SensorCard( + contact: contact, + state: sensorsProvider.stateFor(key), + visibleMetrics: sensorsProvider.visibleMetricsFor(key), + onRemove: () async { + await sensorsProvider.removeSensor(key); + }, + onCustomize: () => _showMetricSelector(context, key), + ); + }), + ], + ), + ); + }, + ), + ); + } +} + +class _SensorCandidatePreview extends StatelessWidget { + final Contact contact; + + const _SensorCandidatePreview({required this.contact}); + + @override + Widget build(BuildContext context) { + final telemetry = contact.telemetry; + final previewLines = [ + '${contact.type.displayName} • ${contact.publicKeyShort}', + if (telemetry?.batteryPercentage != null) + 'Battery ${telemetry!.batteryPercentage!.toStringAsFixed(0)}% • ' + 'Temp ${telemetry.temperature?.toStringAsFixed(1) ?? '--'}°C', + if (telemetry?.gpsLocation != null) + 'GPS ${telemetry!.gpsLocation!.latitude.toStringAsFixed(3)}, ' + '${telemetry.gpsLocation!.longitude.toStringAsFixed(3)}', + ]; + + if (previewLines.length == 1) { + previewLines.add('No telemetry preview available yet'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: previewLines + .take(3) + .map( + (line) => Text(line, maxLines: 1, overflow: TextOverflow.ellipsis), + ) + .toList(), + ); + } +} + +class _EmptySensorsState extends StatelessWidget { + const _EmptySensorsState(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + Container( + width: 84, + height: 84, + decoration: const BoxDecoration( + color: Color(0xFFDDEAF8), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.sensors_outlined, + size: 40, + color: Color(0xFF1E4F7A), + ), + ), + const SizedBox(height: 20), + Text( + 'No sensor nodes added', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 10), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + 'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.', + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} + +class _SensorCard extends StatelessWidget { + final Contact? contact; + final SensorRefreshState state; + final Set visibleMetrics; + final Future Function() onRemove; + final VoidCallback onCustomize; + + const _SensorCard({ + required this.contact, + required this.state, + required this.visibleMetrics, + required this.onRemove, + required this.onCustomize, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final telemetry = contact?.telemetry; + final theme = Theme.of(context); + final metrics = contact == null || telemetry == null + ? const <_MetricCardData>[] + : _buildMetricCards(l10n, telemetry, contact!); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.14), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + contact?.displayName ?? 'Unavailable node', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (state == SensorRefreshState.timeout) + const _InlineAlertBadge(label: 'No response'), + ], + ), + if (telemetry != null) ...[ + const SizedBox(height: 2), + Text( + '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + PopupMenuButton( + onSelected: (value) async { + if (value == 'remove') { + await onRemove(); + } else if (value == 'customize') { + onCustomize(); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'customize', + child: Text('Customize fields'), + ), + PopupMenuItem( + value: 'remove', + child: Text('Remove'), + ), + ], + ), + ], + ), + 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.', + ) + else if (telemetry == null) + const Text('No telemetry received yet. Pull down to fetch it.') + else if (metrics.isEmpty) + const Text( + '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(), + ), + ], + ), + ), + ); + } + + List<_MetricCardData> _buildMetricCards( + AppLocalizations l10n, + dynamic telemetry, + Contact contact, + ) { + final items = <_MetricCardData>[]; + + if (visibleMetrics.contains(SensorMetric.voltage) && + telemetry.batteryMilliVolts != null) { + items.add( + _MetricCardData( + icon: Icons.bolt, + label: l10n.voltage, + value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', + accent: const Color(0xFF0A7D61), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.battery) && + telemetry.batteryPercentage != null) { + items.add( + _MetricCardData( + icon: Icons.battery_5_bar, + label: l10n.battery, + value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', + accent: const Color(0xFF4B8E2F), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.temperature) && + telemetry.temperature != null) { + items.add( + _MetricCardData( + icon: Icons.thermostat, + label: l10n.temperature, + value: '${telemetry.temperature!.toStringAsFixed(1)}°C', + accent: const Color(0xFFC76821), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.humidity) && + telemetry.humidity != null) { + items.add( + _MetricCardData( + icon: Icons.water_drop, + label: l10n.humidity, + value: '${telemetry.humidity!.toStringAsFixed(1)}%', + accent: const Color(0xFF246BB2), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.pressure) && + telemetry.pressure != null) { + items.add( + _MetricCardData( + icon: Icons.compress, + label: l10n.pressure, + value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', + accent: const Color(0xFF6B4BAE), + ), + ); + } + if (visibleMetrics.contains(SensorMetric.gps) && + telemetry.gpsLocation != null) { + items.add( + _MetricCardData( + 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), + ), + ); + } + if (telemetry.extraSensorData != null) { + for (final entry in telemetry.extraSensorData!.entries) { + items.add( + _MetricCardData( + icon: Icons.sensors, + label: entry.key, + value: '${entry.value}', + accent: const Color(0xFF3E657C), + ), + ); + } + } + + return items; + } + + String _formatTelemetryTime(DateTime timestamp) { + final diff = DateTime.now().difference(timestamp); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + String _formatTelemetryDateTime(DateTime timestamp) { + final local = timestamp.toLocal(); + final year = local.year.toString().padLeft(4, '0'); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; + } +} + +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; + final String label; + final Color? foreground; + final Color? background; + + const _InfoPill({ + required this.icon, + required this.label, + this.foreground, + this.background, + }); + + @override + Widget build(BuildContext context) { + final color = foreground ?? Theme.of(context).colorScheme.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: + background ?? Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Text( + label, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} + +class _InlineAlertBadge extends StatelessWidget { + final String label; + + const _InlineAlertBadge({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFC17B1D).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: const Color(0xFFC17B1D), + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _MetricTile extends StatelessWidget { + final _MetricCardData data; + + const _MetricTile({required this.data}); + + @override + Widget build(BuildContext context) { + final width = data.wide ? 320.0 : 168.0; + + 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( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + data.value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MetricCardData { + final IconData icon; + final String label; + final String value; + final Color accent; + final bool wide; + + const _MetricCardData({ + required this.icon, + required this.label, + required this.value, + required this.accent, + this.wide = false, + }); +} + +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', + }; +} + +IconData _typeIcon(Contact contact) { + if (contact.isRepeater) { + return Icons.router; + } + if (contact.isChat) { + return Icons.sensors; + } + return Icons.device_hub; +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 15d282c..f43c0cf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -737,6 +737,19 @@ class _SettingsScreenState extends State { }, ), ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.sensors), + title: const Text('Enable Sensors tab'), + subtitle: const Text( + 'Show a dedicated tab for watched relay and node telemetry', + ), + value: appProvider.isSensorsEnabled, + onChanged: (value) async { + await appProvider.toggleSensorsEnabled(value); + }, + ), + ), ListTile( leading: const Icon(Icons.language), title: Text(AppLocalizations.of(context)!.language), diff --git a/pubspec.yaml b/pubspec.yaml index 140cf15..ff659e1 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.3+16 +version: 2026.0307.4+17 environment: sdk: ^3.9.2