diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 606003e..9bbdc1c 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -193,6 +193,9 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Install macOS native build tools + run: brew install automake libtool + - name: Build macOS release run: flutter build macos --release diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 8ec34ed..2adf8bb 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 4e30cc1..3852837 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -474,6 +474,21 @@ class SensorsProvider with ChangeNotifier { bool isWatched(String publicKeyHex) => _watchedSensorKeys.contains(publicKeyHex); + Contact? selfContact( + ContactsProvider contactsProvider, + ConnectionProvider connectionProvider, + ) { + final selfKey = connectionProvider.deviceInfo.publicKey; + if (selfKey == null || selfKey.isEmpty) { + return null; + } + + final existing = contactsProvider.findContactByKey( + Uint8List.fromList(selfKey), + ); + return existing ?? _buildSelfCandidate(connectionProvider); + } + Future addSensor(Contact contact) async { if (!contact.isChat && !contact.isRepeater && !contact.isSensor) { return; @@ -549,17 +564,65 @@ class SensorsProvider with ChangeNotifier { notifyListeners(); } - List availableCandidates(ContactsProvider contactsProvider) { + List availableCandidates( + ContactsProvider contactsProvider, { + ConnectionProvider? connectionProvider, + }) { final candidates = [ ...contactsProvider.chatContacts, ...contactsProvider.repeaters, ...contactsProvider.sensorContacts, ]; + + // Add "myself" (own device) as a candidate if it has a public key + if (connectionProvider != null) { + final self = selfContact(contactsProvider, connectionProvider); + if (self != null && + !candidates.any((c) => c.publicKeyHex == self.publicKeyHex)) { + candidates.insert(0, self); + } + } + candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); return candidates; } + List displaySensorKeys({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) { + if (_watchedSensorKeys.isNotEmpty) { + return List.unmodifiable(_watchedSensorKeys); + } + + final self = selfContact(contactsProvider, connectionProvider); + if (self == null) { + return const []; + } + + return [self.publicKeyHex]; + } + + Contact? contactForDisplay( + String publicKeyHex, { + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) { + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == publicKeyHex) { + return entry; + } + } + + final self = selfContact(contactsProvider, connectionProvider); + if (self?.publicKeyHex == publicKeyHex) { + return self; + } + + return null; + } + Future refreshAll({ required ContactsProvider contactsProvider, required ConnectionProvider connectionProvider, @@ -597,13 +660,11 @@ class SensorsProvider with ChangeNotifier { _lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now(); - Contact? contact; - for (final entry in contactsProvider.contacts) { - if (entry.publicKeyHex == publicKeyHex) { - contact = entry; - break; - } - } + final contact = contactForDisplay( + publicKeyHex, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); if (contact == null) { _setRefreshState(publicKeyHex, SensorRefreshState.unavailable); @@ -686,4 +747,25 @@ class SensorsProvider with ChangeNotifier { _refreshStateUpdatedAt[publicKeyHex] = DateTime.now(); notifyListeners(); } + + Contact? _buildSelfCandidate(ConnectionProvider connectionProvider) { + final deviceInfo = connectionProvider.deviceInfo; + final selfKey = deviceInfo.publicKey; + if (selfKey == null || selfKey.isEmpty) { + return null; + } + + return Contact( + publicKey: Uint8List.fromList(selfKey), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: deviceInfo.selfName ?? 'My Device', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: deviceInfo.advLat ?? 0, + advLon: deviceInfo.advLon ?? 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } } diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 2f356e7..6cfb3e2 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -100,7 +100,10 @@ class _SensorsTabState extends State { Future _showAddSensorSheet(BuildContext context) async { final sensorsProvider = context.read(); final contactsProvider = context.read(); - final candidates = sensorsProvider.availableCandidates(contactsProvider); + final candidates = sensorsProvider.availableCandidates( + contactsProvider, + connectionProvider: context.read(), + ); await showModalBottomSheet( context: context, @@ -127,7 +130,11 @@ class _SensorsTabState extends State { 'Add sensor node', style: TextStyle(fontWeight: FontWeight.bold), ), - subtitle: Text(AppLocalizations.of(context)!.pickARelayOrNodeToWatchInSensors), + subtitle: Text( + AppLocalizations.of( + context, + )!.pickARelayOrNodeToWatchInSensors, + ), ), ...candidates.map( (contact) => ListTile( @@ -275,58 +282,72 @@ class _SensorsTabState extends State { onPressed: () => _showAddSensorSheet(context), child: const Icon(Icons.add), ), - body: Consumer2( - builder: (context, sensorsProvider, contactsProvider, child) { - final watchedKeys = sensorsProvider.watchedSensorKeys; + body: Consumer3( + builder: + ( + context, + sensorsProvider, + contactsProvider, + connectionProvider, + child, + ) { + final displayKeys = sensorsProvider.displaySensorKeys( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final hasPersistedSensors = + sensorsProvider.watchedSensorKeys.isNotEmpty; - 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; - } - } - final availableFieldKeys = sensorMetricKeysFor(contact); - final visibleFields = sensorsProvider - .effectiveVisibleFieldsFor(key, availableFieldKeys); - return SensorTelemetryCard( - contact: contact, - state: sensorsProvider.stateFor(key), - visibleFields: visibleFields, - fieldOrder: sensorsProvider.metricOrderFor( - key, - availableFieldKeys, - ), - labelOverrides: sensorsProvider.labelOverridesFor(key), - fieldSpans: { - for (final field in visibleFields) - field: sensorsProvider.fieldSpanFor(key, field), - }, - onRemove: () async { - await sensorsProvider.removeSensor(key); - }, - onCustomize: () => - _showMetricSelector(context, key, contact), - onRefresh: () => sensorsProvider.refreshSensor( - publicKeyHex: key, - contactsProvider: contactsProvider, - connectionProvider: context.read(), - ), - ); - }), - ], - ), - ); - }, + return RefreshIndicator( + onRefresh: () => _refreshAll(context), + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + if (displayKeys.isEmpty) + const _EmptySensorsState() + else + ...displayKeys.map((key) { + final contact = sensorsProvider.contactForDisplay( + key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final availableFieldKeys = sensorMetricKeysFor(contact); + final visibleFields = sensorsProvider + .effectiveVisibleFieldsFor(key, availableFieldKeys); + return SensorTelemetryCard( + contact: contact, + state: sensorsProvider.stateFor(key), + visibleFields: visibleFields, + fieldOrder: sensorsProvider.metricOrderFor( + key, + availableFieldKeys, + ), + labelOverrides: sensorsProvider.labelOverridesFor( + key, + ), + fieldSpans: { + for (final field in visibleFields) + field: sensorsProvider.fieldSpanFor(key, field), + }, + onRemove: hasPersistedSensors + ? () async { + await sensorsProvider.removeSensor(key); + } + : null, + onCustomize: () => + _showMetricSelector(context, key, contact), + onRefresh: () => sensorsProvider.refreshSensor( + publicKeyHex: key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ), + ); + }), + ], + ), + ); + }, ), ); } @@ -876,55 +897,8 @@ class _EmptySensorsState extends StatefulWidget { } class _EmptySensorsStateState extends State<_EmptySensorsState> { - bool _discoveryTriggered = false; - bool _discoveryInProgress = false; - - @override - void initState() { - super.initState(); - // Auto-trigger sensor discovery when the empty state is shown - WidgetsBinding.instance.addPostFrameCallback((_) { - _autoDiscover(); - }); - } - - Future _autoDiscover() async { - if (_discoveryTriggered || !mounted) return; - final connectionProvider = context.read(); - if (!connectionProvider.deviceInfo.isConnected) return; - - _discoveryTriggered = true; - setState(() => _discoveryInProgress = true); - try { - await connectionProvider.discoverNodeType(advertType: 4); - } finally { - if (mounted) setState(() => _discoveryInProgress = false); - } - } - - Future _discoverSensors() async { - if (_discoveryInProgress || !mounted) return; - final connectionProvider = context.read(); - if (!connectionProvider.deviceInfo.isConnected) return; - - setState(() => _discoveryInProgress = true); - try { - await connectionProvider.discoverNodeType(advertType: 4); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.sensorDiscoverySent)), - ); - } - } finally { - if (mounted) setState(() => _discoveryInProgress = false); - } - } - @override Widget build(BuildContext context) { - final isConnected = - context.watch().deviceInfo.isConnected; - return Padding( padding: const EdgeInsets.symmetric(vertical: 64), child: Column( @@ -951,29 +925,10 @@ class _EmptySensorsStateState extends State<_EmptySensorsState> { const Padding( padding: EdgeInsets.symmetric(horizontal: 24), child: Text( - 'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.', + 'Use + to add discovered relays or nodes. Your device will appear here automatically when available.', textAlign: TextAlign.center, ), ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: isConnected && !_discoveryInProgress - ? _discoverSensors - : null, - icon: _discoveryInProgress - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.sensors_outlined), - label: Text(_discoveryInProgress - ? 'Discovering...' - : 'Discover Sensors'), - ), ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 0de8403..80ba6e8 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.0319.1+35 +version: 2026.0320.1+36 environment: sdk: ^3.9.2