mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix BottomSheet ancestor lookup
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String, DateTime> _lastRetryTimes = {};
|
||||
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.
|
||||
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 _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
|
||||
|
||||
@@ -96,6 +96,10 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
Future<void> 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<void> Function(Contact contact)? resetPathBeforeLastRetryCallback;
|
||||
void Function(String messageId)? onManualRetryPreparedCallback;
|
||||
Future<bool> 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,
|
||||
|
||||
@@ -154,7 +154,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
||||
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<AddContactScreen> {
|
||||
? 'Importing...'
|
||||
: _importSucceeded
|
||||
? 'Imported'
|
||||
: 'Import Contact',
|
||||
: 'Add Contact',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -675,7 +675,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
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<ContactsTab> {
|
||||
title: l10n.repeaters,
|
||||
count: repeaters.length,
|
||||
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(
|
||||
context,
|
||||
@@ -789,7 +806,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
title: 'Sensors',
|
||||
count: sensors.length,
|
||||
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(
|
||||
context,
|
||||
@@ -887,7 +921,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
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,
|
||||
|
||||
@@ -426,6 +426,16 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
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(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@@ -442,7 +452,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
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<DiscoveryScreen> {
|
||||
),
|
||||
),
|
||||
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<DiscoveryScreen> {
|
||||
),
|
||||
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 = <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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
...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<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(
|
||||
BuildContext context, {
|
||||
SignalMetric? downMetric,
|
||||
|
||||
@@ -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({
|
||||
required bool rxActive,
|
||||
required bool txActive,
|
||||
@@ -1186,48 +1147,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
);
|
||||
},
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<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
|
||||
Widget build(BuildContext context) {
|
||||
final isConnected =
|
||||
context.watch<ConnectionProvider>().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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -731,25 +731,25 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
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<MessageBubble> {
|
||||
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<MessageBubble> {
|
||||
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<MessageBubble> {
|
||||
],
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
),
|
||||
if (receptionDetails?.estimatedTransmitMs != null)
|
||||
_detailRow(
|
||||
context,
|
||||
sheetContext,
|
||||
label: 'Estimated tx',
|
||||
value: _formatDurationMs(
|
||||
receptionDetails!.estimatedTransmitMs!,
|
||||
@@ -845,7 +845,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
if (receptionDetails?.postTransmitDelayMs != null)
|
||||
_detailRow(
|
||||
context,
|
||||
sheetContext,
|
||||
label: 'Post-tx delay',
|
||||
value: _formatDurationMs(
|
||||
receptionDetails!.postTransmitDelayMs!,
|
||||
@@ -853,44 +853,44 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
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<MessageBubble> {
|
||||
),
|
||||
if (imageSession != null)
|
||||
_detailRow(
|
||||
context,
|
||||
sheetContext,
|
||||
label: l10n.complete,
|
||||
value: imageSession.isComplete
|
||||
? l10n.yes
|
||||
@@ -1082,14 +1082,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
),
|
||||
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<MessageBubble> {
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
color: Theme.of(sheetContext)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.35),
|
||||
|
||||
Reference in New Issue
Block a user