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