Fix BottomSheet ancestor lookup

This commit is contained in:
Janez T
2026-03-16 21:46:37 +01:00
parent 1fe260b95e
commit 434d3df53e
11 changed files with 377 additions and 250 deletions

View File

@@ -84,6 +84,8 @@ PODS:
- Flutter - Flutter
- vibration (3.0.0): - vibration (3.0.0):
- Flutter - Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
@@ -108,6 +110,7 @@ DEPENDENCIES:
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`) - vibration (from `.symlinks/plugins/vibration/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -161,6 +164,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
vibration: vibration:
:path: ".symlinks/plugins/vibration/ios" :path: ".symlinks/plugins/vibration/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
@@ -189,6 +194,7 @@ SPEC CHECKSUMS:
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878 PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878

View File

@@ -1723,6 +1723,16 @@ class AppProvider with ChangeNotifier {
connectionProvider.resolveContactForDmCallback = (contactPublicKey) { connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
return contactsProvider.findContactByKey(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 = messagesProvider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async { ({required messageId, required contact, required message}) async {
return _sendWithFinalNearestRouterFallback( return _sendWithFinalNearestRouterFallback(

View File

@@ -586,7 +586,12 @@ class ContactsProvider with ChangeNotifier {
); );
_contacts[contact.publicKeyHex] = updatedContact; _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( debugPrint(
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
); );
@@ -615,7 +620,10 @@ class ContactsProvider with ChangeNotifier {
incomingContact: contact, incomingContact: contact,
existingContact: existingContact, existingContact: existingContact,
); );
_pendingAdverts.remove(contact.publicKeyHex); if (contact.type != ContactType.repeater &&
contact.type != ContactType.sensor) {
_pendingAdverts.remove(contact.publicKeyHex);
}
} }
if (excluded > 0) { if (excluded > 0) {
debugPrint( debugPrint(

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math;
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
@@ -19,10 +20,18 @@ class MessageRetryManager {
final Map<String, DateTime> _lastRetryTimes = {}; final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {}; final Map<String, int> _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. // Retry backoff values in milliseconds.
static const List<int> _retryDelays = [1000, 2000, 4000, 8000]; static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
static const int _defaultLoRaSf = 10; static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5; static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000; static const int _defaultLoRaBwHz = 250000;
@@ -38,32 +47,54 @@ class MessageRetryManager {
return _retryDelays[attempt]; return _retryDelays[attempt];
} }
/// Calculate a conservative delivery-ACK timeout when firmware doesn't static final math.Random _rng = math.Random();
/// provide one or returns an invalid value.
/// 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({ int calculateAckTimeoutMs({
required String text, required String text,
required Contact? contact, required Contact? contact,
int? suggestedTimeoutMs, int? suggestedTimeoutMs,
}) { }) {
int baseTimeout;
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) { 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; // Add random jitter: 1000-8000ms (matches official app)
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes); final jitterMs = 1000 + _rng.nextInt(7001);
final hopCount = contact?.routeHasPath == true return baseTimeout + jitterMs;
? contact!.routeHopCount }
: -1;
if (hopCount < 0) { /// Max attempts for a given contact based on whether it has a known path.
return ((airtimeMs * 10) + 4000).clamp(10000, 30000); static int maxRetryAttemptsForContact(Contact? contact) {
} final hasPath = contact?.routeHasPath ?? false;
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
} }
bool canRetry(Message message, Contact contact) { 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 /// Track a retry attempt for a message

View File

@@ -96,6 +96,10 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})? Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback; 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<void> Function(Contact contact)? resetPathBeforeLastRetryCallback;
void Function(String messageId)? onManualRetryPreparedCallback; void Function(String messageId)? onManualRetryPreparedCallback;
Future<bool> Function({ Future<bool> Function({
required String messageId, required String messageId,
@@ -2316,7 +2320,7 @@ class MessagesProvider with ChangeNotifier {
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt); final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
debugPrint( debugPrint(
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId', '🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttemptsForContact(contact)} for message $messageId',
); );
debugPrint(' Delay: ${delayMs}ms'); debugPrint(' Delay: ${delayMs}ms');
@@ -2356,6 +2360,17 @@ class MessagesProvider with ChangeNotifier {
return; 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) { if (sendMessageCallback != null) {
final queued = await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,

View File

@@ -154,7 +154,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Import Contact')), appBar: AppBar(title: const Text('Add Contact')),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -315,7 +315,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
? 'Importing...' ? 'Importing...'
: _importSucceeded : _importSucceeded
? 'Imported' ? 'Imported'
: 'Import Contact', : 'Add Contact',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -675,7 +675,7 @@ class _ContactsTabState extends State<ContactsTab> {
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context), onPressed: () => _openAddContactScreen(context),
icon: const Icon(Icons.person_add_alt_1_outlined), 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<ContactsTab> {
title: l10n.repeaters, title: l10n.repeaters,
count: repeaters.length, count: repeaters.length,
icon: Icons.router, icon: Icons.router,
trailing: _buildSortMenu(context, ContactSection.repeaters), trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
IconButton(
icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover repeaters',
visualDensity: VisualDensity.compact,
onPressed: () {
context.read<ConnectionProvider>().discoverNodeType(advertType: 2);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Repeater discovery sent')),
);
},
),
_buildSortMenu(context, ContactSection.repeaters),
],
),
), ),
_buildSectionFilterField( _buildSectionFilterField(
context, context,
@@ -789,7 +806,24 @@ class _ContactsTabState extends State<ContactsTab> {
title: 'Sensors', title: 'Sensors',
count: sensors.length, count: sensors.length,
icon: Icons.sensors, icon: Icons.sensors,
trailing: _buildSortMenu(context, ContactSection.sensors), trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
IconButton(
icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover sensors',
visualDensity: VisualDensity.compact,
onPressed: () {
context.read<ConnectionProvider>().discoverNodeType(advertType: 4);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sensor discovery sent')),
);
},
),
_buildSortMenu(context, ContactSection.sensors),
],
),
), ),
_buildSectionFilterField( _buildSectionFilterField(
context, context,
@@ -887,7 +921,7 @@ class _ContactsTabState extends State<ContactsTab> {
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context), onPressed: () => _openAddContactScreen(context),
icon: const Icon(Icons.person_add_alt_1_outlined), icon: const Icon(Icons.person_add_alt_1_outlined),
label: const Text('Import Contact'), label: const Text('Add Contact'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 24, horizontal: 24,

View File

@@ -426,6 +426,16 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final isConnected = connectionProvider.deviceInfo.isConnected; final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[]; final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
// Track which pending adverts are already in the contacts list
final resolvedKeySet = <String>{};
for (final advert in pendingAdverts) {
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
resolvedKeySet.add(advert.publicKeyHex);
}
}
final totalCount = pendingAdverts.length;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -442,7 +452,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Pending discoveries (${pendingAdverts.length})', 'Discovered nodes ($totalCount)',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
@@ -495,7 +505,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (pendingAdverts.isEmpty) if (totalCount == 0)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 48), padding: const EdgeInsets.symmetric(vertical: 48),
child: Column( child: Column(
@@ -507,112 +517,29 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No pending discoveries', 'No discovered nodes',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
], ],
), ),
), ),
...pendingAdverts.map((advert) { ...pendingAdverts.map((advert) =>
final isResolving = _resolvingAdvertKeys.contains( _buildPendingAdvertCard(
advert.publicKeyHex, context,
); advert: advert,
final displayName = _displayNameForAdvert( contactsProvider: contactsProvider,
advert, isConnected: isConnected,
contactsProvider, isResolved: resolvedKeySet.contains(advert.publicKeyHex),
cachedNodes, cachedNodes: cachedNodes,
); l10n: l10n,
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 = <String>[
'${l10n.publicKey}: ${advert.shortDisplayKey}',
];
final summaryParts = <String>[];
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,
),
],
),
),
);
}),
], ],
); );
}, },
@@ -621,6 +548,105 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
); );
} }
Widget _buildPendingAdvertCard(
BuildContext context, {
required PendingAdvert advert,
required ContactsProvider contactsProvider,
required bool isConnected,
required bool isResolved,
required List<MeshMapNode> 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 = <String>[
'${l10n.publicKey}: ${advert.shortDisplayKey}',
];
final summaryParts = <String>[];
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( Widget _buildSignalSummary(
BuildContext context, { BuildContext context, {
SignalMetric? downMetric, SignalMetric? downMetric,

View File

@@ -402,45 +402,6 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
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({ Widget _buildCompactActivityIndicator({
required bool rxActive, required bool rxActive,
required bool txActive, required bool txActive,
@@ -1186,48 +1147,13 @@ class _HomeScreenState extends State<HomeScreen>
), ),
); );
}, },
child: isTight child: _buildCompactActivityIndicator(
? _buildCompactActivityIndicator(
rxActive: provider.rxActivity, rxActive: provider.rxActivity,
txActive: provider.txActivity, 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 else
SizedBox(width: isTight ? 24 : 74), const SizedBox(width: 24),
], ],
); );
}, },

View File

@@ -867,11 +867,63 @@ class _SensorCandidatePreview extends StatelessWidget {
} }
} }
class _EmptySensorsState extends StatelessWidget { class _EmptySensorsState extends StatefulWidget {
const _EmptySensorsState(); 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<void> _autoDiscover() async {
if (_discoveryTriggered || !mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) return;
_discoveryTriggered = true;
setState(() => _discoveryInProgress = true);
try {
await connectionProvider.discoverNodeType(advertType: 4);
} finally {
if (mounted) setState(() => _discoveryInProgress = false);
}
}
Future<void> _discoverSensors() async {
if (_discoveryInProgress || !mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isConnected =
context.watch<ConnectionProvider>().deviceInfo.isConnected;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 64), padding: const EdgeInsets.symmetric(vertical: 64),
child: Column( child: Column(
@@ -902,6 +954,25 @@ class _EmptySensorsState extends StatelessWidget {
textAlign: TextAlign.center, 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'),
),
], ],
), ),
); );

View File

@@ -731,25 +731,25 @@ class _MessageBubbleState extends State<MessageBubble> {
runSpacing: 8, runSpacing: 8,
children: [ children: [
_techBadge( _techBadge(
context, sheetContext,
icon: Icons.message, icon: Icons.message,
label: widget.message.messageType.name label: widget.message.messageType.name
.toUpperCase(), .toUpperCase(),
), ),
_techBadge( _techBadge(
context, sheetContext,
icon: Icons.route, icon: Icons.route,
label: hopDisplayLabel(widget.message), label: hopDisplayLabel(widget.message),
), ),
_techBadge( _techBadge(
context, sheetContext,
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
label: label:
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}', '${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
), ),
if (widget.message.channelIdx != null) if (widget.message.channelIdx != null)
_techBadge( _techBadge(
context, sheetContext,
icon: Icons.group_work, icon: Icons.group_work,
label: 'CH ${widget.message.channelIdx}', label: 'CH ${widget.message.channelIdx}',
), ),
@@ -760,14 +760,14 @@ class _MessageBubbleState extends State<MessageBubble> {
rssiDbm != null) ...[ rssiDbm != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_techSection( _techSection(
context, sheetContext,
icon: Icons.network_check, icon: Icons.network_check,
title: l10n.linkQuality, title: l10n.linkQuality,
child: Column( child: Column(
children: [ children: [
if (rssiDbm != null) if (rssiDbm != null)
_signalRow( _signalRow(
context, sheetContext,
label: 'RSSI', label: 'RSSI',
valueLabel: '$rssiDbm dBm', valueLabel: '$rssiDbm dBm',
normalized: normalized:
@@ -782,7 +782,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (snrDb != null) ...[ if (snrDb != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_signalRow( _signalRow(
context, sheetContext,
label: 'SNR', label: 'SNR',
valueLabel: '${snrDb.toStringAsFixed(1)} dB', valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp( normalized: ((snrDb + 20.0) / 40.0).clamp(
@@ -802,18 +802,18 @@ class _MessageBubbleState extends State<MessageBubble> {
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
_techSection( _techSection(
context, sheetContext,
icon: Icons.tune, icon: Icons.tune,
title: l10n.delivery, title: l10n.delivery,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
context, sheetContext,
label: l10n.status, label: l10n.status,
value: widget.message.deliveryStatus.name, value: widget.message.deliveryStatus.name,
), ),
_detailRow( _detailRow(
context, sheetContext,
label: 'Received (RFC3339)', label: 'Received (RFC3339)',
value: _formatRfc3339(widget.message.receivedAt), value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField( onCopy: () => copyField(
@@ -822,14 +822,14 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (widget.message.expectedAckTag != null) if (widget.message.expectedAckTag != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.expectedAckTag, label: l10n.expectedAckTag,
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (receptionDetails?.senderToReceiptMs != null) if (receptionDetails?.senderToReceiptMs != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Sender to receipt', label: 'Sender to receipt',
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!, receptionDetails!.senderToReceiptMs!,
@@ -837,7 +837,7 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (receptionDetails?.estimatedTransmitMs != null) if (receptionDetails?.estimatedTransmitMs != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Estimated tx', label: 'Estimated tx',
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!, receptionDetails!.estimatedTransmitMs!,
@@ -845,7 +845,7 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (receptionDetails?.postTransmitDelayMs != null) if (receptionDetails?.postTransmitDelayMs != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Post-tx delay', label: 'Post-tx delay',
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
@@ -853,44 +853,44 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (widget.receivedCopies > 1) if (widget.receivedCopies > 1)
_detailRow( _detailRow(
context, sheetContext,
label: 'Received copies', label: 'Received copies',
value: '${widget.receivedCopies}', value: '${widget.receivedCopies}',
), ),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'ACK timeout', label: 'ACK timeout',
value: value:
'${widget.message.suggestedTimeoutMs} ms', '${widget.message.suggestedTimeoutMs} ms',
), ),
if (retryCause != null) if (retryCause != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Retry cause', label: 'Retry cause',
value: retryCause, value: retryCause,
), ),
if (retryMode != null) if (retryMode != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Retry mode', label: 'Retry mode',
value: retryMode, value: retryMode,
), ),
if (widget.message.roundTripTimeMs != null) if (widget.message.roundTripTimeMs != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.roundTrip, label: l10n.roundTrip,
value: '${widget.message.roundTripTimeMs} ms', value: '${widget.message.roundTripTimeMs} ms',
), ),
if (widget.message.retryAttempt > 0) if (widget.message.retryAttempt > 0)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.retryAttempt, label: l10n.retryAttempt,
value: '${widget.message.retryAttempt}/4', value: '${widget.message.retryAttempt}/4',
), ),
if (widget.message.lastRetryAt != null) if (widget.message.lastRetryAt != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Last retry', label: 'Last retry',
value: _formatRfc3339( value: _formatRfc3339(
widget.message.lastRetryAt!, widget.message.lastRetryAt!,
@@ -901,33 +901,33 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (widget.message.usedFloodFallback) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.floodFallback, label: l10n.floodFallback,
value: l10n.yes, value: l10n.yes,
), ),
if (routeMetadata?.relayName case final relayName?) if (routeMetadata?.relayName case final relayName?)
_detailRow( _detailRow(
context, sheetContext,
label: 'Relay', label: 'Relay',
value: relayName, value: relayName,
), ),
if (routeMetadata?.canonicalPath if (routeMetadata?.canonicalPath
case final routePath?) case final routePath?)
_detailRow( _detailRow(
context, sheetContext,
label: 'Selected path', label: 'Selected path',
value: routePath, value: routePath,
onCopy: () => copyField(routePath), onCopy: () => copyField(routePath),
), ),
if (retryResult != null) if (retryResult != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Retry result', label: 'Retry result',
value: retryResult, value: retryResult,
), ),
if (packetPathHex != null) if (packetPathHex != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Path bytes', label: 'Path bytes',
value: packetPathHex, value: packetPathHex,
onCopy: () => copyField(packetPathHex), onCopy: () => copyField(packetPathHex),
@@ -937,19 +937,19 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_techSection( _techSection(
context, sheetContext,
icon: Icons.badge, icon: Icons.badge,
title: l10n.identity, title: l10n.identity,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
context, sheetContext,
label: l10n.messageId, label: l10n.messageId,
value: widget.message.id, value: widget.message.id,
onCopy: () => copyField(widget.message.id), onCopy: () => copyField(widget.message.id),
), ),
_detailRow( _detailRow(
context, sheetContext,
label: l10n.sender, label: l10n.sender,
value: value:
senderName ?? senderName ??
@@ -958,20 +958,20 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (senderPrefixHex != null) if (senderPrefixHex != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.senderKey, label: l10n.senderKey,
value: senderPrefixHex, value: senderPrefixHex,
onCopy: () => copyField(senderPrefixHex), onCopy: () => copyField(senderPrefixHex),
), ),
if (recipientName != null) if (recipientName != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.recipient, label: l10n.recipient,
value: recipientName, value: recipientName,
), ),
if (recipientPrefixHex != null) if (recipientPrefixHex != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.recipientKey, label: l10n.recipientKey,
value: recipientPrefixHex, value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex), onCopy: () => copyField(recipientPrefixHex),
@@ -982,18 +982,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.isVoice) ...[ if (widget.message.isVoice) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_techSection( _techSection(
context, sheetContext,
icon: Icons.graphic_eq, icon: Icons.graphic_eq,
title: l10n.voice, title: l10n.voice,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
context, sheetContext,
label: l10n.voiceId, label: l10n.voiceId,
value: widget.message.voiceId ?? '-', value: widget.message.voiceId ?? '-',
), ),
_detailRow( _detailRow(
context, sheetContext,
label: l10n.envelope, label: l10n.envelope,
value: envelope != null value: envelope != null
? 'VE3 compact' ? 'VE3 compact'
@@ -1001,14 +1001,14 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.sessionProgress, label: l10n.sessionProgress,
value: value:
'${voiceSession.receivedCount}/${voiceSession.total} segments', '${voiceSession.receivedCount}/${voiceSession.total} segments',
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.complete, label: l10n.complete,
value: voiceSession.isComplete value: voiceSession.isComplete
? l10n.yes ? l10n.yes
@@ -1016,14 +1016,14 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Transfers', label: 'Transfers',
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
context, sheetContext,
label: 'Downloaded by', label: 'Downloaded by',
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
@@ -1031,7 +1031,7 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
context, sheetContext,
label: 'Estimated tx', label: 'Estimated tx',
value: voiceTxEstimate.inSeconds < 60 value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s' ? '~${voiceTxEstimate.inSeconds}s'
@@ -1044,29 +1044,29 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageEnvelope != null) ...[ if (imageEnvelope != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_techSection( _techSection(
context, sheetContext,
icon: Icons.image_outlined, icon: Icons.image_outlined,
title: 'Image', title: 'Image',
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
context, sheetContext,
label: l10n.envelope, label: l10n.envelope,
value: 'IE1', value: 'IE1',
), ),
_detailRow( _detailRow(
context, sheetContext,
label: 'Format', label: 'Format',
value: imageEnvelope.format.label, value: imageEnvelope.format.label,
), ),
_detailRow( _detailRow(
context, sheetContext,
label: 'Dimensions', label: 'Dimensions',
value: value:
'${imageEnvelope.width}×${imageEnvelope.height}', '${imageEnvelope.width}×${imageEnvelope.height}',
), ),
_detailRow( _detailRow(
context, sheetContext,
label: 'Segments', label: 'Segments',
value: imageSession != null value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}' ? '${imageSession.receivedCount}/${imageSession.total}'
@@ -1074,7 +1074,7 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (imageSession != null) if (imageSession != null)
_detailRow( _detailRow(
context, sheetContext,
label: l10n.complete, label: l10n.complete,
value: imageSession.isComplete value: imageSession.isComplete
? l10n.yes ? l10n.yes
@@ -1082,14 +1082,14 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
context, sheetContext,
label: 'Transfers', label: 'Transfers',
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
context, sheetContext,
label: 'Downloaded by', label: 'Downloaded by',
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
@@ -1097,7 +1097,7 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
context, sheetContext,
label: 'Estimated tx', label: 'Estimated tx',
value: imageTxEstimate.inSeconds < 60 value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s' ? '~${imageTxEstimate.inSeconds}s'
@@ -1124,7 +1124,7 @@ class _MessageBubbleState extends State<MessageBubble> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(sheetContext)
.colorScheme .colorScheme
.surfaceContainerHighest .surfaceContainerHighest
.withValues(alpha: 0.35), .withValues(alpha: 0.35),