mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Summarize repo changes
This commit is contained in:
@@ -264,7 +264,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await _activeService.addOrUpdateContact(pendingOp.contact!);
|
await _activeService.addOrUpdateContact(pendingOp.contact!);
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
if (pendingOp.messageId != null) {
|
if (pendingOp.messageId != null) {
|
||||||
_messageDeliveryTracker.trackPendingDirectMessage(
|
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||||
@@ -1272,12 +1271,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...',
|
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmation...',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait briefly for some ACKs to arrive, then proceed anyway
|
// Wait for a slot to free up (delivery/timeout), max 500ms
|
||||||
// (User action shouldn't be blocked forever)
|
await _messageDeliveryTracker.waitForSlot();
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1353,30 +1351,14 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
attempt: retryAttempt,
|
attempt: retryAttempt,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (messageId != null) {
|
// Clear pending operation — any ERR_CODE_NOT_FOUND has already been
|
||||||
Future.delayed(const Duration(milliseconds: 350), () {
|
// handled synchronously by onContactNotFound before sendTextMessage returns.
|
||||||
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'ℹ️ [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
|
|
||||||
);
|
|
||||||
onMessageSent?.call(messageId, 0, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear pending operation after successful send (no error)
|
|
||||||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
|
||||||
if (effectiveContact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join(':');
|
.join(':');
|
||||||
// Use a small delay to allow error response to arrive before clearing
|
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
|
||||||
_pendingSendOperations.remove(operationId);
|
_pendingSendOperations.remove(operationId);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
|
|||||||
/// Map of ACK tag to timestamp for timeout cleanup
|
/// Map of ACK tag to timestamp for timeout cleanup
|
||||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||||
|
|
||||||
|
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||||
|
Completer<void>? _slotFreedCompleter;
|
||||||
|
|
||||||
/// Track a pending message ID before sending
|
/// Track a pending message ID before sending
|
||||||
///
|
///
|
||||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||||
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
|
|||||||
_messageIdToAckTag.remove(messageId);
|
_messageIdToAckTag.remove(messageId);
|
||||||
}
|
}
|
||||||
_ackTagTimestamps.remove(ackCode);
|
_ackTagTimestamps.remove(ackCode);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove ACK tag mapping by message ID
|
/// Remove ACK tag mapping by message ID
|
||||||
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
|
|||||||
if (ackTag != null) {
|
if (ackTag != null) {
|
||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
_pendingMessageIds.remove(messageId);
|
_pendingMessageIds.remove(messageId);
|
||||||
final emptyKeys = <String>[];
|
final emptyKeys = <String>[];
|
||||||
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
|
|||||||
_ackTagToMessageId.clear();
|
_ackTagToMessageId.clear();
|
||||||
_messageIdToAckTag.clear();
|
_messageIdToAckTag.clear();
|
||||||
_ackTagTimestamps.clear();
|
_ackTagTimestamps.clear();
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of pending ACK tags
|
/// Get count of pending ACK tags
|
||||||
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
|
|||||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||||
bool get shouldRateLimit => pendingCount >= 7;
|
bool get shouldRateLimit => pendingCount >= 7;
|
||||||
|
|
||||||
|
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||||
|
///
|
||||||
|
/// Returns immediately if not at the rate limit.
|
||||||
|
Future<void> waitForSlot({
|
||||||
|
Duration timeout = const Duration(milliseconds: 500),
|
||||||
|
}) async {
|
||||||
|
if (!shouldRateLimit) return;
|
||||||
|
_slotFreedCompleter ??= Completer<void>();
|
||||||
|
await _slotFreedCompleter!.future.timeout(
|
||||||
|
timeout,
|
||||||
|
onTimeout: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifySlotFreed() {
|
||||||
|
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||||
|
_slotFreedCompleter!.complete();
|
||||||
|
}
|
||||||
|
_slotFreedCompleter = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get oldest pending ACK timestamp (for debugging)
|
/// Get oldest pending ACK timestamp (for debugging)
|
||||||
DateTime? get oldestPendingTimestamp {
|
DateTime? get oldestPendingTimestamp {
|
||||||
if (_ackTagTimestamps.isEmpty) return null;
|
if (_ackTagTimestamps.isEmpty) return null;
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
final routeHashCounts = _RouteHashCounts.fromContacts(
|
final routeHashCounts = _RouteHashCounts.fromContacts(
|
||||||
contactsProvider?.contacts ?? const <Contact>[],
|
contactsProvider?.contacts ?? const <Contact>[],
|
||||||
);
|
);
|
||||||
final packetTypes = unfilteredSnapshot.visibleEntries
|
final packetTypes =
|
||||||
|
unfilteredSnapshot.visibleEntries
|
||||||
.map((entry) => entry.payloadLabel)
|
.map((entry) => entry.payloadLabel)
|
||||||
.toSet()
|
.toSet()
|
||||||
.toList()
|
.toList()
|
||||||
@@ -202,7 +203,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.radar_rounded,
|
Icons.radar_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.45),
|
color: theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -218,7 +221,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
? 'This view only shows in-memory traffic while it is active.'
|
? 'This view only shows in-memory traffic while it is active.'
|
||||||
: 'Try a different packet type or switch back to All.',
|
: 'Try a different packet type or switch back to All.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -488,7 +493,9 @@ class _FilterChip extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)),
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: selected ? 0.45 : 0.22),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -641,8 +648,9 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final accent = isRx ? Colors.green : Colors.blue;
|
final accent = isRx ? Colors.green : Colors.blue;
|
||||||
final rxInfo = log.logRxDataInfo;
|
final rxInfo = log.logRxDataInfo;
|
||||||
final routePreview = _resolvedRoutePreview(context, entry);
|
|
||||||
final originDistance = _originDistanceLabel(context, entry);
|
final originDistance = _originDistanceLabel(context, entry);
|
||||||
|
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
|
||||||
|
final signalMetric = _SignalMetric.fromRxInfo(rxInfo);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -657,7 +665,10 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accent.withValues(alpha: 0.14),
|
color: accent.withValues(alpha: 0.14),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
@@ -677,7 +688,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
entry.payloadLabel,
|
packetDetails.title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -696,6 +707,10 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (signalMetric != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_CompactSignalIndicator(metric: signalMetric),
|
||||||
|
] else
|
||||||
Text(
|
Text(
|
||||||
_timeAgo(log.timestamp, now),
|
_timeAgo(log.timestamp, now),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -706,6 +721,18 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
_PacketInfoLine(
|
||||||
|
text:
|
||||||
|
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
|
||||||
|
),
|
||||||
|
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
|
||||||
|
if (packetDetails.pathLine != null)
|
||||||
|
_PacketInfoLine(text: packetDetails.pathLine!),
|
||||||
|
if (packetDetails.pathHashLine != null)
|
||||||
|
_PacketInfoLine(text: packetDetails.pathHashLine!),
|
||||||
|
if (packetDetails.endpointLine != null)
|
||||||
|
_PacketInfoLine(text: packetDetails.endpointLine!),
|
||||||
|
const SizedBox(height: 10),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@@ -714,29 +741,18 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
label: '${log.rawData.length} bytes',
|
label: '${log.rawData.length} bytes',
|
||||||
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
||||||
),
|
),
|
||||||
|
if (entry.isMultiHop)
|
||||||
|
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
|
||||||
|
if (originDistance != null)
|
||||||
|
_PacketMetaChip(label: 'Origin $originDistance'),
|
||||||
if (rxInfo?.rssiDbm != null)
|
if (rxInfo?.rssiDbm != null)
|
||||||
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
|
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
|
||||||
if (rxInfo?.snrDb != null)
|
if (rxInfo?.snrDb != null)
|
||||||
_PacketMetaChip(
|
_PacketMetaChip(
|
||||||
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
||||||
),
|
),
|
||||||
if (entry.hopCount != null)
|
|
||||||
_PacketMetaChip(label: '${entry.hopCount} hops'),
|
|
||||||
if (entry.isMultiHop)
|
|
||||||
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
|
|
||||||
if (originDistance != null)
|
|
||||||
_PacketMetaChip(label: 'Origin $originDistance'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
routePreview,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -748,6 +764,13 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
return '${diff.inMinutes}m ago';
|
return '${diff.inMinutes}m ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _formatClock(DateTime timestamp) {
|
||||||
|
final hour = timestamp.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = timestamp.minute.toString().padLeft(2, '0');
|
||||||
|
final second = timestamp.second.toString().padLeft(2, '0');
|
||||||
|
return '$hour:$minute:$second';
|
||||||
|
}
|
||||||
|
|
||||||
static String _resolvedRoutePreview(
|
static String _resolvedRoutePreview(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
LiveTrafficEntry entry,
|
LiveTrafficEntry entry,
|
||||||
@@ -856,11 +879,16 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
final hex = chunk
|
final hex = chunk
|
||||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||||
.join(' ');
|
.join(' ');
|
||||||
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex');
|
hexLines.add(
|
||||||
|
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final ascii = data
|
final ascii = data
|
||||||
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.')
|
.map(
|
||||||
|
(byte) =>
|
||||||
|
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
|
||||||
|
)
|
||||||
.join();
|
.join();
|
||||||
|
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
@@ -886,10 +914,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
|
||||||
'Hex',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -909,10 +934,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
|
||||||
'ASCII',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -951,6 +973,253 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _LiveTrafficPacketDetails {
|
||||||
|
final String title;
|
||||||
|
final String packetHashHex;
|
||||||
|
final String? pathLine;
|
||||||
|
final String? pathHashLine;
|
||||||
|
final String? endpointLine;
|
||||||
|
|
||||||
|
const _LiveTrafficPacketDetails({
|
||||||
|
required this.title,
|
||||||
|
required this.packetHashHex,
|
||||||
|
required this.pathLine,
|
||||||
|
required this.pathHashLine,
|
||||||
|
required this.endpointLine,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
|
||||||
|
final route = entry.route;
|
||||||
|
final payloadType = route?.payloadType;
|
||||||
|
final parsedPayload = _ParsedTrafficPayload.tryParse(
|
||||||
|
entry.log.rawData,
|
||||||
|
route,
|
||||||
|
);
|
||||||
|
final title = switch (payloadType) {
|
||||||
|
0x00 => 'FLOOD REQUEST',
|
||||||
|
0x01 => 'FLOOD RESPONSE',
|
||||||
|
0x02 => 'FLOOD TEXT',
|
||||||
|
0x03 => 'FLOOD ACK',
|
||||||
|
0x04 => 'FLOOD ADVERTISEMENT',
|
||||||
|
0x05 => 'FLOOD GROUP_TEXT',
|
||||||
|
0x06 => 'FLOOD GROUP_DATA',
|
||||||
|
0x07 => 'FLOOD ANON_REQUEST',
|
||||||
|
0x08 => 'FLOOD RETURNED_PATH',
|
||||||
|
0x09 => 'FLOOD TRACE_PATH',
|
||||||
|
0x0A => 'FLOOD MULTIPART',
|
||||||
|
0x0B => 'FLOOD CONTROL',
|
||||||
|
_ => entry.payloadLabel.toUpperCase(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final hopHashes = route?.hopHashes ?? const <String>[];
|
||||||
|
final pathLine = hopHashes.isEmpty
|
||||||
|
? null
|
||||||
|
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
|
||||||
|
final pathHashLine = route == null
|
||||||
|
? null
|
||||||
|
: 'Path Hashes: ${route.hashSize}-byte per hop';
|
||||||
|
|
||||||
|
return _LiveTrafficPacketDetails(
|
||||||
|
title: title,
|
||||||
|
packetHashHex: _packetHash(entry.log.rawData),
|
||||||
|
pathLine: pathLine,
|
||||||
|
pathHashLine: pathHashLine,
|
||||||
|
endpointLine: parsedPayload?.endpointLine,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _packetHash(List<int> bytes) {
|
||||||
|
const fnvOffset = 0xcbf29ce484222325;
|
||||||
|
const fnvPrime = 0x100000001b3;
|
||||||
|
const mask = 0xFFFFFFFFFFFFFFFF;
|
||||||
|
var hash = fnvOffset;
|
||||||
|
for (final byte in bytes) {
|
||||||
|
hash ^= byte & 0xFF;
|
||||||
|
hash = (hash * fnvPrime) & mask;
|
||||||
|
}
|
||||||
|
return hash.toRadixString(16).padLeft(16, '0').toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParsedTrafficPayload {
|
||||||
|
final String? endpointLine;
|
||||||
|
|
||||||
|
const _ParsedTrafficPayload({this.endpointLine});
|
||||||
|
|
||||||
|
static _ParsedTrafficPayload? tryParse(
|
||||||
|
List<int> rawData,
|
||||||
|
DecodedLogRxRoute? route,
|
||||||
|
) {
|
||||||
|
if (rawData.length < 5 ||
|
||||||
|
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawPacketData = rawData.sublist(3);
|
||||||
|
if (rawPacketData.length < 2) return null;
|
||||||
|
|
||||||
|
final header = rawPacketData[0];
|
||||||
|
final routeType = header & 0x03;
|
||||||
|
final payloadType = (header >> 2) & 0x0F;
|
||||||
|
|
||||||
|
var index = 1;
|
||||||
|
if (routeType == 0x00 || routeType == 0x03) {
|
||||||
|
if (rawPacketData.length < index + 5) return null;
|
||||||
|
index += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawPacketData.length <= index) return null;
|
||||||
|
final pathDescriptor = rawPacketData[index++];
|
||||||
|
final pathByteLen =
|
||||||
|
LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ??
|
||||||
|
(pathDescriptor == 0xFF ? 0 : null);
|
||||||
|
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
index += pathByteLen;
|
||||||
|
final payload = rawPacketData.sublist(index);
|
||||||
|
final senderHash = route?.hopHashes.isNotEmpty == true
|
||||||
|
? route!.hopHashes.first
|
||||||
|
: null;
|
||||||
|
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x05:
|
||||||
|
case 0x06:
|
||||||
|
if (payload.isEmpty) return const _ParsedTrafficPayload();
|
||||||
|
return _ParsedTrafficPayload(
|
||||||
|
endpointLine:
|
||||||
|
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
|
);
|
||||||
|
case 0x00:
|
||||||
|
case 0x01:
|
||||||
|
case 0x07:
|
||||||
|
if (payload.isEmpty) return const _ParsedTrafficPayload();
|
||||||
|
final destinationHash = payload.first
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0')
|
||||||
|
.toUpperCase();
|
||||||
|
return _ParsedTrafficPayload(
|
||||||
|
endpointLine: senderHash == null
|
||||||
|
? 'To: <$destinationHash>'
|
||||||
|
: 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const _ParsedTrafficPayload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PacketInfoLine extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _PacketInfoLine({required this.text});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignalMetric {
|
||||||
|
final String valueLabel;
|
||||||
|
final Color color;
|
||||||
|
final int activeBars;
|
||||||
|
|
||||||
|
const _SignalMetric({
|
||||||
|
required this.valueLabel,
|
||||||
|
required this.color,
|
||||||
|
required this.activeBars,
|
||||||
|
});
|
||||||
|
|
||||||
|
static _SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
|
||||||
|
if (rxInfo == null) return null;
|
||||||
|
if (rxInfo?.snrDb != null) {
|
||||||
|
final snr = rxInfo.snrDb!;
|
||||||
|
return _SignalMetric(
|
||||||
|
valueLabel: '${snr.toStringAsFixed(1)}dB',
|
||||||
|
color: snr >= 10
|
||||||
|
? Colors.green
|
||||||
|
: snr >= 0
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
activeBars: snr >= 10
|
||||||
|
? 3
|
||||||
|
: snr >= 0
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rxInfo?.rssiDbm != null) {
|
||||||
|
final rssi = rxInfo.rssiDbm!;
|
||||||
|
return _SignalMetric(
|
||||||
|
valueLabel: '$rssi dBm',
|
||||||
|
color: rssi >= -80
|
||||||
|
? Colors.green
|
||||||
|
: rssi >= -95
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
activeBars: rssi >= -80
|
||||||
|
? 3
|
||||||
|
: rssi >= -95
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompactSignalIndicator extends StatelessWidget {
|
||||||
|
final _SignalMetric metric;
|
||||||
|
|
||||||
|
const _CompactSignalIndicator({required this.metric});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final inactive = Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < 3; index++) ...[
|
||||||
|
if (index > 0) const SizedBox(width: 3),
|
||||||
|
Container(
|
||||||
|
width: 5,
|
||||||
|
height: 10.0 + (index * 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: index < metric.activeBars ? metric.color : inactive,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
metric.valueLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _GeoPoint {
|
class _GeoPoint {
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
final double longitude;
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
Timer? _channelReadTimer;
|
Timer? _channelReadTimer;
|
||||||
String? _pendingChannelReadKey;
|
String? _pendingChannelReadKey;
|
||||||
TextEditingValue _lastComposerValue = const TextEditingValue();
|
|
||||||
bool _isMentionPickerOpen = false;
|
|
||||||
bool _suppressMentionTrigger = false;
|
bool _suppressMentionTrigger = false;
|
||||||
|
TextRange? _activeMentionRange;
|
||||||
|
String _mentionQuery = '';
|
||||||
|
List<Contact> _mentionSuggestions = const [];
|
||||||
|
|
||||||
// Message destination state
|
// Message destination state
|
||||||
String _destinationType =
|
String _destinationType =
|
||||||
@@ -92,8 +93,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_lastComposerValue = _textController.value;
|
|
||||||
_textController.addListener(_handleComposerChanged);
|
_textController.addListener(_handleComposerChanged);
|
||||||
|
_focusNode.addListener(_handleFocusChanged);
|
||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceSettings();
|
_loadVoiceSettings();
|
||||||
@@ -126,6 +127,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_channelReadTimer?.cancel();
|
_channelReadTimer?.cancel();
|
||||||
_voiceStreamSub?.cancel();
|
_voiceStreamSub?.cancel();
|
||||||
_voiceRecorder.dispose();
|
_voiceRecorder.dispose();
|
||||||
|
_focusNode.removeListener(_handleFocusChanged);
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
@@ -195,25 +197,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleComposerChanged() {
|
void _handleComposerChanged() {
|
||||||
final previousValue = _lastComposerValue;
|
|
||||||
final currentValue = _textController.value;
|
|
||||||
_lastComposerValue = currentValue;
|
|
||||||
|
|
||||||
_updateCharacterCount();
|
_updateCharacterCount();
|
||||||
|
|
||||||
if (_suppressMentionTrigger || _isMentionPickerOpen) {
|
if (_suppressMentionTrigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final mentionTriggerRange = _getMentionTriggerRange(
|
_updateMentionSuggestions(_textController.value);
|
||||||
previousValue: previousValue,
|
|
||||||
currentValue: currentValue,
|
|
||||||
);
|
|
||||||
if (mentionTriggerRange == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateCharacterCount() {
|
void _updateCharacterCount() {
|
||||||
@@ -222,43 +212,112 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRange? _getMentionTriggerRange({
|
void _handleFocusChanged() {
|
||||||
required TextEditingValue previousValue,
|
if (!_focusNode.hasFocus) {
|
||||||
required TextEditingValue currentValue,
|
_clearMentionSuggestions();
|
||||||
}) {
|
return;
|
||||||
if (!previousValue.selection.isValid ||
|
}
|
||||||
!currentValue.selection.isValid ||
|
|
||||||
!previousValue.selection.isCollapsed ||
|
if (_suppressMentionTrigger) {
|
||||||
!currentValue.selection.isCollapsed) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMentionSuggestions(_textController.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateMentionSuggestions(TextEditingValue value) {
|
||||||
|
final triggerRange = _getMentionTriggerRange(value);
|
||||||
|
if (triggerRange == null) {
|
||||||
|
_clearMentionSuggestions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = value.text.substring(
|
||||||
|
triggerRange.start + 1,
|
||||||
|
triggerRange.end,
|
||||||
|
);
|
||||||
|
final contacts = _buildMentionSuggestions(query);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_activeMentionRange = triggerRange;
|
||||||
|
_mentionQuery = query;
|
||||||
|
_mentionSuggestions = contacts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearMentionSuggestions() {
|
||||||
|
if (_activeMentionRange == null &&
|
||||||
|
_mentionQuery.isEmpty &&
|
||||||
|
_mentionSuggestions.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_activeMentionRange = null;
|
||||||
|
_mentionQuery = '';
|
||||||
|
_mentionSuggestions = const [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRange? _getMentionTriggerRange(TextEditingValue value) {
|
||||||
|
if (!value.selection.isValid || !value.selection.isCollapsed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final previousOffset = previousValue.selection.baseOffset;
|
final cursorOffset = value.selection.baseOffset;
|
||||||
final currentOffset = currentValue.selection.baseOffset;
|
if (cursorOffset <= 0) {
|
||||||
if (previousOffset < 0 || currentOffset < 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text.length != previousValue.text.length + 1 ||
|
final text = value.text;
|
||||||
currentOffset != previousOffset + 1) {
|
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
|
||||||
|
if (triggerStart == -1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text.substring(0, previousOffset) !=
|
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
|
||||||
previousValue.text.substring(0, previousOffset)) {
|
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text.substring(currentOffset) !=
|
final query = text.substring(triggerStart + 1, cursorOffset);
|
||||||
previousValue.text.substring(previousOffset)) {
|
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentValue.text[previousOffset] != '@') {
|
return TextRange(start: triggerStart, end: cursorOffset);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return TextRange(start: previousOffset, end: currentOffset);
|
List<Contact> _buildMentionSuggestions(String query) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
|
final contacts =
|
||||||
|
contactsProvider.contacts
|
||||||
|
.where((contact) => contact.type == ContactType.chat)
|
||||||
|
.where((contact) {
|
||||||
|
if (normalizedQuery.isEmpty) return true;
|
||||||
|
return contact.displayName.toLowerCase().contains(
|
||||||
|
normalizedQuery,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aName = a.displayName.toLowerCase();
|
||||||
|
final bName = b.displayName.toLowerCase();
|
||||||
|
final aStarts =
|
||||||
|
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
|
||||||
|
final bStarts =
|
||||||
|
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
|
||||||
|
if (aStarts != bStarts) {
|
||||||
|
return aStarts ? -1 : 1;
|
||||||
|
}
|
||||||
|
return aName.compareTo(bName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return contacts.take(8).toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
int get _maxMessageBytes =>
|
int get _maxMessageBytes =>
|
||||||
@@ -378,56 +437,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showMentionSelectorForRange(TextRange triggerRange) async {
|
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
final contacts = contactsProvider.contacts
|
|
||||||
.where((contact) => contact.type == ContactType.chat)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (contacts.isEmpty || !mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isMentionPickerOpen = true;
|
|
||||||
Contact? selectedContact;
|
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => RecipientSelectorSheet(
|
|
||||||
contacts: contacts,
|
|
||||||
rooms: const [],
|
|
||||||
channels: const [],
|
|
||||||
unreadCount: 0,
|
|
||||||
unreadCountsByPublicKey: {
|
|
||||||
for (final contact in contacts) contact.publicKeyHex: 0,
|
|
||||||
},
|
|
||||||
currentDestinationType: null,
|
|
||||||
currentRecipientPublicKey: null,
|
|
||||||
showAllOption: false,
|
|
||||||
onSelect: (_, recipient) {
|
|
||||||
selectedContact = recipient;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
_isMentionPickerOpen = false;
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedContact != null) {
|
|
||||||
_insertReplyMention(
|
|
||||||
selectedContact!.displayName,
|
|
||||||
replacementRange: triggerRange,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_focusNode.requestFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle recipient selection
|
/// Handle recipient selection
|
||||||
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -489,11 +498,19 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
selection: TextSelection.collapsed(offset: nextOffset),
|
selection: TextSelection.collapsed(offset: nextOffset),
|
||||||
composing: TextRange.empty,
|
composing: TextRange.empty,
|
||||||
);
|
);
|
||||||
_lastComposerValue = _textController.value;
|
|
||||||
_suppressMentionTrigger = false;
|
_suppressMentionTrigger = false;
|
||||||
|
_clearMentionSuggestions();
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _selectMention(Contact contact) {
|
||||||
|
_insertReplyMention(
|
||||||
|
contact.displayName,
|
||||||
|
replacementRange: _activeMentionRange,
|
||||||
|
);
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _replyToMessage(Message message) async {
|
Future<void> _replyToMessage(Message message) async {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -2040,6 +2057,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bottomPadding: composerBottomPadding,
|
bottomPadding: composerBottomPadding,
|
||||||
destinationLabel: _getDestinationLabel(),
|
destinationLabel: _getDestinationLabel(),
|
||||||
destinationAvatar: _buildDestinationAvatar(context),
|
destinationAvatar: _buildDestinationAvatar(context),
|
||||||
|
mentionSuggestions: _mentionSuggestions,
|
||||||
|
mentionQuery: _mentionQuery,
|
||||||
|
onMentionSelected: _selectMention,
|
||||||
onShowComposerActions: _showComposerActions,
|
onShowComposerActions: _showComposerActions,
|
||||||
onShowRecipientSelector: _showRecipientSelector,
|
onShowRecipientSelector: _showRecipientSelector,
|
||||||
onStartVoiceRecording: _startVoiceRecording,
|
onStartVoiceRecording: _startVoiceRecording,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/contact.dart';
|
||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../common/contact_avatar.dart';
|
||||||
|
|
||||||
class MessagesComposer extends StatelessWidget {
|
class MessagesComposer extends StatelessWidget {
|
||||||
final TextEditingController textController;
|
final TextEditingController textController;
|
||||||
@@ -17,6 +19,9 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
final double bottomPadding;
|
final double bottomPadding;
|
||||||
final String destinationLabel;
|
final String destinationLabel;
|
||||||
final Widget destinationAvatar;
|
final Widget destinationAvatar;
|
||||||
|
final List<Contact> mentionSuggestions;
|
||||||
|
final String mentionQuery;
|
||||||
|
final ValueChanged<Contact> onMentionSelected;
|
||||||
final VoidCallback onShowComposerActions;
|
final VoidCallback onShowComposerActions;
|
||||||
final VoidCallback onShowRecipientSelector;
|
final VoidCallback onShowRecipientSelector;
|
||||||
final Future<void> Function() onStartVoiceRecording;
|
final Future<void> Function() onStartVoiceRecording;
|
||||||
@@ -36,6 +41,9 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
required this.bottomPadding,
|
required this.bottomPadding,
|
||||||
required this.destinationLabel,
|
required this.destinationLabel,
|
||||||
required this.destinationAvatar,
|
required this.destinationAvatar,
|
||||||
|
required this.mentionSuggestions,
|
||||||
|
required this.mentionQuery,
|
||||||
|
required this.onMentionSelected,
|
||||||
required this.onShowComposerActions,
|
required this.onShowComposerActions,
|
||||||
required this.onShowRecipientSelector,
|
required this.onShowRecipientSelector,
|
||||||
required this.onStartVoiceRecording,
|
required this.onStartVoiceRecording,
|
||||||
@@ -50,6 +58,15 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (mentionSuggestions.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: _MentionSuggestionsCard(
|
||||||
|
suggestions: mentionSuggestions,
|
||||||
|
query: mentionQuery,
|
||||||
|
onSelected: onMentionSelected,
|
||||||
|
),
|
||||||
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -153,6 +170,96 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MentionSuggestionsCard extends StatelessWidget {
|
||||||
|
final List<Contact> suggestions;
|
||||||
|
final String query;
|
||||||
|
final ValueChanged<Contact> onSelected;
|
||||||
|
|
||||||
|
const _MentionSuggestionsCard({
|
||||||
|
required this.suggestions,
|
||||||
|
required this.query,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final title = query.isEmpty ? '@' : '@$query';
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
elevation: 10,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 360),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 14, 18, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
|
||||||
|
itemCount: suggestions.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final contact = suggestions[index];
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: () => onSelected(contact),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ContactAvatar(contact: contact, radius: 19),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
contact.displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ComposerActionButton extends StatelessWidget {
|
class _ComposerActionButton extends StatelessWidget {
|
||||||
final bool isRecording;
|
final bool isRecording;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|||||||
@@ -795,8 +795,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "27e65f4"
|
ref: "55e303a"
|
||||||
resolved-ref: "27e65f48b1b5dc3dbcafc0167c4c5755770116ba"
|
resolved-ref: "55e303aa71d9fd847cedd11bc1243504fbe363e0"
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ dependencies:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
url: https://github.com/dz0ny/meshcore_client.git
|
||||||
ref: "27e65f4"
|
ref: "55e303a"
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
|
|||||||
@@ -115,9 +115,15 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('1 pkt/min'), findsOneWidget);
|
expect(find.text('1 pkt/min'), findsOneWidget);
|
||||||
expect(find.text('Device total 7'), findsOneWidget);
|
expect(find.text('Device total 7'), findsOneWidget);
|
||||||
expect(find.text('Response'), findsWidgets);
|
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
|
||||||
expect(find.text('MULTI-HOP'), findsOneWidget);
|
expect(find.text('MULTI-HOP'), findsOneWidget);
|
||||||
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Hash:'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.textContaining('Path Hashes: 2-byte per hop'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('clear live view only resets transient screen state', (
|
testWidgets('clear live view only resets transient screen state', (
|
||||||
@@ -174,6 +180,6 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('No packets for this filter'), findsNothing);
|
expect(find.text('No packets for this filter'), findsNothing);
|
||||||
expect(find.textContaining('3 bytes'), findsOneWidget);
|
expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||||
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
||||||
@@ -16,10 +17,34 @@ import 'package:meshcore_sar_app/services/voice_codec_service.dart';
|
|||||||
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
Contact buildContact({
|
||||||
|
required String name,
|
||||||
|
required ContactType type,
|
||||||
|
int secondByte = 1,
|
||||||
|
}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = secondByte;
|
||||||
|
publicKey[1] = secondByte;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: type,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: 0,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
@@ -104,4 +129,83 @@ void main() {
|
|||||||
connectionProvider.dispose();
|
connectionProvider.dispose();
|
||||||
channelsProvider.dispose();
|
channelsProvider.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows mention overlay above composer and inserts selection', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connectionProvider = ConnectionProvider();
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
final messagesProvider = MessagesProvider();
|
||||||
|
final mapProvider = MapProvider();
|
||||||
|
final drawingProvider = DrawingProvider();
|
||||||
|
await messagesProvider.initialize();
|
||||||
|
await drawingProvider.initialize();
|
||||||
|
final channelsProvider = ChannelsProvider()..initializePublicChannel();
|
||||||
|
final voiceProvider = VoiceProvider(
|
||||||
|
codec: VoiceCodecService(),
|
||||||
|
player: VoicePlayerService(),
|
||||||
|
);
|
||||||
|
final imageProvider = ip.ImageProvider();
|
||||||
|
final appProvider = AppProvider(
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
messagesProvider: messagesProvider,
|
||||||
|
drawingProvider: drawingProvider,
|
||||||
|
channelsProvider: channelsProvider,
|
||||||
|
voiceProvider: voiceProvider,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
contactsProvider.addContacts([
|
||||||
|
buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2),
|
||||||
|
buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: connectionProvider),
|
||||||
|
ChangeNotifierProvider.value(value: contactsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: messagesProvider),
|
||||||
|
ChangeNotifierProvider.value(value: mapProvider),
|
||||||
|
ChangeNotifierProvider.value(value: drawingProvider),
|
||||||
|
ChangeNotifierProvider.value(value: channelsProvider),
|
||||||
|
ChangeNotifierProvider.value(value: voiceProvider),
|
||||||
|
ChangeNotifierProvider.value(value: imageProvider),
|
||||||
|
ChangeNotifierProvider.value(value: appProvider),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const Scaffold(body: MessagesTab(isActive: true)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
await tester.tap(find.byType(TextField).first);
|
||||||
|
await tester.enterText(find.byType(TextField).first, '@t');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Tim'), findsOneWidget);
|
||||||
|
expect(find.text('Slane'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Tim'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('@[Tim] '), findsOneWidget);
|
||||||
|
expect(find.text('Tim'), findsNothing);
|
||||||
|
|
||||||
|
appProvider.dispose();
|
||||||
|
voiceProvider.dispose();
|
||||||
|
imageProvider.dispose();
|
||||||
|
drawingProvider.dispose();
|
||||||
|
mapProvider.dispose();
|
||||||
|
messagesProvider.dispose();
|
||||||
|
contactsProvider.dispose();
|
||||||
|
connectionProvider.dispose();
|
||||||
|
channelsProvider.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user