diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 593f051..e993f0a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -84,6 +84,8 @@ PODS: - Flutter - vibration (3.0.0): - Flutter + - wakelock_plus (0.0.1): + - Flutter DEPENDENCIES: - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) @@ -108,6 +110,7 @@ DEPENDENCIES: - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - vibration (from `.symlinks/plugins/vibration/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) SPEC REPOS: trunk: @@ -161,6 +164,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" vibration: :path: ".symlinks/plugins/vibration/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 @@ -189,6 +194,7 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878 diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 8f55f62..9cdc2b1 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1723,6 +1723,16 @@ class AppProvider with ChangeNotifier { connectionProvider.resolveContactForDmCallback = (contactPublicKey) { return contactsProvider.findContactByKey(contactPublicKey); }; + // Reset path before the last retry attempt to force flood mode + messagesProvider.resetPathBeforeLastRetryCallback = (contact) async { + if (connectionProvider.deviceInfo.isConnected) { + debugPrint( + '🔄 [AppProvider] Resetting path for ${contact.advName} before last retry (flood fallback)', + ); + await connectionProvider.resetPath(contact.publicKey); + } + }; + messagesProvider.onFinalRouterFallbackCallback = ({required messageId, required contact, required message}) async { return _sendWithFinalNearestRouterFallback( diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 06d44ec..455d3f2 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -586,7 +586,12 @@ class ContactsProvider with ChangeNotifier { ); _contacts[contact.publicKeyHex] = updatedContact; - _pendingAdverts.remove(contact.publicKeyHex); + // Keep repeaters and sensors in pending adverts so they remain visible + // in the discovery list (with a checkmark). Remove others. + if (contact.type != ContactType.repeater && + contact.type != ContactType.sensor) { + _pendingAdverts.remove(contact.publicKeyHex); + } debugPrint( ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ); @@ -615,7 +620,10 @@ class ContactsProvider with ChangeNotifier { incomingContact: contact, existingContact: existingContact, ); - _pendingAdverts.remove(contact.publicKeyHex); + if (contact.type != ContactType.repeater && + contact.type != ContactType.sensor) { + _pendingAdverts.remove(contact.publicKeyHex); + } } if (excluded > 0) { debugPrint( diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart index a536987..9f36cc6 100644 --- a/lib/providers/helpers/message_retry_manager.dart +++ b/lib/providers/helpers/message_retry_manager.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math' as math; import '../../models/message.dart'; import '../../models/contact.dart'; @@ -19,10 +20,18 @@ class MessageRetryManager { final Map _lastRetryTimes = {}; final Map _pathFailureStreaks = {}; - static const int maxRetryAttempts = 4; + /// Max retry attempts when the contact has a known path. + /// Official MeshCore app uses 5 (with auto-retry) or 3 (without). + static const int maxRetryAttemptsWithPath = 5; + + /// No retries for flood-only contacts (no known path). + static const int maxRetryAttemptsFloodOnly = 1; + + @Deprecated('Use maxRetryAttemptsForContact instead') + static const int maxRetryAttempts = maxRetryAttemptsWithPath; // Retry backoff values in milliseconds. - static const List _retryDelays = [1000, 2000, 4000, 8000]; + static const List _retryDelays = [1000, 2000, 4000, 8000, 8000]; static const int _defaultLoRaSf = 10; static const int _defaultLoRaCr = 5; static const int _defaultLoRaBwHz = 250000; @@ -38,32 +47,54 @@ class MessageRetryManager { return _retryDelays[attempt]; } - /// Calculate a conservative delivery-ACK timeout when firmware doesn't - /// provide one or returns an invalid value. + static final math.Random _rng = math.Random(); + + /// Calculate a delivery-ACK timeout with random jitter. + /// + /// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`. + /// The jitter prevents collision when multiple messages are in flight. int calculateAckTimeoutMs({ required String text, required Contact? contact, int? suggestedTimeoutMs, }) { + int baseTimeout; if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) { - return suggestedTimeoutMs; + baseTimeout = suggestedTimeoutMs; + } else { + final payloadBytes = utf8.encode(text).length; + final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes); + final hopCount = contact?.routeHasPath == true + ? contact!.routeHopCount + : -1; + + if (hopCount < 0) { + baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000); + } else { + baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000); + } } - final payloadBytes = utf8.encode(text).length; - final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes); - final hopCount = contact?.routeHasPath == true - ? contact!.routeHopCount - : -1; + // Add random jitter: 1000-8000ms (matches official app) + final jitterMs = 1000 + _rng.nextInt(7001); + return baseTimeout + jitterMs; + } - if (hopCount < 0) { - return ((airtimeMs * 10) + 4000).clamp(10000, 30000); - } - - return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000); + /// Max attempts for a given contact based on whether it has a known path. + static int maxRetryAttemptsForContact(Contact? contact) { + final hasPath = contact?.routeHasPath ?? false; + return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly; } bool canRetry(Message message, Contact contact) { - return message.retryAttempt < maxRetryAttempts; + return message.retryAttempt < maxRetryAttemptsForContact(contact); + } + + /// Whether the next attempt is the last one. + /// When true, the caller should reset the path to force flood mode. + bool isLastAttempt(Message message, Contact contact) { + final maxAttempts = maxRetryAttemptsForContact(contact); + return maxAttempts > 1 && message.retryAttempt + 1 >= maxAttempts; } /// Track a retry attempt for a message diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index e7a86ef..e9a6478 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -96,6 +96,10 @@ class MessagesProvider with ChangeNotifier { Future Function({required Contact contact, required int failureStreak})? onDirectPathFailedCallback; + + /// Called before the last retry attempt to reset the contact's path, + /// forcing the firmware to use flood mode for the final try. + Future Function(Contact contact)? resetPathBeforeLastRetryCallback; void Function(String messageId)? onManualRetryPreparedCallback; Future Function({ required String messageId, @@ -2316,7 +2320,7 @@ class MessagesProvider with ChangeNotifier { final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt); debugPrint( - '🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId', + '🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttemptsForContact(contact)} for message $messageId', ); debugPrint(' Delay: ${delayMs}ms'); @@ -2356,6 +2360,17 @@ class MessagesProvider with ChangeNotifier { return; } + // On the last attempt, reset the path to force flood mode + // (matches official MeshCore app behaviour) + if (_retryManager.isLastAttempt(currentMessage, contact)) { + debugPrint( + '🔄 [MessagesProvider] Last attempt — resetting path to flood for $messageId', + ); + if (resetPathBeforeLastRetryCallback != null) { + await resetPathBeforeLastRetryCallback!(contact); + } + } + if (sendMessageCallback != null) { final queued = await sendMessageCallback!( contactPublicKey: contact.publicKey, diff --git a/lib/screens/add_contact_screen.dart b/lib/screens/add_contact_screen.dart index 22531cf..4c7a0d3 100644 --- a/lib/screens/add_contact_screen.dart +++ b/lib/screens/add_contact_screen.dart @@ -154,7 +154,7 @@ class _AddContactScreenState extends State { final colorScheme = theme.colorScheme; return Scaffold( - appBar: AppBar(title: const Text('Import Contact')), + appBar: AppBar(title: const Text('Add Contact')), body: ListView( padding: const EdgeInsets.all(16), children: [ @@ -315,7 +315,7 @@ class _AddContactScreenState extends State { ? 'Importing...' : _importSucceeded ? 'Imported' - : 'Import Contact', + : 'Add Contact', ), ), const SizedBox(height: 12), diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 9654de7..aa8d60c 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -675,7 +675,7 @@ class _ContactsTabState extends State { child: OutlinedButton.icon( onPressed: () => _openAddContactScreen(context), icon: const Icon(Icons.person_add_alt_1_outlined), - label: const Text('Import Contact'), + label: const Text('Add Contact'), ), ), ], @@ -737,7 +737,24 @@ class _ContactsTabState extends State { title: l10n.repeaters, count: repeaters.length, icon: Icons.router, - trailing: _buildSortMenu(context, ContactSection.repeaters), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (context.watch().deviceInfo.isConnected) + IconButton( + icon: const Icon(Icons.radar, size: 20), + tooltip: 'Discover repeaters', + visualDensity: VisualDensity.compact, + onPressed: () { + context.read().discoverNodeType(advertType: 2); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Repeater discovery sent')), + ); + }, + ), + _buildSortMenu(context, ContactSection.repeaters), + ], + ), ), _buildSectionFilterField( context, @@ -789,7 +806,24 @@ class _ContactsTabState extends State { title: 'Sensors', count: sensors.length, icon: Icons.sensors, - trailing: _buildSortMenu(context, ContactSection.sensors), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (context.watch().deviceInfo.isConnected) + IconButton( + icon: const Icon(Icons.radar, size: 20), + tooltip: 'Discover sensors', + visualDensity: VisualDensity.compact, + onPressed: () { + context.read().discoverNodeType(advertType: 4); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sensor discovery sent')), + ); + }, + ), + _buildSortMenu(context, ContactSection.sensors), + ], + ), ), _buildSectionFilterField( context, @@ -887,7 +921,7 @@ class _ContactsTabState extends State { child: OutlinedButton.icon( onPressed: () => _openAddContactScreen(context), icon: const Icon(Icons.person_add_alt_1_outlined), - label: const Text('Import Contact'), + label: const Text('Add Contact'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 24, diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index eaa3478..4817d6d 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -426,6 +426,16 @@ class _DiscoveryScreenState extends State { final isConnected = connectionProvider.deviceInfo.isConnected; final cachedNodes = nodesSnapshot.data ?? const []; + // Track which pending adverts are already in the contacts list + final resolvedKeySet = {}; + for (final advert in pendingAdverts) { + if (contactsProvider.findContactByKey(advert.publicKey) != null) { + resolvedKeySet.add(advert.publicKeyHex); + } + } + + final totalCount = pendingAdverts.length; + return ListView( padding: const EdgeInsets.all(16), children: [ @@ -442,7 +452,7 @@ class _DiscoveryScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - 'Pending discoveries (${pendingAdverts.length})', + 'Discovered nodes ($totalCount)', style: Theme.of(context).textTheme.titleMedium, ), ), @@ -495,7 +505,7 @@ class _DiscoveryScreenState extends State { ), ), const SizedBox(height: 16), - if (pendingAdverts.isEmpty) + if (totalCount == 0) Padding( padding: const EdgeInsets.symmetric(vertical: 48), child: Column( @@ -507,112 +517,29 @@ class _DiscoveryScreenState extends State { ), const SizedBox(height: 16), Text( - 'No pending discoveries', + 'No discovered nodes', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( - 'Unknown adverts will appear here until you choose to resolve them.', + 'Use the menu to discover repeaters and sensors on the mesh.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), - ...pendingAdverts.map((advert) { - final isResolving = _resolvingAdvertKeys.contains( - advert.publicKeyHex, - ); - final displayName = _displayNameForAdvert( - advert, - contactsProvider, - cachedNodes, - ); - final typeLabel = _resolvedTypeLabelForAdvert( - advert, - cachedNodes, - ); - final downMetric = SignalMetric.fromValues( - rssiDbm: advert.rxRssiDbm, - snrDb: advert.rxSnr, - ); - final upMetric = SignalMetric.fromValues( - rssiDbm: advert.repeaterLastRssi, - snrDb: advert.repeaterLastSnr, - ); - final detailLines = [ - '${l10n.publicKey}: ${advert.shortDisplayKey}', - ]; - final summaryParts = []; - final battery = advert.repeaterBatteryPercent; - if (battery != null) { - summaryParts.add('Battery ${battery.round()}%'); - } - if (advert.repeaterQueueLen != null) { - summaryParts.add('Queue ${advert.repeaterQueueLen}'); - } - if (summaryParts.isNotEmpty) { - detailLines.add(summaryParts.join(' • ')); - } - detailLines.add( - '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', - ); - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CircleAvatar(child: Icon(_iconForAdvert(advert))), - const SizedBox(width: 12), - Expanded( - child: _buildAdvertTitle( - context, - displayName: displayName, - subtitle: typeLabel, - ), - ), - if (downMetric != null || upMetric != null) ...[ - const SizedBox(width: 12), - _buildSignalSummary( - context, - downMetric: downMetric, - upMetric: upMetric, - ), - ], - const SizedBox(width: 8), - isResolving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : IconButton( - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.person_add_alt_1), - tooltip: 'Resolve contact', - onPressed: isConnected - ? () => _resolveAdvert(advert) - : null, - ), - ], - ), - const SizedBox(height: 10), - Text( - detailLines.join('\n'), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ), - ); - }), + ...pendingAdverts.map((advert) => + _buildPendingAdvertCard( + context, + advert: advert, + contactsProvider: contactsProvider, + isConnected: isConnected, + isResolved: resolvedKeySet.contains(advert.publicKeyHex), + cachedNodes: cachedNodes, + l10n: l10n, + ), + ), ], ); }, @@ -621,6 +548,105 @@ class _DiscoveryScreenState extends State { ); } + Widget _buildPendingAdvertCard( + BuildContext context, { + required PendingAdvert advert, + required ContactsProvider contactsProvider, + required bool isConnected, + required bool isResolved, + required List cachedNodes, + required AppLocalizations l10n, + }) { + final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex); + final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes); + final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes); + final downMetric = SignalMetric.fromValues( + rssiDbm: advert.rxRssiDbm, + snrDb: advert.rxSnr, + ); + final upMetric = SignalMetric.fromValues( + rssiDbm: advert.repeaterLastRssi, + snrDb: advert.repeaterLastSnr, + ); + final detailLines = [ + '${l10n.publicKey}: ${advert.shortDisplayKey}', + ]; + final summaryParts = []; + final battery = advert.repeaterBatteryPercent; + if (battery != null) { + summaryParts.add('Battery ${battery.round()}%'); + } + if (advert.repeaterQueueLen != null) { + summaryParts.add('Queue ${advert.repeaterQueueLen}'); + } + if (summaryParts.isNotEmpty) { + detailLines.add(summaryParts.join(' • ')); + } + detailLines.add( + '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', + ); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar(child: Icon(_iconForAdvert(advert))), + const SizedBox(width: 12), + Expanded( + child: _buildAdvertTitle( + context, + displayName: displayName, + subtitle: typeLabel, + ), + ), + if (downMetric != null || upMetric != null) ...[ + const SizedBox(width: 12), + _buildSignalSummary( + context, + downMetric: downMetric, + upMetric: upMetric, + ), + ], + const SizedBox(width: 8), + if (isResolved) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + size: 24, + ) + else if (isResolving) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.person_add_alt_1), + tooltip: 'Resolve contact', + onPressed: isConnected + ? () => _resolveAdvert(advert) + : null, + ), + ], + ), + const SizedBox(height: 10), + Text( + detailLines.join('\n'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } + Widget _buildSignalSummary( BuildContext context, { SignalMetric? downMetric, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 677db94..14c5135 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -402,45 +402,6 @@ class _HomeScreenState extends State ); } - Widget _buildActivityBadge({ - required String label, - required int count, - required bool isActive, - required Color activeColor, - bool compact = false, - }) { - final color = isActive ? activeColor : Colors.grey; - return Container( - padding: EdgeInsets.symmetric( - horizontal: compact ? 7 : 8, - vertical: compact ? 4 : 5, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: compact ? 6 : 7, - height: compact ? 6 : 7, - decoration: BoxDecoration(shape: BoxShape.circle, color: color), - ), - SizedBox(width: compact ? 5 : 6), - Text( - '$label:$count', - style: TextStyle( - fontSize: compact ? 10 : 11, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ], - ), - ); - } - Widget _buildCompactActivityIndicator({ required bool rxActive, required bool txActive, @@ -1186,48 +1147,13 @@ class _HomeScreenState extends State ), ); }, - child: isTight - ? _buildCompactActivityIndicator( + child: _buildCompactActivityIndicator( rxActive: provider.rxActivity, txActive: provider.txActivity, - ) - : Container( - constraints: const BoxConstraints(minHeight: 48), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh - .withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(22), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _buildActivityBadge( - label: 'RX', - count: provider.rxPacketCount, - isActive: provider.rxActivity, - activeColor: Colors.green, - compact: true, - ), - const SizedBox(height: 4), - _buildActivityBadge( - label: 'TX', - count: provider.txPacketCount, - isActive: provider.txActivity, - activeColor: Colors.blue, - compact: true, - ), - ], - ), ), ) else - SizedBox(width: isTight ? 24 : 74), + const SizedBox(width: 24), ], ); }, diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 6c7ed0d..e3e2307 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -867,11 +867,63 @@ class _SensorCandidatePreview extends StatelessWidget { } } -class _EmptySensorsState extends StatelessWidget { +class _EmptySensorsState extends StatefulWidget { const _EmptySensorsState(); + @override + State<_EmptySensorsState> createState() => _EmptySensorsStateState(); +} + +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( + const SnackBar(content: Text('Sensor discovery sent')), + ); + } + } 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( @@ -902,6 +954,25 @@ class _EmptySensorsState extends StatelessWidget { 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/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index fcdb112..635837f 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -731,25 +731,25 @@ class _MessageBubbleState extends State { runSpacing: 8, children: [ _techBadge( - context, + sheetContext, icon: Icons.message, label: widget.message.messageType.name .toUpperCase(), ), _techBadge( - context, + sheetContext, icon: Icons.route, label: hopDisplayLabel(widget.message), ), _techBadge( - context, + sheetContext, icon: Icons.account_tree_outlined, label: '${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}', ), if (widget.message.channelIdx != null) _techBadge( - context, + sheetContext, icon: Icons.group_work, label: 'CH ${widget.message.channelIdx}', ), @@ -760,14 +760,14 @@ class _MessageBubbleState extends State { rssiDbm != null) ...[ const SizedBox(height: 12), _techSection( - context, + sheetContext, icon: Icons.network_check, title: l10n.linkQuality, child: Column( children: [ if (rssiDbm != null) _signalRow( - context, + sheetContext, label: 'RSSI', valueLabel: '$rssiDbm dBm', normalized: @@ -782,7 +782,7 @@ class _MessageBubbleState extends State { if (snrDb != null) ...[ const SizedBox(height: 8), _signalRow( - context, + sheetContext, label: 'SNR', valueLabel: '${snrDb.toStringAsFixed(1)} dB', normalized: ((snrDb + 20.0) / 40.0).clamp( @@ -802,18 +802,18 @@ class _MessageBubbleState extends State { ], const SizedBox(height: 12), _techSection( - context, + sheetContext, icon: Icons.tune, title: l10n.delivery, child: Column( children: [ _detailRow( - context, + sheetContext, label: l10n.status, value: widget.message.deliveryStatus.name, ), _detailRow( - context, + sheetContext, label: 'Received (RFC3339)', value: _formatRfc3339(widget.message.receivedAt), onCopy: () => copyField( @@ -822,14 +822,14 @@ class _MessageBubbleState extends State { ), if (widget.message.expectedAckTag != null) _detailRow( - context, + sheetContext, label: l10n.expectedAckTag, value: widget.message.expectedAckTag! .toString(), ), if (receptionDetails?.senderToReceiptMs != null) _detailRow( - context, + sheetContext, label: 'Sender to receipt', value: _formatDurationMs( receptionDetails!.senderToReceiptMs!, @@ -837,7 +837,7 @@ class _MessageBubbleState extends State { ), if (receptionDetails?.estimatedTransmitMs != null) _detailRow( - context, + sheetContext, label: 'Estimated tx', value: _formatDurationMs( receptionDetails!.estimatedTransmitMs!, @@ -845,7 +845,7 @@ class _MessageBubbleState extends State { ), if (receptionDetails?.postTransmitDelayMs != null) _detailRow( - context, + sheetContext, label: 'Post-tx delay', value: _formatDurationMs( receptionDetails!.postTransmitDelayMs!, @@ -853,44 +853,44 @@ class _MessageBubbleState extends State { ), if (widget.receivedCopies > 1) _detailRow( - context, + sheetContext, label: 'Received copies', value: '${widget.receivedCopies}', ), if (widget.message.suggestedTimeoutMs != null) _detailRow( - context, + sheetContext, label: 'ACK timeout', value: '${widget.message.suggestedTimeoutMs} ms', ), if (retryCause != null) _detailRow( - context, + sheetContext, label: 'Retry cause', value: retryCause, ), if (retryMode != null) _detailRow( - context, + sheetContext, label: 'Retry mode', value: retryMode, ), if (widget.message.roundTripTimeMs != null) _detailRow( - context, + sheetContext, label: l10n.roundTrip, value: '${widget.message.roundTripTimeMs} ms', ), if (widget.message.retryAttempt > 0) _detailRow( - context, + sheetContext, label: l10n.retryAttempt, value: '${widget.message.retryAttempt}/4', ), if (widget.message.lastRetryAt != null) _detailRow( - context, + sheetContext, label: 'Last retry', value: _formatRfc3339( widget.message.lastRetryAt!, @@ -901,33 +901,33 @@ class _MessageBubbleState extends State { ), if (widget.message.usedFloodFallback) _detailRow( - context, + sheetContext, label: l10n.floodFallback, value: l10n.yes, ), if (routeMetadata?.relayName case final relayName?) _detailRow( - context, + sheetContext, label: 'Relay', value: relayName, ), if (routeMetadata?.canonicalPath case final routePath?) _detailRow( - context, + sheetContext, label: 'Selected path', value: routePath, onCopy: () => copyField(routePath), ), if (retryResult != null) _detailRow( - context, + sheetContext, label: 'Retry result', value: retryResult, ), if (packetPathHex != null) _detailRow( - context, + sheetContext, label: 'Path bytes', value: packetPathHex, onCopy: () => copyField(packetPathHex), @@ -937,19 +937,19 @@ class _MessageBubbleState extends State { ), const SizedBox(height: 12), _techSection( - context, + sheetContext, icon: Icons.badge, title: l10n.identity, child: Column( children: [ _detailRow( - context, + sheetContext, label: l10n.messageId, value: widget.message.id, onCopy: () => copyField(widget.message.id), ), _detailRow( - context, + sheetContext, label: l10n.sender, value: senderName ?? @@ -958,20 +958,20 @@ class _MessageBubbleState extends State { ), if (senderPrefixHex != null) _detailRow( - context, + sheetContext, label: l10n.senderKey, value: senderPrefixHex, onCopy: () => copyField(senderPrefixHex), ), if (recipientName != null) _detailRow( - context, + sheetContext, label: l10n.recipient, value: recipientName, ), if (recipientPrefixHex != null) _detailRow( - context, + sheetContext, label: l10n.recipientKey, value: recipientPrefixHex, onCopy: () => copyField(recipientPrefixHex), @@ -982,18 +982,18 @@ class _MessageBubbleState extends State { if (widget.message.isVoice) ...[ const SizedBox(height: 12), _techSection( - context, + sheetContext, icon: Icons.graphic_eq, title: l10n.voice, child: Column( children: [ _detailRow( - context, + sheetContext, label: l10n.voiceId, value: widget.message.voiceId ?? '-', ), _detailRow( - context, + sheetContext, label: l10n.envelope, value: envelope != null ? 'VE3 compact' @@ -1001,14 +1001,14 @@ class _MessageBubbleState extends State { ), if (voiceSession != null) _detailRow( - context, + sheetContext, label: l10n.sessionProgress, value: '${voiceSession.receivedCount}/${voiceSession.total} segments', ), if (voiceSession != null) _detailRow( - context, + sheetContext, label: l10n.complete, value: voiceSession.isComplete ? l10n.yes @@ -1016,14 +1016,14 @@ class _MessageBubbleState extends State { ), if (transferDetails != null) _detailRow( - context, + sheetContext, label: 'Transfers', value: '${transferDetails.totalTransfers}', ), if (transferDetails != null && transferDetails.downloaders.isNotEmpty) _detailRow( - context, + sheetContext, label: 'Downloaded by', value: _formatDownloaderSummary( transferDetails, @@ -1031,7 +1031,7 @@ class _MessageBubbleState extends State { ), if (voiceTxEstimate > Duration.zero) _detailRow( - context, + sheetContext, label: 'Estimated tx', value: voiceTxEstimate.inSeconds < 60 ? '~${voiceTxEstimate.inSeconds}s' @@ -1044,29 +1044,29 @@ class _MessageBubbleState extends State { if (imageEnvelope != null) ...[ const SizedBox(height: 12), _techSection( - context, + sheetContext, icon: Icons.image_outlined, title: 'Image', child: Column( children: [ _detailRow( - context, + sheetContext, label: l10n.envelope, value: 'IE1', ), _detailRow( - context, + sheetContext, label: 'Format', value: imageEnvelope.format.label, ), _detailRow( - context, + sheetContext, label: 'Dimensions', value: '${imageEnvelope.width}×${imageEnvelope.height}', ), _detailRow( - context, + sheetContext, label: 'Segments', value: imageSession != null ? '${imageSession.receivedCount}/${imageSession.total}' @@ -1074,7 +1074,7 @@ class _MessageBubbleState extends State { ), if (imageSession != null) _detailRow( - context, + sheetContext, label: l10n.complete, value: imageSession.isComplete ? l10n.yes @@ -1082,14 +1082,14 @@ class _MessageBubbleState extends State { ), if (transferDetails != null) _detailRow( - context, + sheetContext, label: 'Transfers', value: '${transferDetails.totalTransfers}', ), if (transferDetails != null && transferDetails.downloaders.isNotEmpty) _detailRow( - context, + sheetContext, label: 'Downloaded by', value: _formatDownloaderSummary( transferDetails, @@ -1097,7 +1097,7 @@ class _MessageBubbleState extends State { ), if (imageTxEstimate > Duration.zero) _detailRow( - context, + sheetContext, label: 'Estimated tx', value: imageTxEstimate.inSeconds < 60 ? '~${imageTxEstimate.inSeconds}s' @@ -1124,7 +1124,7 @@ class _MessageBubbleState extends State { width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Theme.of(context) + color: Theme.of(sheetContext) .colorScheme .surfaceContainerHighest .withValues(alpha: 0.35),