mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -14,6 +14,7 @@ import 'providers/channels_provider.dart';
|
||||
import 'providers/voice_provider.dart';
|
||||
import 'providers/image_provider.dart' as ip;
|
||||
import 'providers/app_provider.dart';
|
||||
import 'providers/sensors_provider.dart';
|
||||
import 'services/voice_codec_service.dart';
|
||||
import 'services/voice_player_service.dart';
|
||||
import 'services/tile_cache_service.dart';
|
||||
@@ -234,6 +235,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||
|
||||
// Voice provider (packet reassembly + playback)
|
||||
ChangeNotifierProvider(
|
||||
|
||||
@@ -1,10 +1,172 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
class ParsedContactRoute {
|
||||
final String canonicalText;
|
||||
final int hashSize;
|
||||
final int hopCount;
|
||||
final int encodedPathLen;
|
||||
final int signedEncodedPathLen;
|
||||
final Uint8List pathBytes;
|
||||
final Uint8List paddedPathBytes;
|
||||
|
||||
const ParsedContactRoute({
|
||||
required this.canonicalText,
|
||||
required this.hashSize,
|
||||
required this.hopCount,
|
||||
required this.encodedPathLen,
|
||||
required this.signedEncodedPathLen,
|
||||
required this.pathBytes,
|
||||
required this.paddedPathBytes,
|
||||
});
|
||||
|
||||
int get byteLength => pathBytes.length;
|
||||
String get summary => hopCount == 0
|
||||
? 'Direct'
|
||||
: '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes';
|
||||
}
|
||||
|
||||
class ContactRouteFormatException implements Exception {
|
||||
final String message;
|
||||
|
||||
const ContactRouteFormatException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ContactRouteCodec {
|
||||
static const int maxHashSize = 3;
|
||||
static const int maxPathBytes = 64;
|
||||
static const int _unknownDescriptor = 0xFF;
|
||||
|
||||
static ParsedContactRoute parse(String input) {
|
||||
final normalized = input.trim().toUpperCase();
|
||||
if (normalized.isEmpty) {
|
||||
throw const ContactRouteFormatException('Route cannot be empty.');
|
||||
}
|
||||
|
||||
final hopTokens = normalized
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.toList();
|
||||
if (hopTokens.any((token) => token.isEmpty)) {
|
||||
throw const ContactRouteFormatException('Route contains an empty hop.');
|
||||
}
|
||||
|
||||
final hopBytes = <List<int>>[];
|
||||
int? hashSize;
|
||||
for (final token in hopTokens) {
|
||||
final compact = token.replaceAll(':', '');
|
||||
if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) {
|
||||
throw ContactRouteFormatException('Invalid hop "$token".');
|
||||
}
|
||||
if (compact.length.isOdd) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must contain full bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final currentHashSize = compact.length ~/ 2;
|
||||
if (currentHashSize < 1 || currentHashSize > maxHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
hashSize ??= currentHashSize;
|
||||
if (hashSize != currentHashSize) {
|
||||
throw const ContactRouteFormatException(
|
||||
'All hops in a route must use the same hash size.',
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < compact.length; i += 2) {
|
||||
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
hopBytes.add(bytes);
|
||||
}
|
||||
|
||||
final resolvedHashSize = hashSize ?? 1;
|
||||
final flatBytes = Uint8List.fromList(
|
||||
hopBytes.expand((hop) => hop).toList(),
|
||||
);
|
||||
if (flatBytes.length > maxPathBytes) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Route exceeds the 64-byte firmware limit.',
|
||||
);
|
||||
}
|
||||
|
||||
final encodedPathLen =
|
||||
((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F);
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, flatBytes.length, flatBytes);
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: hopBytes
|
||||
.map(
|
||||
(hop) => hop
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
)
|
||||
.join(','),
|
||||
hashSize: resolvedHashSize,
|
||||
hopCount: hopBytes.length,
|
||||
encodedPathLen: encodedPathLen,
|
||||
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||
pathBytes: flatBytes,
|
||||
paddedPathBytes: padded,
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute? fromContact(Contact contact) {
|
||||
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: contact.routeCanonicalText,
|
||||
hashSize: contact.routeHashSize,
|
||||
hopCount: contact.routeHopCount,
|
||||
encodedPathLen: contact.routeEncodedPathLen,
|
||||
signedEncodedPathLen: contact.routeSignedPathLen,
|
||||
pathBytes: contact.routePathBytes,
|
||||
paddedPathBytes: _padPath(contact.routePathBytes),
|
||||
);
|
||||
}
|
||||
|
||||
static Uint8List _padPath(Uint8List bytes) {
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes);
|
||||
return padded;
|
||||
}
|
||||
|
||||
static int toSignedDescriptor(int encodedPathLen) =>
|
||||
encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen;
|
||||
|
||||
static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF;
|
||||
|
||||
static bool isUnknownDescriptor(int signedPathLen) =>
|
||||
toUnsignedDescriptor(signedPathLen) == _unknownDescriptor;
|
||||
|
||||
static bool isValidDescriptor(int signedPathLen) {
|
||||
final raw = toUnsignedDescriptor(signedPathLen);
|
||||
if (raw == _unknownDescriptor) return false;
|
||||
final hashSize = ((raw >> 6) + 1);
|
||||
if (hashSize > maxHashSize) return false;
|
||||
final hopCount = raw & 0x3F;
|
||||
return hopCount * hashSize <= maxPathBytes;
|
||||
}
|
||||
}
|
||||
|
||||
extension ContactLocalization on Contact {
|
||||
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
||||
/// For all other contacts, returns [displayName].
|
||||
@@ -14,4 +176,56 @@ extension ContactLocalization on Contact {
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
int get routeEncodedPathLen =>
|
||||
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
|
||||
|
||||
int get routeSignedPathLen =>
|
||||
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
|
||||
|
||||
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
|
||||
|
||||
bool get routeHasPath =>
|
||||
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
|
||||
|
||||
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
|
||||
|
||||
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
|
||||
|
||||
int get routeByteLength => routeHasPath
|
||||
? math.min(routeHopCount * routeHashSize, outPath.length)
|
||||
: 0;
|
||||
|
||||
Uint8List get routePathBytes => routeByteLength <= 0
|
||||
? Uint8List(0)
|
||||
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
|
||||
|
||||
String get routeCanonicalText {
|
||||
if (!routeHasPath || routeHopCount <= 0) return '';
|
||||
final bytes = routePathBytes;
|
||||
final hops = <String>[];
|
||||
for (var i = 0; i < bytes.length; i += routeHashSize) {
|
||||
hops.add(
|
||||
bytes
|
||||
.sublist(i, i + routeHashSize)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return hops.join(',');
|
||||
}
|
||||
|
||||
String get routeSummary {
|
||||
if (routeIsUnknown || !routeHasPath) {
|
||||
return 'Flood/Unknown';
|
||||
}
|
||||
if (routeHopCount == 0) {
|
||||
return 'Direct';
|
||||
}
|
||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||
}
|
||||
|
||||
bool get routeSupportsLegacyRawTransport =>
|
||||
routeHasPath && routeSignedPathLen >= 0;
|
||||
}
|
||||
|
||||
86
lib/models/message_reception_details.dart
Normal file
86
lib/models/message_reception_details.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
const int _transmitEstimateToleranceMs = 1500;
|
||||
|
||||
int? sanitizeEstimatedTransmitMs({
|
||||
required int? estimatedTransmitMs,
|
||||
required int? senderToReceiptMs,
|
||||
}) {
|
||||
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
// Sender timestamps are second-granularity, so allow a small cushion before
|
||||
// treating the estimate as impossible for the observed delivery time.
|
||||
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
class MessageReceptionDetails {
|
||||
final DateTime capturedAt;
|
||||
final DateTime? packetLoggedAt;
|
||||
final int? rssiDbm;
|
||||
final double? snrDb;
|
||||
final List<int>? pathBytes;
|
||||
final int? senderToReceiptMs;
|
||||
final int? estimatedTransmitMs;
|
||||
final int? postTransmitDelayMs;
|
||||
|
||||
const MessageReceptionDetails({
|
||||
required this.capturedAt,
|
||||
this.packetLoggedAt,
|
||||
this.rssiDbm,
|
||||
this.snrDb,
|
||||
this.pathBytes,
|
||||
this.senderToReceiptMs,
|
||||
this.estimatedTransmitMs,
|
||||
this.postTransmitDelayMs,
|
||||
});
|
||||
|
||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||
? null
|
||||
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
|
||||
'rssiDbm': rssiDbm,
|
||||
'snrDb': snrDb,
|
||||
'pathBytes': pathBytes,
|
||||
'senderToReceiptMs': senderToReceiptMs,
|
||||
'estimatedTransmitMs': estimatedTransmitMs,
|
||||
'postTransmitDelayMs': postTransmitDelayMs,
|
||||
};
|
||||
}
|
||||
|
||||
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (capturedAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final pathBytes = json['pathBytes'];
|
||||
return MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||
packetLoggedAt: json['packetLoggedAtMillis'] is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['packetLoggedAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
rssiDbm: json['rssiDbm'] as int?,
|
||||
snrDb: (json['snrDb'] as num?)?.toDouble(),
|
||||
pathBytes: pathBytes is List
|
||||
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
|
||||
: null,
|
||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/models/message_transfer_details.dart
Normal file
142
lib/models/message_transfer_details.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
class MessageTransferDownloader {
|
||||
final String requesterKey6;
|
||||
final String? requesterName;
|
||||
final int transferCount;
|
||||
final DateTime lastTransferredAt;
|
||||
|
||||
const MessageTransferDownloader({
|
||||
required this.requesterKey6,
|
||||
this.requesterName,
|
||||
required this.transferCount,
|
||||
required this.lastTransferredAt,
|
||||
});
|
||||
|
||||
MessageTransferDownloader copyWith({
|
||||
String? requesterKey6,
|
||||
String? requesterName,
|
||||
int? transferCount,
|
||||
DateTime? lastTransferredAt,
|
||||
}) {
|
||||
return MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6 ?? this.requesterKey6,
|
||||
requesterName: requesterName ?? this.requesterName,
|
||||
transferCount: transferCount ?? this.transferCount,
|
||||
lastTransferredAt: lastTransferredAt ?? this.lastTransferredAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'requesterKey6': requesterKey6,
|
||||
'requesterName': requesterName,
|
||||
'transferCount': transferCount,
|
||||
'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
static MessageTransferDownloader? fromJson(Map<String, dynamic> json) {
|
||||
final requesterKey6 = json['requesterKey6'];
|
||||
final transferCount = json['transferCount'];
|
||||
final lastTransferredAtMillis = json['lastTransferredAtMillis'];
|
||||
if (requesterKey6 is! String ||
|
||||
transferCount is! int ||
|
||||
lastTransferredAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6,
|
||||
requesterName: json['requesterName'] as String?,
|
||||
transferCount: transferCount,
|
||||
lastTransferredAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
lastTransferredAtMillis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageTransferDetails {
|
||||
final int totalTransfers;
|
||||
final List<MessageTransferDownloader> downloaders;
|
||||
|
||||
const MessageTransferDetails({
|
||||
required this.totalTransfers,
|
||||
required this.downloaders,
|
||||
});
|
||||
|
||||
const MessageTransferDetails.empty()
|
||||
: totalTransfers = 0,
|
||||
downloaders = const [];
|
||||
|
||||
MessageTransferDetails registerTransfer({
|
||||
required String requesterKey6,
|
||||
String? requesterName,
|
||||
DateTime? transferredAt,
|
||||
}) {
|
||||
final eventAt = transferredAt ?? DateTime.now();
|
||||
final normalizedName = requesterName?.trim();
|
||||
final updatedDownloaders = List<MessageTransferDownloader>.from(
|
||||
downloaders,
|
||||
);
|
||||
final index = updatedDownloaders.indexWhere(
|
||||
(entry) => entry.requesterKey6 == requesterKey6,
|
||||
);
|
||||
|
||||
if (index == -1) {
|
||||
updatedDownloaders.add(
|
||||
MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6,
|
||||
requesterName: normalizedName?.isEmpty ?? true
|
||||
? null
|
||||
: normalizedName,
|
||||
transferCount: 1,
|
||||
lastTransferredAt: eventAt,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final existing = updatedDownloaders[index];
|
||||
updatedDownloaders[index] = existing.copyWith(
|
||||
requesterName: normalizedName?.isEmpty ?? true
|
||||
? existing.requesterName
|
||||
: normalizedName,
|
||||
transferCount: existing.transferCount + 1,
|
||||
lastTransferredAt: eventAt,
|
||||
);
|
||||
}
|
||||
|
||||
updatedDownloaders.sort(
|
||||
(a, b) => b.lastTransferredAt.compareTo(a.lastTransferredAt),
|
||||
);
|
||||
|
||||
return MessageTransferDetails(
|
||||
totalTransfers: totalTransfers + 1,
|
||||
downloaders: updatedDownloaders,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalTransfers': totalTransfers,
|
||||
'downloaders': downloaders.map((entry) => entry.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
static MessageTransferDetails? fromJson(Map<String, dynamic> json) {
|
||||
final totalTransfers = json['totalTransfers'];
|
||||
if (totalTransfers is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final rawDownloaders = json['downloaders'] as List<dynamic>? ?? const [];
|
||||
final downloaders = rawDownloaders
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MessageTransferDownloader.fromJson)
|
||||
.whereType<MessageTransferDownloader>()
|
||||
.toList();
|
||||
|
||||
return MessageTransferDetails(
|
||||
totalTransfers: totalTransfers,
|
||||
downloaders: downloaders,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/device_info.dart';
|
||||
import '../models/room_login_state.dart';
|
||||
import '../models/sse_server_config.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
|
||||
import '../services/sse_server_service.dart';
|
||||
import '../utils/sar_message_parser.dart';
|
||||
import 'helpers/room_login_manager.dart';
|
||||
@@ -146,11 +147,15 @@ class ConnectionProvider with ChangeNotifier {
|
||||
final MessageDeliveryTracker _messageDeliveryTracker =
|
||||
MessageDeliveryTracker();
|
||||
final PingTracker _pingTracker = PingTracker();
|
||||
final Map<String, Future<PingResult>> _pendingSmartPings = {};
|
||||
|
||||
// Expose room login states
|
||||
Map<String, RoomLoginState> get roomLoginStates =>
|
||||
_roomLoginManager.roomLoginStates;
|
||||
|
||||
bool isPingInProgress(Uint8List publicKey) =>
|
||||
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
|
||||
|
||||
// Callbacks for other providers
|
||||
Function(Contact)? onContactReceived;
|
||||
Function(List<Contact>)? onContactsComplete;
|
||||
@@ -1117,9 +1122,11 @@ class ConnectionProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
debugPrint(' Type: ${contact.type.displayName}');
|
||||
debugPrint(' Path status: ${contact.pathDescription}');
|
||||
if (contact.hasPath) {
|
||||
debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)');
|
||||
debugPrint(' Path status: ${contact.routeSummary}');
|
||||
if (contact.routeHasPath) {
|
||||
debugPrint(
|
||||
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
|
||||
);
|
||||
} else {
|
||||
debugPrint(' ⚠️ No path available - will use flood mode');
|
||||
}
|
||||
@@ -1169,6 +1176,19 @@ class ConnectionProvider with ChangeNotifier {
|
||||
attempt: retryAttempt,
|
||||
);
|
||||
|
||||
if (messageId != null) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
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 (contact != null) {
|
||||
@@ -1316,6 +1336,34 @@ class ConnectionProvider with ChangeNotifier {
|
||||
required Uint8List contactPublicKey,
|
||||
required bool hasPath,
|
||||
Function()? onRetryWithFlooding,
|
||||
}) async {
|
||||
final pingKey = _publicKeyToHex(contactPublicKey);
|
||||
final pendingPing = _pendingSmartPings[pingKey];
|
||||
if (pendingPing != null) {
|
||||
debugPrint('ℹ️ [Provider] Joining in-flight ping for $pingKey');
|
||||
return pendingPing;
|
||||
}
|
||||
|
||||
final future = _runSmartPing(
|
||||
contactPublicKey: contactPublicKey,
|
||||
hasPath: hasPath,
|
||||
onRetryWithFlooding: onRetryWithFlooding,
|
||||
);
|
||||
_pendingSmartPings[pingKey] = future;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
return await future;
|
||||
} finally {
|
||||
_pendingSmartPings.remove(pingKey);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<PingResult> _runSmartPing({
|
||||
required Uint8List contactPublicKey,
|
||||
required bool hasPath,
|
||||
Function()? onRetryWithFlooding,
|
||||
}) async {
|
||||
if (!_activeService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
@@ -1334,7 +1382,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
// Send the ping
|
||||
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
|
||||
await _activeService.requestTelemetry(
|
||||
contactPublicKey,
|
||||
zeroHop: firstAttemptDirect,
|
||||
);
|
||||
|
||||
// Wait for response or timeout
|
||||
final bool gotResponse = await pingFuture;
|
||||
@@ -1361,8 +1412,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
wasDirectAttempt: false,
|
||||
);
|
||||
|
||||
// Retry with flooding (zeroHop=true acts as broadcast to neighbors)
|
||||
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
|
||||
// Retry with flooding.
|
||||
await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
|
||||
|
||||
// Wait for response or timeout
|
||||
final bool gotRetryResponse = await retryFuture;
|
||||
@@ -1384,6 +1435,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
String _publicKeyToHex(Uint8List publicKey) {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
/// Send binary request to contact (modern replacement for requestTelemetry)
|
||||
///
|
||||
/// Supports multiple request types:
|
||||
@@ -1967,6 +2022,30 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setContactRoute(
|
||||
Contact contact, {
|
||||
required int signedEncodedPathLen,
|
||||
required Uint8List paddedPathBytes,
|
||||
}) async {
|
||||
if (!_activeService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final updatedContact = contact.copyWith(
|
||||
outPathLen: signedEncodedPathLen,
|
||||
outPath: Uint8List.fromList(paddedPathBytes),
|
||||
);
|
||||
await _activeService.addOrUpdateContact(updatedContact);
|
||||
} catch (e) {
|
||||
_error = 'Failed to set route: $e';
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a contact from the companion radio
|
||||
///
|
||||
/// Deletes the contact from the device's internal contact table.
|
||||
|
||||
@@ -423,9 +423,16 @@ class ContactsProvider with ChangeNotifier {
|
||||
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
|
||||
debugPrint(' New lastAdvert: $currentTimestamp');
|
||||
|
||||
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
|
||||
final updatedContact = contact.copyWith(
|
||||
telemetry: telemetry,
|
||||
lastAdvert: currentTimestamp, // Update last seen time
|
||||
advLat: persistedGps != null
|
||||
? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
|
||||
: contact.advLat,
|
||||
advLon: persistedGps != null
|
||||
? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
|
||||
: contact.advLon,
|
||||
);
|
||||
_contacts[contact.publicKeyHex] = updatedContact;
|
||||
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
|
||||
@@ -476,6 +483,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
return incomingGps == null;
|
||||
}
|
||||
|
||||
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
||||
return (coordinate * 1e6).round();
|
||||
}
|
||||
|
||||
/// Find contact by public key prefix (6 bytes)
|
||||
Contact? _findContactByPrefix(Uint8List prefix) {
|
||||
if (prefix.length < 6) return null;
|
||||
@@ -521,7 +532,39 @@ class ContactsProvider with ChangeNotifier {
|
||||
/// prefer flood routing until the radio reports a fresh route.
|
||||
void markPathUnhealthy(Uint8List publicKey) {
|
||||
final contact = findContactByKey(publicKey);
|
||||
if (contact == null || !contact.hasPath) {
|
||||
if (contact == null || !contact.routeHasPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
||||
outPathLen: -1,
|
||||
outPath: Uint8List(0),
|
||||
);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setContactRouteLocal(
|
||||
Uint8List publicKey, {
|
||||
required int signedEncodedPathLen,
|
||||
required Uint8List paddedPathBytes,
|
||||
}) {
|
||||
final contact = findContactByKey(publicKey);
|
||||
if (contact == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
||||
outPathLen: signedEncodedPathLen,
|
||||
outPath: Uint8List.fromList(paddedPathBytes),
|
||||
);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetContactRouteLocal(Uint8List publicKey) {
|
||||
final contact = findContactByKey(publicKey);
|
||||
if (contact == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ class MessageDeliveryTracker {
|
||||
return _ackTagToMessageId[ackCode];
|
||||
}
|
||||
|
||||
/// Returns true once a message has been matched to a concrete ACK tag.
|
||||
bool hasAckForMessage(String messageId) {
|
||||
return _messageIdToAckTag.containsKey(messageId);
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping after delivery confirmed or timeout
|
||||
///
|
||||
/// Cleans up both forward and reverse mappings.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
@@ -55,8 +54,8 @@ class MessageRetryManager {
|
||||
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.hasPath == true
|
||||
? math.max(contact!.outPathLen, 0)
|
||||
final hopCount = contact?.routeHasPath == true
|
||||
? contact!.routeHopCount
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
@@ -87,7 +86,7 @@ class MessageRetryManager {
|
||||
|
||||
// Only retry if contact has a learned path
|
||||
// If no path, the device uses flood mode automatically - retrying won't help
|
||||
return contact.hasPath;
|
||||
return contact.routeHasPath;
|
||||
}
|
||||
|
||||
/// Check if should fall back to flood mode
|
||||
@@ -101,7 +100,7 @@ class MessageRetryManager {
|
||||
/// Contacts without paths already use flood mode automatically.
|
||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
||||
return message.retryAttempt >= 3 &&
|
||||
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
|
||||
contact.routeHasPath &&
|
||||
!message.usedFloodFallback;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ class PingTracker {
|
||||
/// Mark a ping as successful (response received)
|
||||
/// Should be called when telemetry response arrives
|
||||
void markPingSuccessful(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
final request = _pendingPings.remove(keyHex);
|
||||
final requestKey = _findMatchingPendingPingKey(publicKey);
|
||||
final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
|
||||
|
||||
if (request != null) {
|
||||
request.cancel();
|
||||
@@ -81,6 +81,23 @@ class PingTracker {
|
||||
String _publicKeyToHex(Uint8List publicKey) {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
String? _findMatchingPendingPingKey(Uint8List responseKey) {
|
||||
final responseHex = _publicKeyToHex(responseKey);
|
||||
|
||||
if (_pendingPings.containsKey(responseHex)) {
|
||||
return responseHex;
|
||||
}
|
||||
|
||||
for (final entry in _pendingPings.entries) {
|
||||
final pendingHex = entry.key;
|
||||
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
|
||||
return pendingHex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class to track a single ping request
|
||||
|
||||
@@ -9,13 +9,6 @@ typedef RawPacketSender =
|
||||
required Uint8List payload,
|
||||
});
|
||||
|
||||
typedef FragmentAckWaiter =
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
});
|
||||
|
||||
Future<bool> serveCachedSessionFragments<T>({
|
||||
required String providerLabel,
|
||||
required String sessionId,
|
||||
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
required int Function(T fragment) indexOf,
|
||||
required Uint8List Function(T fragment) encodeBinary,
|
||||
required RawPacketSender? sendRawPacket,
|
||||
FragmentAckWaiter? waitForFragmentAck,
|
||||
Set<int>? requestedIndices,
|
||||
Duration ackTimeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||
@@ -37,13 +28,19 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||
return false;
|
||||
}
|
||||
if (requester.outPathLen < 0) {
|
||||
if (!requester.routeHasPath) {
|
||||
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||
return false;
|
||||
}
|
||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
||||
if (requester.routeHopCount > maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
||||
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!requester.routeSupportsLegacyRawTransport) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -65,24 +62,12 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final ackFuture = waitForFragmentAck?.call(
|
||||
sessionId: sessionId,
|
||||
index: index,
|
||||
timeout: ackTimeout,
|
||||
);
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.outPathLen,
|
||||
contactPathLen: requester.routeSignedPathLen,
|
||||
payload: encodeBinary(fragment),
|
||||
);
|
||||
servedCount++;
|
||||
if (ackFuture != null) {
|
||||
final acked = await ackFuture;
|
||||
if (!acked) {
|
||||
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||
|
||||
@@ -1,38 +1,57 @@
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
|
||||
class RestoredSessionMetadata {
|
||||
final Map<String, String> voiceSenderKeyBySession;
|
||||
final Map<String, String> imageSenderKeyBySession;
|
||||
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||
|
||||
const RestoredSessionMetadata({
|
||||
required this.voiceSenderKeyBySession,
|
||||
required this.imageSenderKeyBySession,
|
||||
required this.imageEnvelopeBySession,
|
||||
});
|
||||
}
|
||||
|
||||
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||
Iterable<String> messageTexts,
|
||||
Iterable<Message> messages,
|
||||
) {
|
||||
final voiceSenderKeyBySession = <String, String>{};
|
||||
final imageSenderKeyBySession = <String, String>{};
|
||||
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||
|
||||
for (final text in messageTexts) {
|
||||
for (final message in messages) {
|
||||
final text = message.text;
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||
if (voiceEnvelope != null) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
|
||||
.senderKey6
|
||||
.toLowerCase();
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||
if (imageEnvelope != null) {
|
||||
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RestoredSessionMetadata(
|
||||
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||
imageSenderKeyBySession: imageSenderKeyBySession,
|
||||
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier {
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
})?
|
||||
waitForFragmentAckCallback;
|
||||
|
||||
ImageProvider() {
|
||||
_restore();
|
||||
@@ -102,11 +96,28 @@ class ImageProvider with ChangeNotifier {
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availableFragmentIndices(String sessionId) {
|
||||
final outgoing = _outgoing[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.fragments.map((fragment) => fragment.index).toList()
|
||||
..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.fragments.length; i++) {
|
||||
if (session.fragments[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Incoming fragment reception ──────────────────────────────────────────
|
||||
|
||||
/// Add a received [fragment]. Creates the session on first fragment using
|
||||
/// metadata from the fragment itself (requires envelope to have been
|
||||
/// announced first; if not, defaults width/height to 0 — corrected on save).
|
||||
/// Add a received [fragment]. New compact fragments rely on prior envelope
|
||||
/// metadata for total/format, while legacy fragments can still self-describe.
|
||||
///
|
||||
/// Returns true when the session just became complete.
|
||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||
@@ -116,16 +127,20 @@ class ImageProvider with ChangeNotifier {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(
|
||||
fragment.sessionId,
|
||||
() => ImageSession(
|
||||
_sessions.putIfAbsent(fragment.sessionId, () {
|
||||
if (fragment.total < 1) {
|
||||
throw StateError(
|
||||
'Image envelope missing for compact fragment ${fragment.sessionId}',
|
||||
);
|
||||
}
|
||||
return ImageSession(
|
||||
sessionId: fragment.sessionId,
|
||||
format: fragment.format,
|
||||
total: fragment.total,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[fragment.sessionId]!;
|
||||
if (fragment.index < session.total) {
|
||||
@@ -250,21 +265,26 @@ class ImageProvider with ChangeNotifier {
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final cached = _outgoing[sessionId];
|
||||
if (cached == null) {
|
||||
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
|
||||
final outgoing = _outgoing[sessionId];
|
||||
final fragments = outgoing != null
|
||||
? List<ImagePacket>.from(outgoing.fragments)
|
||||
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
|
||||
const <ImagePacket>[];
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [ImageProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return serveCachedSessionFragments<ImagePacket>(
|
||||
providerLabel: 'ImageProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: cached.fragments,
|
||||
fragments: fragments,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (fragment) => fragment.index,
|
||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
waitForFragmentAck: waitForFragmentAckCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../models/message_reception_details.dart';
|
||||
import '../models/message_transfer_details.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../services/message_storage_service.dart';
|
||||
@@ -10,6 +12,7 @@ import '../services/notification_service.dart';
|
||||
import '../utils/sar_message_parser.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'helpers/message_retry_manager.dart';
|
||||
|
||||
@@ -22,6 +25,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
bool _isInitialized = false;
|
||||
AppLocalizations? _localizations;
|
||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
||||
|
||||
// Track pending sent messages by expected ACK/TAG
|
||||
final Map<int, Message> _pendingSentMessages = {};
|
||||
@@ -74,12 +79,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
})?
|
||||
sendMessageCallback;
|
||||
|
||||
Future<void> Function({
|
||||
required Contact contact,
|
||||
required int failureStreak,
|
||||
})?
|
||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||
onDirectPathFailedCallback;
|
||||
|
||||
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
|
||||
String Function(int channelIdx)? resolveChannelNameCallback;
|
||||
|
||||
List<Message> get messages => List.unmodifiable(_messages);
|
||||
|
||||
List<Message> get contactMessages =>
|
||||
@@ -115,6 +120,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
MessageContactLocation? getMessageContactLocation(String messageId) =>
|
||||
_messageContactLocations[messageId];
|
||||
|
||||
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
|
||||
_messageReceptionDetails[messageId];
|
||||
|
||||
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
|
||||
_messageTransferDetails[messageId];
|
||||
|
||||
/// Set localizations for notifications
|
||||
void setLocalizations(AppLocalizations localizations) {
|
||||
_localizations = localizations;
|
||||
@@ -145,9 +156,19 @@ class MessagesProvider with ChangeNotifier {
|
||||
final storedMessages = await _storageService.loadMessages();
|
||||
final storedContactLocations = await _storageService
|
||||
.loadMessageContactLocations();
|
||||
final storedReceptionDetails = await _storageService
|
||||
.loadMessageReceptionDetails();
|
||||
final storedTransferDetails = await _storageService
|
||||
.loadMessageTransferDetails();
|
||||
_messageContactLocations
|
||||
..clear()
|
||||
..addAll(storedContactLocations);
|
||||
_messageReceptionDetails
|
||||
..clear()
|
||||
..addAll(storedReceptionDetails);
|
||||
_messageTransferDetails
|
||||
..clear()
|
||||
..addAll(storedTransferDetails);
|
||||
|
||||
// Add stored messages with enhancement to ensure SAR detection
|
||||
for (final message in storedMessages) {
|
||||
@@ -187,14 +208,6 @@ class MessagesProvider with ChangeNotifier {
|
||||
isVoice: true,
|
||||
voiceId: envelope.sessionId,
|
||||
);
|
||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||
if (pkt != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: pkt.sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +324,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
Message message, {
|
||||
String Function(String name)? contactLookup,
|
||||
MessageContactLocation? contactLocationSnapshot,
|
||||
MessageReceptionDetails? receptionDetailsSnapshot,
|
||||
}) {
|
||||
// Always enhance message with SAR parser to detect SAR markers
|
||||
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||
@@ -341,14 +355,6 @@ class MessagesProvider with ChangeNotifier {
|
||||
isVoice: true,
|
||||
voiceId: envelope.sessionId,
|
||||
);
|
||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||
if (pkt != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: pkt.sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +403,22 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
||||
);
|
||||
final existingIndex = _messages.indexWhere(
|
||||
(existing) =>
|
||||
existing.messageType == finalMessage.messageType &&
|
||||
existing.senderTimestamp == finalMessage.senderTimestamp &&
|
||||
existing.text == finalMessage.text,
|
||||
);
|
||||
if (existingIndex != -1) {
|
||||
final existingId = _messages[existingIndex].id;
|
||||
if (contactLocationSnapshot != null) {
|
||||
_messageContactLocations[existingId] = contactLocationSnapshot;
|
||||
}
|
||||
if (receptionDetailsSnapshot != null) {
|
||||
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
|
||||
}
|
||||
_persistMessages();
|
||||
}
|
||||
return; // Skip duplicate
|
||||
}
|
||||
|
||||
@@ -404,6 +426,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (contactLocationSnapshot != null) {
|
||||
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
|
||||
}
|
||||
if (receptionDetailsSnapshot != null) {
|
||||
_messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot;
|
||||
}
|
||||
|
||||
// If it's a SAR marker message, extract and store the marker
|
||||
if (finalMessage.isSarMarker) {
|
||||
@@ -549,33 +574,31 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// Trigger notification for regular message
|
||||
Future<void> _triggerMessageNotification(Message message) async {
|
||||
try {
|
||||
// Get sender name from message
|
||||
final senderName =
|
||||
message.senderName ?? message.senderKeyShort ?? 'Unknown';
|
||||
|
||||
// Determine if it's a channel message
|
||||
final senderName = _resolveParticipantName(
|
||||
publicKey: message.senderPublicKeyPrefix,
|
||||
fallback: message.senderName ?? message.senderKeyShort,
|
||||
);
|
||||
final isChannelMessage = message.isChannelMessage;
|
||||
|
||||
// Get channel name if available
|
||||
String? channelName;
|
||||
if (isChannelMessage) {
|
||||
// You could map channelIdx to channel name here if needed
|
||||
// For now, use "Public" for channel 0
|
||||
channelName = message.channelIdx == 0
|
||||
? 'Public'
|
||||
: 'Channel ${message.channelIdx}';
|
||||
}
|
||||
final channelName = isChannelMessage
|
||||
? _resolveChannelName(message.channelIdx)
|
||||
: null;
|
||||
final messageText = _buildNotificationMessageText(
|
||||
message,
|
||||
senderName: senderName,
|
||||
isChannelMessage: isChannelMessage,
|
||||
channelName: channelName,
|
||||
);
|
||||
|
||||
debugPrint('🔔 [MessagesProvider] Triggering message notification');
|
||||
debugPrint(' Sender: $senderName');
|
||||
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
|
||||
debugPrint(
|
||||
' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...',
|
||||
' Message: ${messageText.substring(0, messageText.length > 50 ? 50 : messageText.length)}...',
|
||||
);
|
||||
|
||||
await _notificationService.showMessageNotification(
|
||||
senderName: senderName,
|
||||
messageText: message.text,
|
||||
messageText: messageText,
|
||||
isChannelMessage: isChannelMessage,
|
||||
channelName: channelName,
|
||||
localizations: _localizations,
|
||||
@@ -587,12 +610,84 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
String _resolveParticipantName({
|
||||
required Uint8List? publicKey,
|
||||
String? fallback,
|
||||
}) {
|
||||
final resolved = resolveContactNameCallback?.call(publicKey)?.trim();
|
||||
if (resolved != null && resolved.isNotEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
final normalizedFallback = fallback?.trim();
|
||||
if (normalizedFallback != null && normalizedFallback.isNotEmpty) {
|
||||
return normalizedFallback;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
String _resolveChannelName(int? channelIdx) {
|
||||
final idx = channelIdx ?? 0;
|
||||
final resolved = resolveChannelNameCallback?.call(idx).trim();
|
||||
if (resolved != null && resolved.isNotEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
return idx == 0 ? 'Public' : 'Channel $idx';
|
||||
}
|
||||
|
||||
String _buildNotificationMessageText(
|
||||
Message message, {
|
||||
required String senderName,
|
||||
required bool isChannelMessage,
|
||||
String? channelName,
|
||||
}) {
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
|
||||
if (voiceEnvelope != null) {
|
||||
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
|
||||
final summary =
|
||||
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
|
||||
return isChannelMessage ? '$senderName\n$summary' : summary;
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||
if (imageEnvelope != null) {
|
||||
final summary =
|
||||
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
|
||||
return isChannelMessage ? '$senderName\n$summary' : summary;
|
||||
}
|
||||
|
||||
if (!isChannelMessage && message.recipientPublicKey != null) {
|
||||
final recipientName = _resolveParticipantName(
|
||||
publicKey: message.recipientPublicKey,
|
||||
fallback: null,
|
||||
);
|
||||
if (recipientName != 'Unknown') {
|
||||
return 'To: $recipientName\n${message.text}';
|
||||
}
|
||||
}
|
||||
|
||||
if (isChannelMessage) {
|
||||
return '$senderName\n${message.text}';
|
||||
}
|
||||
|
||||
return message.text;
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
final kib = bytes / 1024;
|
||||
if (kib < 1024) return '${kib.toStringAsFixed(kib >= 10 ? 0 : 1)} KB';
|
||||
final mib = kib / 1024;
|
||||
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
|
||||
}
|
||||
|
||||
/// Persist messages to storage (async, non-blocking)
|
||||
Future<void> _persistMessages() async {
|
||||
try {
|
||||
await _storageService.saveMessages(
|
||||
_messages,
|
||||
messageContactLocations: _messageContactLocations,
|
||||
messageReceptionDetails: _messageReceptionDetails,
|
||||
messageTransferDetails: _messageTransferDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||
@@ -709,6 +804,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messageContactMap.remove(messageId);
|
||||
_groupedMessageMapping.remove(messageId);
|
||||
_messageContactLocations.remove(messageId);
|
||||
_messageReceptionDetails.remove(messageId);
|
||||
_messageTransferDetails.remove(messageId);
|
||||
|
||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||
|
||||
@@ -738,6 +835,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
_messageContactLocations.clear();
|
||||
_messageReceptionDetails.clear();
|
||||
_messageTransferDetails.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -753,10 +852,70 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
_messageContactLocations.clear();
|
||||
_messageReceptionDetails.clear();
|
||||
_messageTransferDetails.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int transferCountForSession({
|
||||
String? voiceSessionId,
|
||||
String? imageSessionId,
|
||||
}) {
|
||||
final messageId = _findMessageIdByMediaSession(
|
||||
voiceSessionId: voiceSessionId,
|
||||
imageSessionId: imageSessionId,
|
||||
);
|
||||
if (messageId == null) return 0;
|
||||
return _messageTransferDetails[messageId]?.totalTransfers ?? 0;
|
||||
}
|
||||
|
||||
void recordMediaTransfer({
|
||||
required String sessionId,
|
||||
required String mediaType,
|
||||
required String requesterKey6,
|
||||
String? requesterName,
|
||||
}) {
|
||||
final messageId = _findMessageIdByMediaSession(
|
||||
voiceSessionId: mediaType == 'voice' ? sessionId : null,
|
||||
imageSessionId: mediaType == 'image' ? sessionId : null,
|
||||
);
|
||||
if (messageId == null) {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] No message found for $mediaType session $sessionId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final current =
|
||||
_messageTransferDetails[messageId] ??
|
||||
const MessageTransferDetails.empty();
|
||||
_messageTransferDetails[messageId] = current.registerTransfer(
|
||||
requesterKey6: requesterKey6,
|
||||
requesterName: requesterName,
|
||||
);
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? _findMessageIdByMediaSession({
|
||||
String? voiceSessionId,
|
||||
String? imageSessionId,
|
||||
}) {
|
||||
for (final message in _messages.reversed) {
|
||||
if (voiceSessionId != null && message.voiceId == voiceSessionId) {
|
||||
return message.id;
|
||||
}
|
||||
if (imageSessionId != null) {
|
||||
final envelope = ImageEnvelope.tryParse(message.text);
|
||||
if (envelope != null && envelope.sessionId == imageSessionId) {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
return await _storageService.getStorageStats();
|
||||
@@ -852,14 +1011,6 @@ class MessagesProvider with ChangeNotifier {
|
||||
isVoice: true,
|
||||
voiceId: envelope.sessionId,
|
||||
);
|
||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||
if (pkt != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: pkt.sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1062,10 +1213,11 @@ class MessagesProvider with ChangeNotifier {
|
||||
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
|
||||
);
|
||||
|
||||
// Once the device accepts a direct message and returns an ACK tag, the
|
||||
// send itself succeeded locally even if end-to-end delivery confirmation
|
||||
// may still arrive later. Keep ACK tracking, but stop showing "waiting".
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: expectedAckTag > 0
|
||||
? MessageDeliveryStatus.sending
|
||||
: MessageDeliveryStatus.sent,
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
||||
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
|
||||
);
|
||||
@@ -1522,7 +1674,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
|
||||
debugPrint(' Retry attempt: ${message.retryAttempt}');
|
||||
debugPrint(' Contact has path: ${contact?.hasPath ?? false}');
|
||||
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
|
||||
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
||||
|
||||
// Decision tree for retry/flood/fail
|
||||
@@ -1678,7 +1830,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
_retryManager.clearRetry(messageId);
|
||||
|
||||
final failedContact = _messageContactMap[messageId];
|
||||
if (failedContact != null && failedContact.hasPath) {
|
||||
if (failedContact != null && failedContact.routeHasPath) {
|
||||
final failureStreak = _retryManager.recordPathFailure(failedContact);
|
||||
debugPrint(
|
||||
' Path failure streak for ${failedContact.advName}: $failureStreak',
|
||||
@@ -1698,8 +1850,69 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset an existing failed message back into a sending state so a manual
|
||||
/// retry can reuse the same record instead of appending a duplicate.
|
||||
bool prepareMessageForRetry(String messageId) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index == -1) {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] prepareMessageForRetry: Message not found: $messageId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final message = _messages[index];
|
||||
|
||||
_timeoutTimers[message.id]?.cancel();
|
||||
_timeoutTimers.remove(message.id);
|
||||
if (message.expectedAckTag != null) {
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
_clearAckHistoryForMessage(messageId);
|
||||
_retryManager.clearRetry(messageId);
|
||||
|
||||
_messages[index] = Message(
|
||||
id: message.id,
|
||||
messageType: message.messageType,
|
||||
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
|
||||
channelIdx: message.channelIdx,
|
||||
pathLen: message.pathLen,
|
||||
textType: message.textType,
|
||||
senderTimestamp: message.senderTimestamp,
|
||||
text: message.text,
|
||||
isSarMarker: message.isSarMarker,
|
||||
sarGpsCoordinates: message.sarGpsCoordinates,
|
||||
sarNotes: message.sarNotes,
|
||||
sarCustomEmoji: message.sarCustomEmoji,
|
||||
sarColorIndex: message.sarColorIndex,
|
||||
receivedAt: message.receivedAt,
|
||||
senderName: message.senderName,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: message.recipientPublicKey,
|
||||
retryAttempt: 0,
|
||||
lastRetryAt: DateTime.now(),
|
||||
usedFloodFallback: false,
|
||||
isRead: message.isRead,
|
||||
echoCount: message.echoCount,
|
||||
firstEchoAt: message.firstEchoAt,
|
||||
lastEchoSnrRaw: message.lastEchoSnrRaw,
|
||||
lastEchoRssiDbm: message.lastEchoRssiDbm,
|
||||
lastEchoAt: message.lastEchoAt,
|
||||
isDrawing: message.isDrawing,
|
||||
drawingId: message.drawingId,
|
||||
groupId: message.groupId,
|
||||
recipients: message.recipients,
|
||||
isVoice: message.isVoice,
|
||||
voiceId: message.voiceId,
|
||||
);
|
||||
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Resend a failed message
|
||||
Future<void> resendMessage(String messageId) async {
|
||||
Future<void> resendMessage(String messageId, {Contact? contact}) async {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index == -1) {
|
||||
debugPrint(
|
||||
@@ -1709,9 +1922,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
final message = _messages[index];
|
||||
final contact = _messageContactMap[messageId];
|
||||
final resolvedContact = contact ?? _messageContactMap[messageId];
|
||||
|
||||
if (contact == null) {
|
||||
if (resolvedContact == null) {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId',
|
||||
);
|
||||
@@ -1720,26 +1933,19 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
debugPrint('🔁 [MessagesProvider] Resending message $messageId');
|
||||
|
||||
// Reset retry state
|
||||
_messages[index] = message.copyWith(
|
||||
retryAttempt: 0,
|
||||
usedFloodFallback: false,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
lastRetryAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Clear retry tracking
|
||||
_retryManager.clearRetry(messageId);
|
||||
|
||||
notifyListeners();
|
||||
_messageContactMap[messageId] = resolvedContact;
|
||||
final prepared = prepareMessageForRetry(messageId);
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send again
|
||||
if (sendMessageCallback != null) {
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
contactPublicKey: resolvedContact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
contact: resolvedContact,
|
||||
retryAttempt: 0,
|
||||
);
|
||||
if (!queued) {
|
||||
|
||||
233
lib/providers/sensors_provider.dart
Normal file
233
lib/providers/sensors_provider.dart
Normal file
@@ -0,0 +1,233 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import 'connection_provider.dart';
|
||||
import 'contacts_provider.dart';
|
||||
|
||||
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
||||
|
||||
enum SensorMetric {
|
||||
lastSeen,
|
||||
voltage,
|
||||
battery,
|
||||
temperature,
|
||||
humidity,
|
||||
pressure,
|
||||
gps,
|
||||
updated,
|
||||
}
|
||||
|
||||
class SensorsProvider with ChangeNotifier {
|
||||
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||
static const Set<SensorMetric> _defaultVisibleMetrics = <SensorMetric>{
|
||||
SensorMetric.lastSeen,
|
||||
SensorMetric.voltage,
|
||||
SensorMetric.battery,
|
||||
SensorMetric.temperature,
|
||||
SensorMetric.humidity,
|
||||
SensorMetric.pressure,
|
||||
SensorMetric.gps,
|
||||
SensorMetric.updated,
|
||||
};
|
||||
|
||||
final List<String> _watchedSensorKeys = <String>[];
|
||||
final Map<String, SensorRefreshState> _refreshStates =
|
||||
<String, SensorRefreshState>{};
|
||||
final Map<String, Set<SensorMetric>> _visibleMetricsBySensor =
|
||||
<String, Set<SensorMetric>>{};
|
||||
bool _isLoaded = false;
|
||||
bool _isRefreshingAll = false;
|
||||
|
||||
SensorsProvider() {
|
||||
unawaited(_loadWatchedSensors());
|
||||
}
|
||||
|
||||
List<String> get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys);
|
||||
bool get isLoaded => _isLoaded;
|
||||
bool get isRefreshingAll => _isRefreshingAll;
|
||||
|
||||
SensorRefreshState stateFor(String publicKeyHex) =>
|
||||
_refreshStates[publicKeyHex] ?? SensorRefreshState.idle;
|
||||
|
||||
Future<void> _loadWatchedSensors() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
||||
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
||||
_watchedSensorKeys
|
||||
..clear()
|
||||
..addAll(stored);
|
||||
_visibleMetricsBySensor.clear();
|
||||
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||
for (final entry in decoded.entries) {
|
||||
final metricNames = (entry.value as List<dynamic>).cast<String>();
|
||||
_visibleMetricsBySensor[entry.key] = metricNames
|
||||
.map(_metricFromName)
|
||||
.whereType<SensorMetric>()
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
for (final key in _watchedSensorKeys) {
|
||||
_visibleMetricsBySensor.putIfAbsent(
|
||||
key,
|
||||
() => Set<SensorMetric>.from(_defaultVisibleMetrics),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading watched sensors: $e');
|
||||
} finally {
|
||||
_isLoaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistWatchedSensors() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving watched sensors: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistVisibleMetrics() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final encoded = <String, List<String>>{};
|
||||
for (final entry in _visibleMetricsBySensor.entries) {
|
||||
encoded[entry.key] = entry.value.map((metric) => metric.name).toList();
|
||||
}
|
||||
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
|
||||
} catch (e) {
|
||||
debugPrint('Error saving visible sensor metrics: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Set<SensorMetric> visibleMetricsFor(String publicKeyHex) =>
|
||||
Set<SensorMetric>.unmodifiable(
|
||||
_visibleMetricsBySensor[publicKeyHex] ?? _defaultVisibleMetrics,
|
||||
);
|
||||
|
||||
bool showsMetric(String publicKeyHex, SensorMetric metric) =>
|
||||
visibleMetricsFor(publicKeyHex).contains(metric);
|
||||
|
||||
Future<void> toggleMetric(
|
||||
String publicKeyHex,
|
||||
SensorMetric metric,
|
||||
bool visible,
|
||||
) async {
|
||||
final visibleMetrics = _visibleMetricsBySensor.putIfAbsent(
|
||||
publicKeyHex,
|
||||
() => Set<SensorMetric>.from(_defaultVisibleMetrics),
|
||||
);
|
||||
if (visible) {
|
||||
visibleMetrics.add(metric);
|
||||
} else {
|
||||
if (visibleMetrics.length == 1 && visibleMetrics.contains(metric)) {
|
||||
return;
|
||||
}
|
||||
visibleMetrics.remove(metric);
|
||||
}
|
||||
await _persistVisibleMetrics();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool isWatched(String publicKeyHex) =>
|
||||
_watchedSensorKeys.contains(publicKeyHex);
|
||||
|
||||
Future<void> addSensor(Contact contact) async {
|
||||
if (!contact.isChat && !contact.isRepeater) {
|
||||
return;
|
||||
}
|
||||
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||
await _persistWatchedSensors();
|
||||
_visibleMetricsBySensor[contact.publicKeyHex] = Set<SensorMetric>.from(
|
||||
_defaultVisibleMetrics,
|
||||
);
|
||||
await _persistVisibleMetrics();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeSensor(String publicKeyHex) async {
|
||||
_watchedSensorKeys.remove(publicKeyHex);
|
||||
_refreshStates.remove(publicKeyHex);
|
||||
_visibleMetricsBySensor.remove(publicKeyHex);
|
||||
await _persistWatchedSensors();
|
||||
await _persistVisibleMetrics();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<Contact> availableCandidates(ContactsProvider contactsProvider) {
|
||||
final candidates = <Contact>[
|
||||
...contactsProvider.chatContacts,
|
||||
...contactsProvider.repeaters,
|
||||
];
|
||||
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
||||
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Future<void> refreshAll({
|
||||
required ContactsProvider contactsProvider,
|
||||
required ConnectionProvider connectionProvider,
|
||||
}) async {
|
||||
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshingAll = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
for (final key in _watchedSensorKeys) {
|
||||
Contact? contact;
|
||||
for (final entry in contactsProvider.contacts) {
|
||||
if (entry.publicKeyHex == key) {
|
||||
contact = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (contact == null) {
|
||||
_refreshStates[key] = SensorRefreshState.unavailable;
|
||||
notifyListeners();
|
||||
continue;
|
||||
}
|
||||
|
||||
_refreshStates[key] = SensorRefreshState.refreshing;
|
||||
notifyListeners();
|
||||
|
||||
final result = await connectionProvider.smartPing(
|
||||
contactPublicKey: contact.publicKey,
|
||||
hasPath: contact.hasPath,
|
||||
);
|
||||
|
||||
_refreshStates[key] = result.success
|
||||
? SensorRefreshState.success
|
||||
: SensorRefreshState.timeout;
|
||||
notifyListeners();
|
||||
}
|
||||
} finally {
|
||||
_isRefreshingAll = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
SensorMetric? _metricFromName(String name) {
|
||||
for (final metric in SensorMetric.values) {
|
||||
if (metric.name == name) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -71,12 +71,6 @@ class VoiceProvider with ChangeNotifier {
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
})?
|
||||
waitForFragmentAckCallback;
|
||||
|
||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||
|
||||
@@ -131,6 +125,23 @@ class VoiceProvider with ChangeNotifier {
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availablePacketIndices(String sessionId) {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.packets.map((packet) => packet.index).toList()..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.packets.length; i++) {
|
||||
if (session.packets[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Packet reception ─────────────────────────────────────────────────────
|
||||
|
||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||
@@ -142,14 +153,18 @@ class VoiceProvider with ChangeNotifier {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(
|
||||
packet.sessionId,
|
||||
() => VoiceSession(
|
||||
_sessions.putIfAbsent(packet.sessionId, () {
|
||||
if (packet.total < 1) {
|
||||
throw StateError(
|
||||
'Voice envelope missing for compact packet ${packet.sessionId}',
|
||||
);
|
||||
}
|
||||
return VoiceSession(
|
||||
sessionId: packet.sessionId,
|
||||
mode: packet.mode,
|
||||
total: packet.total,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[packet.sessionId]!;
|
||||
if (packet.index < session.total) {
|
||||
@@ -185,6 +200,47 @@ class VoiceProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void registerEnvelope(VoiceEnvelope envelope) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final needsMerge =
|
||||
existing.total != envelope.total || existing.mode != envelope.mode;
|
||||
if (!needsMerge) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final merged = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
merged.firstPacketAt = existing.firstPacketAt;
|
||||
merged.lastPacketAt = existing.lastPacketAt;
|
||||
for (final packet in existing.packets) {
|
||||
if (packet == null) continue;
|
||||
if (packet.index < merged.total) {
|
||||
merged.packets[packet.index] = packet;
|
||||
}
|
||||
}
|
||||
_sessions[envelope.sessionId] = merged;
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cache encoded packets for deferred voice serving.
|
||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||
if (packets.isEmpty) return;
|
||||
@@ -201,10 +257,14 @@ class VoiceProvider with ChangeNotifier {
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final cached = _outgoingSessions[sessionId];
|
||||
if (cached == null) {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
final packets = outgoing != null
|
||||
? List<VoicePacket>.from(outgoing.packets)
|
||||
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
|
||||
const <VoicePacket>[];
|
||||
if (packets.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId',
|
||||
'⚠️ [VoiceProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -212,12 +272,11 @@ class VoiceProvider with ChangeNotifier {
|
||||
providerLabel: 'VoiceProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: cached.packets,
|
||||
fragments: packets,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (packet) => packet.index,
|
||||
encodeBinary: (packet) => packet.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
waitForFragmentAck: waitForFragmentAckCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
@@ -119,6 +120,43 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
List<Contact> _sortContactsByDistance(List<Contact> contacts) {
|
||||
final sorted = List<Contact>.from(contacts);
|
||||
|
||||
sorted.sort((a, b) {
|
||||
final distanceA = _distanceFromCurrentPosition(a);
|
||||
final distanceB = _distanceFromCurrentPosition(b);
|
||||
|
||||
if (distanceA != null && distanceB != null) {
|
||||
final distanceCompare = distanceA.compareTo(distanceB);
|
||||
if (distanceCompare != 0) return distanceCompare;
|
||||
} else if (distanceA != null) {
|
||||
return -1;
|
||||
} else if (distanceB != null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
double? _distanceFromCurrentPosition(Contact contact) {
|
||||
final currentPosition = _currentPosition;
|
||||
final contactLocation = contact.displayLocation;
|
||||
if (currentPosition == null || contactLocation == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _calculateDistanceInMeters(
|
||||
currentPosition.latitude,
|
||||
currentPosition.longitude,
|
||||
contactLocation.latitude,
|
||||
contactLocation.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show the add channel dialog
|
||||
Future<void> _showAddChannelDialog(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -165,10 +203,12 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
return Scaffold(
|
||||
body: Consumer<ContactsProvider>(
|
||||
builder: (context, contactsProvider, child) {
|
||||
final chatContacts = contactsProvider.chatContacts;
|
||||
final repeaters = contactsProvider.repeaters;
|
||||
final rooms = contactsProvider.rooms;
|
||||
final channels = contactsProvider.channels;
|
||||
final chatContacts = _sortContactsByDistance(
|
||||
contactsProvider.chatContacts,
|
||||
);
|
||||
final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
|
||||
final rooms = _sortContactsByDistance(contactsProvider.rooms);
|
||||
final channels = _sortContactsByDistance(contactsProvider.channels);
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
|
||||
// Check if there are any displayable contacts (excluding channels)
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'messages_tab.dart';
|
||||
import 'contacts_tab.dart';
|
||||
import 'sensors_tab.dart';
|
||||
import 'map_tab.dart';
|
||||
import 'map_management_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
@@ -24,7 +25,7 @@ import '../widgets/permission_request_dialog.dart';
|
||||
import '../widgets/connection_dialog.dart';
|
||||
import '../utils/battery_display_helper.dart';
|
||||
|
||||
enum _HomeTab { messages, contacts, map }
|
||||
enum _HomeTab { messages, contacts, sensors, map }
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
final Function(AppThemeMode) onThemeChanged;
|
||||
@@ -54,11 +55,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
bool _showRxTxIndicators = true;
|
||||
bool _isMapEnabled = true;
|
||||
bool _isContactsEnabled = true;
|
||||
bool _isSensorsEnabled = false;
|
||||
|
||||
List<_HomeTab> get _enabledTabs {
|
||||
return [
|
||||
_HomeTab.messages,
|
||||
if (_isContactsEnabled) _HomeTab.contacts,
|
||||
if (_isSensorsEnabled) _HomeTab.sensors,
|
||||
if (_isMapEnabled) _HomeTab.map,
|
||||
];
|
||||
}
|
||||
@@ -77,6 +80,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_appProvider = context.read<AppProvider>();
|
||||
_isMapEnabled = _appProvider.isMapEnabled;
|
||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
||||
_appProvider.addListener(_handleAppProviderChanged);
|
||||
|
||||
// Initialize synchronously so first build always has a valid controller.
|
||||
@@ -106,6 +110,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_updateTabController(
|
||||
mapEnabled: _appProvider.isMapEnabled,
|
||||
contactsEnabled: _appProvider.isContactsEnabled,
|
||||
sensorsEnabled: _appProvider.isSensorsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,8 +134,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
void _updateTabController({
|
||||
required bool mapEnabled,
|
||||
required bool contactsEnabled,
|
||||
required bool sensorsEnabled,
|
||||
}) {
|
||||
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) {
|
||||
if (_isMapEnabled == mapEnabled &&
|
||||
_isContactsEnabled == contactsEnabled &&
|
||||
_isSensorsEnabled == sensorsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,6 +155,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
// Update state
|
||||
_isMapEnabled = mapEnabled;
|
||||
_isContactsEnabled = contactsEnabled;
|
||||
_isSensorsEnabled = sensorsEnabled;
|
||||
if (!_isMapEnabled) {
|
||||
_isMapFullscreen = false;
|
||||
}
|
||||
@@ -183,6 +192,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
case _HomeTab.contacts:
|
||||
context.read<ContactsProvider>().markAllAsViewed();
|
||||
break;
|
||||
case _HomeTab.sensors:
|
||||
break;
|
||||
case _HomeTab.map:
|
||||
break;
|
||||
}
|
||||
@@ -471,6 +482,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
? () => _navigateToTab(_HomeTab.map)
|
||||
: null,
|
||||
);
|
||||
case _HomeTab.sensors:
|
||||
return const SensorsTab();
|
||||
case _HomeTab.map:
|
||||
return MapTab(
|
||||
onFullscreenChanged: (isFullscreen) {
|
||||
@@ -502,6 +515,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
onTap: (index) {
|
||||
final tabs = _enabledTabs;
|
||||
if (index < 0 || index >= tabs.length) {
|
||||
return;
|
||||
}
|
||||
_handleTabActivated(tabs[index]);
|
||||
},
|
||||
tabs: enabledTabs.map((tab) {
|
||||
switch (tab) {
|
||||
case _HomeTab.messages:
|
||||
@@ -525,6 +545,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
icon: const Icon(Icons.map),
|
||||
text: AppLocalizations.of(context)!.map,
|
||||
);
|
||||
case _HomeTab.sensors:
|
||||
return const Tab(
|
||||
icon: Icon(Icons.sensors),
|
||||
text: 'Sensors',
|
||||
);
|
||||
}
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
@@ -76,6 +76,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
final BackgroundLocationService _backgroundLocationService =
|
||||
BackgroundLocationService();
|
||||
bool _isDisposing = false; // Flag to prevent updates during disposal
|
||||
MapProvider? _mapProvider;
|
||||
|
||||
// MBTiles layers
|
||||
List<MapLayer> _mbtilesLayers = [];
|
||||
@@ -129,6 +130,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return; // Check if widget is still mounted
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
_mapProvider = mapProvider;
|
||||
mapProvider.addListener(_handleMapNavigation);
|
||||
// Load WMS overlay state
|
||||
mapProvider.loadOverlayState();
|
||||
@@ -438,8 +440,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
// Save map position before disposing
|
||||
_saveMapPosition();
|
||||
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.removeListener(_handleMapNavigation);
|
||||
_mapProvider?.removeListener(_handleMapNavigation);
|
||||
|
||||
// DO NOT stop location tracking - it's managed by AppProvider
|
||||
// Restore the original callback instead of setting to null
|
||||
|
||||
@@ -19,7 +19,9 @@ import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../widgets/messages/sar_update_sheet.dart';
|
||||
import '../widgets/messages/recipient_selector_sheet.dart';
|
||||
import '../widgets/messages/message_bubble.dart';
|
||||
import '../widgets/messages/messages_composer.dart';
|
||||
import '../widgets/messages/messages_content.dart';
|
||||
import '../widgets/common/contact_avatar.dart';
|
||||
import '../services/message_destination_preferences.dart';
|
||||
import '../services/voice_bitrate_preferences.dart';
|
||||
import '../services/voice_recorder_service.dart';
|
||||
@@ -47,6 +49,7 @@ class MessagesTab extends StatefulWidget {
|
||||
class _MessagesTabState extends State<MessagesTab> {
|
||||
static const int _maxContactMessageBytes = 156;
|
||||
static const int _maxChannelMessageBytes = 127;
|
||||
static const double _composerOverlayHeight = 148;
|
||||
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
@@ -314,20 +317,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get tooltip for destination button
|
||||
String _getDestinationTooltip() {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel &&
|
||||
_selectedRecipient != null) {
|
||||
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
|
||||
return '$channelName (tap to change)';
|
||||
} else if (_selectedRecipient != null) {
|
||||
final recipientName = _selectedRecipient!.displayName;
|
||||
return '$recipientName (tap to change)';
|
||||
}
|
||||
return 'Select recipient';
|
||||
}
|
||||
|
||||
String _getDestinationLabel() {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel &&
|
||||
@@ -466,10 +455,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(
|
||||
sentMessage,
|
||||
contact: _selectedRecipient,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||
|
||||
// Send message to selected recipient
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
@@ -589,9 +575,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeContact &&
|
||||
_selectedRecipient != null &&
|
||||
_selectedRecipient!.outPathLen >= 0) {
|
||||
_selectedRecipient!.routeHasPath) {
|
||||
imageDataBytesPerFragment = safeImageDataBytesForPath(
|
||||
_selectedRecipient!.outPathLen,
|
||||
_selectedRecipient!.routeHopCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -615,11 +601,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
ToastLogger.error(context, 'Device key unavailable');
|
||||
return;
|
||||
}
|
||||
final senderKey6 = deviceKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
final envelope = ImageEnvelope(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
@@ -627,8 +608,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
sizeBytes: compressed.length,
|
||||
senderKey6: senderKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -929,9 +908,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
final senderKey6 = senderPublicKeyPrefix
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
final durationMs = encodedPackets.fold<int>(
|
||||
0,
|
||||
(sum, p) => sum + p.durationMs,
|
||||
@@ -941,9 +917,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
mode: mode,
|
||||
total: encodedPackets.length,
|
||||
durationMs: durationMs,
|
||||
senderKey6: senderKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 1,
|
||||
version: 3,
|
||||
);
|
||||
final envelopeText = envelope.encodeText();
|
||||
|
||||
@@ -1115,6 +1089,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runAfterSheetDismissal(
|
||||
BuildContext sheetContext,
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
Navigator.pop(sheetContext);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 180));
|
||||
if (!mounted) return;
|
||||
await action();
|
||||
}
|
||||
|
||||
void _showComposerActions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -1126,9 +1110,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_location_alt),
|
||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showSarDialog();
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
_showSarDialog();
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_voiceSupported)
|
||||
@@ -1138,13 +1123,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
||||
onTap: _isSendingVoice
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
if (_isRecording) {
|
||||
_stopAndSendVoice();
|
||||
} else {
|
||||
_startVoiceRecording();
|
||||
}
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
if (_isRecording) {
|
||||
await _stopAndSendVoice();
|
||||
} else {
|
||||
await _startVoiceRecording();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -1153,9 +1139,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
title: const Text('Send image from gallery'),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_pickAndSendImage(source: ImageSource.gallery);
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _pickAndSendImage(source: ImageSource.gallery);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -1164,18 +1151,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
title: const Text('Take photo'),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_pickAndSendImage(source: ImageSource.camera);
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _pickAndSendImage(source: ImageSource.camera);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.grid_3x3),
|
||||
title: const Text('Start Tic-Tac-Toe'),
|
||||
subtitle: const Text('DM only'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_startTicTacToeGame();
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _startTicTacToeGame();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -1185,6 +1174,23 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDestinationAvatar(BuildContext context) {
|
||||
final recipient = _selectedRecipient;
|
||||
if (recipient != null) {
|
||||
return ContactAvatar(
|
||||
contact: recipient,
|
||||
radius: 14,
|
||||
displayName: _getDestinationLabel(),
|
||||
);
|
||||
}
|
||||
|
||||
return Icon(
|
||||
_getDestinationIcon(),
|
||||
size: 17,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendSarMessage(
|
||||
String emoji,
|
||||
String name,
|
||||
@@ -1496,6 +1502,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
return filteredMessages;
|
||||
}
|
||||
|
||||
void _handleMessageTap(Message message) {
|
||||
if (widget.onNavigateToMap == null) return;
|
||||
|
||||
if (message.isSarMarker && message.sarGpsCoordinates != null) {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap?.call();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.isDrawing && message.drawingId != null) {
|
||||
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
final drawingProvider = context.read<DrawingProvider>();
|
||||
mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MessagesProvider>(
|
||||
@@ -1507,552 +1535,41 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Messages list with pull-to-refresh
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
Positioned.fill(
|
||||
child: MessagesContent(
|
||||
messages: messages,
|
||||
scrollController: _scrollController,
|
||||
highlightedMessageId: _highlightedMessageId,
|
||||
bottomContentPadding:
|
||||
_composerOverlayHeight + composerBottomPadding,
|
||||
onRefresh: _handleRefresh,
|
||||
child: messages.isEmpty
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.noMessagesYet,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.pullDownToSync,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final isHighlighted =
|
||||
message.id == _highlightedMessageId;
|
||||
|
||||
return MessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
isHighlighted: isHighlighted,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onTap:
|
||||
widget.onNavigateToMap != null &&
|
||||
message.isSarMarker &&
|
||||
message.sarGpsCoordinates != null
|
||||
? () {
|
||||
final mapProvider = context
|
||||
.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
: widget.onNavigateToMap != null &&
|
||||
message.isDrawing &&
|
||||
message.drawingId != null
|
||||
? () {
|
||||
debugPrint(
|
||||
'🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}',
|
||||
);
|
||||
final mapProvider = context
|
||||
.read<MapProvider>();
|
||||
final drawingProvider = context
|
||||
.read<DrawingProvider>();
|
||||
mapProvider.navigateToDrawing(
|
||||
message.drawingId!,
|
||||
drawingProvider,
|
||||
);
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onMessageTap: _handleMessageTap,
|
||||
),
|
||||
),
|
||||
|
||||
// Message input area
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
composerBottomPadding,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_isRecording ? Icons.stop : Icons.add,
|
||||
size: 22,
|
||||
),
|
||||
tooltip: _isRecording
|
||||
? 'Stop recording'
|
||||
: 'More actions',
|
||||
onPressed: _isRecording
|
||||
? _stopAndSendVoice
|
||||
: _showComposerActions,
|
||||
color: _isRecording
|
||||
? Colors.red
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: _showRecipientSelector,
|
||||
child: Ink(
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
20,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.dividerColor
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getDestinationIcon(),
|
||||
size: 17,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getDestinationLabel(),
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.expand_more_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(
|
||||
milliseconds: 180,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 46,
|
||||
maxHeight: 132,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Theme.of(context).dividerColor
|
||||
.withValues(alpha: 0.35),
|
||||
width: _focusNode.hasFocus ? 1.4 : 1,
|
||||
),
|
||||
boxShadow: _focusNode.hasFocus
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.10),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: [
|
||||
_messageByteLimiter,
|
||||
],
|
||||
style: const TextStyle(fontSize: 15),
|
||||
textAlignVertical:
|
||||
TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
)!.typeYourMessage,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.9),
|
||||
),
|
||||
filled: false,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
),
|
||||
textInputAction:
|
||||
TextInputAction.newline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final canSendText =
|
||||
!_isRecording &&
|
||||
!_isSendingVoice &&
|
||||
_textController.text
|
||||
.trim()
|
||||
.isNotEmpty;
|
||||
final semanticsLabel = _isRecording
|
||||
? 'Recording... release to send voice'
|
||||
: (_isSendingVoice
|
||||
? 'Sending voice...'
|
||||
: _voiceSupported
|
||||
? 'Send (long press to record voice)'
|
||||
: 'Send');
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled:
|
||||
canSendText ||
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice),
|
||||
label: semanticsLabel,
|
||||
onTap: canSendText
|
||||
? _sendMessage
|
||||
: null,
|
||||
onLongPress:
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice)
|
||||
? () {
|
||||
if (_isRecording) {
|
||||
_stopAndSendVoice();
|
||||
return;
|
||||
}
|
||||
_startVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message: semanticsLabel,
|
||||
excludeFromSemantics: true,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: canSendText
|
||||
? () {
|
||||
debugPrint(
|
||||
'👆 [MessagesTab] Send button tapped '
|
||||
'(canSendText=$canSendText, '
|
||||
'textLength=${_textController.text.trim().length}, '
|
||||
'recording=$_isRecording, '
|
||||
'sendingVoice=$_isSendingVoice)',
|
||||
);
|
||||
_sendMessage();
|
||||
}
|
||||
: null,
|
||||
onLongPressStart:
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice)
|
||||
? (_) {
|
||||
debugPrint(
|
||||
'🎙️ [MessagesTab] Send button long-press start '
|
||||
'(voiceSupported=$_voiceSupported, '
|
||||
'sendingVoice=$_isSendingVoice, '
|
||||
'recording=$_isRecording)',
|
||||
);
|
||||
_startVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
onLongPressEnd:
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? (_) {
|
||||
debugPrint(
|
||||
'🎙️ [MessagesTab] Send button long-press end '
|
||||
'(recording=$_isRecording)',
|
||||
);
|
||||
_stopAndSendVoice();
|
||||
}
|
||||
: null,
|
||||
onLongPressCancel:
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? () {
|
||||
debugPrint(
|
||||
'🎙️ [MessagesTab] Send button long-press cancel '
|
||||
'(recording=$_isRecording)',
|
||||
);
|
||||
_stopAndSendVoice();
|
||||
}
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(
|
||||
milliseconds: 180,
|
||||
),
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Colors.transparent
|
||||
: Theme.of(context)
|
||||
.dividerColor
|
||||
.withValues(
|
||||
alpha: 0.35,
|
||||
),
|
||||
),
|
||||
boxShadow:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? [
|
||||
BoxShadow(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(
|
||||
alpha:
|
||||
0.22,
|
||||
),
|
||||
blurRadius: 14,
|
||||
offset:
|
||||
const Offset(
|
||||
0,
|
||||
6,
|
||||
),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: _isSendingVoice
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isRecording
|
||||
? Icons
|
||||
.mic_rounded
|
||||
: Icons
|
||||
.send_rounded,
|
||||
size: 22,
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onPrimary
|
||||
: Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$_messageByteCount/$_maxMessageBytes',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
_messageByteCount >
|
||||
_maxMessageBytes *
|
||||
0.9
|
||||
? Colors.orange.shade800
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: MessagesComposer(
|
||||
textController: _textController,
|
||||
focusNode: _focusNode,
|
||||
messageByteLimiter: _messageByteLimiter,
|
||||
messageByteCount: _messageByteCount,
|
||||
maxMessageBytes: _maxMessageBytes,
|
||||
isRecording: _isRecording,
|
||||
isSendingVoice: _isSendingVoice,
|
||||
voiceSupported: _voiceSupported,
|
||||
bottomPadding: composerBottomPadding,
|
||||
destinationLabel: _getDestinationLabel(),
|
||||
destinationAvatar: _buildDestinationAvatar(context),
|
||||
onShowComposerActions: _showComposerActions,
|
||||
onShowRecipientSelector: _showRecipientSelector,
|
||||
onStartVoiceRecording: _startVoiceRecording,
|
||||
onStopAndSendVoice: _stopAndSendVoice,
|
||||
onSendMessage: _sendMessage,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
|
||||
class PacketLogScreen extends StatefulWidget {
|
||||
final MeshCoreBleService bleService;
|
||||
@@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget {
|
||||
final isRx = log.direction == PacketDirection.rx;
|
||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||
final rxInfo = log.logRxDataInfo;
|
||||
final contacts = context.watch<ContactsProvider>().contacts;
|
||||
final connectionProvider = context.watch<ConnectionProvider>();
|
||||
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
|
||||
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final ownName =
|
||||
connectionProvider.deviceInfo.selfName ??
|
||||
connectionProvider.deviceInfo.displayName;
|
||||
final resolvedPath = decodedRoute?.pathHashes
|
||||
.map(
|
||||
(hash) => LogRxRouteDecoder.resolveHash(
|
||||
hash,
|
||||
contacts: contacts,
|
||||
ownPublicKey: ownPublicKey,
|
||||
ownName: ownName,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
|
||||
? resolvedPath.first
|
||||
: null;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
@@ -580,6 +604,14 @@ class _PacketLogCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isRx && decodedRoute != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_RouteSection(
|
||||
route: decodedRoute,
|
||||
path: resolvedPath ?? const [],
|
||||
originalSender: originalSender,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -723,6 +755,162 @@ class _PacketLogCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteSection extends StatelessWidget {
|
||||
final DecodedLogRxRoute route;
|
||||
final List<ResolvedNodeHash> path;
|
||||
final ResolvedNodeHash? originalSender;
|
||||
|
||||
const _RouteSection({
|
||||
required this.route,
|
||||
required this.path,
|
||||
required this.originalSender,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.alt_route, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Mesh Route',
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
_FactCard(
|
||||
icon: Icons.route,
|
||||
label: 'Payload',
|
||||
value: _payloadTypeLabel(route.payloadType),
|
||||
),
|
||||
_FactCard(
|
||||
icon: Icons.hub,
|
||||
label: 'Hops',
|
||||
value: '${route.pathHashes.length}',
|
||||
),
|
||||
if (originalSender != null)
|
||||
_FactCard(
|
||||
icon: Icons.person_pin_circle,
|
||||
label: 'Original sender',
|
||||
value: _nodeLabel(originalSender!),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (path.isEmpty)
|
||||
Text(
|
||||
'Direct packet, no hop path attached.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (var i = 0; i < path.length; i++) ...[
|
||||
_RouteHopChip(index: i + 1, node: path[i]),
|
||||
if (i < path.length - 1)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(Icons.arrow_right_alt, size: 16),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _payloadTypeLabel(int payloadType) {
|
||||
switch (payloadType) {
|
||||
case 0x00:
|
||||
return 'REQ';
|
||||
case 0x01:
|
||||
return 'RESP';
|
||||
case 0x02:
|
||||
return 'TXT';
|
||||
case 0x03:
|
||||
return 'ACK';
|
||||
case 0x04:
|
||||
return 'ADVERT';
|
||||
case 0x05:
|
||||
return 'GRP_TXT';
|
||||
case 0x06:
|
||||
return 'GRP_DATA';
|
||||
case 0x07:
|
||||
return 'ANON_REQ';
|
||||
case 0x08:
|
||||
return 'PATH';
|
||||
case 0x09:
|
||||
return 'TRACE';
|
||||
case 0x0A:
|
||||
return 'MULTIPART';
|
||||
case 0x0B:
|
||||
return 'CONTROL';
|
||||
default:
|
||||
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
static String _nodeLabel(ResolvedNodeHash node) {
|
||||
if (node.isOwnNode) {
|
||||
return '${node.label} (${node.hexLabel})';
|
||||
}
|
||||
if (node.matchCount == 0) {
|
||||
return node.hexLabel;
|
||||
}
|
||||
if (node.isUniqueMatch) {
|
||||
return '${node.label} (${node.hexLabel})';
|
||||
}
|
||||
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteHopChip extends StatelessWidget {
|
||||
final int index;
|
||||
final ResolvedNodeHash node;
|
||||
|
||||
const _RouteHopChip({required this.index, required this.node});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = node.isOwnNode
|
||||
? Colors.blue
|
||||
: node.isUniqueMatch
|
||||
? Colors.green
|
||||
: Colors.orange;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
'$index. ${_RouteSection._nodeLabel(node)}',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FactCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
735
lib/screens/sensors_tab.dart
Normal file
735
lib/screens/sensors_tab.dart
Normal file
@@ -0,0 +1,735 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/sensors_provider.dart';
|
||||
|
||||
class SensorsTab extends StatelessWidget {
|
||||
const SensorsTab({super.key});
|
||||
|
||||
Future<void> _showAddSensorSheet(BuildContext context) async {
|
||||
final sensorsProvider = context.read<SensorsProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final candidates = sensorsProvider.availableCandidates(contactsProvider);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
if (candidates.isEmpty) {
|
||||
return const SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No eligible nodes available. Discover a relay or node first.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
children: [
|
||||
const ListTile(
|
||||
title: Text(
|
||||
'Add sensor node',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text('Pick a relay or node to watch in Sensors.'),
|
||||
),
|
||||
...candidates.map(
|
||||
(contact) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: const Color(0xFFDDEAF8),
|
||||
child: Icon(
|
||||
_typeIcon(contact),
|
||||
color: const Color(0xFF1E4F7A),
|
||||
),
|
||||
),
|
||||
title: Text(contact.displayName),
|
||||
subtitle: _SensorCandidatePreview(contact: contact),
|
||||
isThreeLine: true,
|
||||
onTap: () async {
|
||||
await sensorsProvider.addSensor(contact);
|
||||
if (!sheetContext.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${contact.displayName} added to Sensors',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMetricSelector(
|
||||
BuildContext context,
|
||||
String publicKeyHex,
|
||||
) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => Consumer<SensorsProvider>(
|
||||
builder: (context, sensorsProvider, child) {
|
||||
final visibleMetrics = sensorsProvider.visibleMetricsFor(
|
||||
publicKeyHex,
|
||||
);
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
children: [
|
||||
Text(
|
||||
'Visible fields',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choose which values appear on sensor cards.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: SensorMetric.values.map((metric) {
|
||||
final visible = visibleMetrics.contains(metric);
|
||||
return FilterChip(
|
||||
selected: visible,
|
||||
label: Text(_metricLabel(metric)),
|
||||
onSelected: (value) {
|
||||
sensorsProvider.toggleMetric(
|
||||
publicKeyHex,
|
||||
metric,
|
||||
value,
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshAll(BuildContext context) async {
|
||||
await context.read<SensorsProvider>().refreshAll(
|
||||
contactsProvider: context.read<ContactsProvider>(),
|
||||
connectionProvider: context.read<ConnectionProvider>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddSensorSheet(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: Consumer2<SensorsProvider, ContactsProvider>(
|
||||
builder: (context, sensorsProvider, contactsProvider, child) {
|
||||
final watchedKeys = sensorsProvider.watchedSensorKeys;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _refreshAll(context),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
if (watchedKeys.isEmpty)
|
||||
const _EmptySensorsState()
|
||||
else
|
||||
...watchedKeys.map((key) {
|
||||
Contact? contact;
|
||||
for (final entry in contactsProvider.contacts) {
|
||||
if (entry.publicKeyHex == key) {
|
||||
contact = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _SensorCard(
|
||||
contact: contact,
|
||||
state: sensorsProvider.stateFor(key),
|
||||
visibleMetrics: sensorsProvider.visibleMetricsFor(key),
|
||||
onRemove: () async {
|
||||
await sensorsProvider.removeSensor(key);
|
||||
},
|
||||
onCustomize: () => _showMetricSelector(context, key),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorCandidatePreview extends StatelessWidget {
|
||||
final Contact contact;
|
||||
|
||||
const _SensorCandidatePreview({required this.contact});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final telemetry = contact.telemetry;
|
||||
final previewLines = <String>[
|
||||
'${contact.type.displayName} • ${contact.publicKeyShort}',
|
||||
if (telemetry?.batteryPercentage != null)
|
||||
'Battery ${telemetry!.batteryPercentage!.toStringAsFixed(0)}% • '
|
||||
'Temp ${telemetry.temperature?.toStringAsFixed(1) ?? '--'}°C',
|
||||
if (telemetry?.gpsLocation != null)
|
||||
'GPS ${telemetry!.gpsLocation!.latitude.toStringAsFixed(3)}, '
|
||||
'${telemetry.gpsLocation!.longitude.toStringAsFixed(3)}',
|
||||
];
|
||||
|
||||
if (previewLines.length == 1) {
|
||||
previewLines.add('No telemetry preview available yet');
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: previewLines
|
||||
.take(3)
|
||||
.map(
|
||||
(line) => Text(line, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptySensorsState extends StatelessWidget {
|
||||
const _EmptySensorsState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 64),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFDDEAF8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.sensors_outlined,
|
||||
size: 40,
|
||||
color: Color(0xFF1E4F7A),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'No sensor nodes added',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorCard extends StatelessWidget {
|
||||
final Contact? contact;
|
||||
final SensorRefreshState state;
|
||||
final Set<SensorMetric> visibleMetrics;
|
||||
final Future<void> Function() onRemove;
|
||||
final VoidCallback onCustomize;
|
||||
|
||||
const _SensorCard({
|
||||
required this.contact,
|
||||
required this.state,
|
||||
required this.visibleMetrics,
|
||||
required this.onRemove,
|
||||
required this.onCustomize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final telemetry = contact?.telemetry;
|
||||
final theme = Theme.of(context);
|
||||
final metrics = contact == null || telemetry == null
|
||||
? const <_MetricCardData>[]
|
||||
: _buildMetricCards(l10n, telemetry, contact!);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.14),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
contact?.displayName ?? 'Unavailable node',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (state == SensorRefreshState.timeout)
|
||||
const _InlineAlertBadge(label: 'No response'),
|
||||
],
|
||||
),
|
||||
if (telemetry != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
if (value == 'remove') {
|
||||
await onRemove();
|
||||
} else if (value == 'customize') {
|
||||
onCustomize();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem<String>(
|
||||
value: 'customize',
|
||||
child: Text('Customize fields'),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'remove',
|
||||
child: Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (((state != SensorRefreshState.idle &&
|
||||
state != SensorRefreshState.timeout) ||
|
||||
state == SensorRefreshState.unavailable) ||
|
||||
(contact != null &&
|
||||
visibleMetrics.contains(SensorMetric.lastSeen)))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
if (state != SensorRefreshState.idle &&
|
||||
state != SensorRefreshState.timeout)
|
||||
_StatusPill(state: state),
|
||||
if (contact != null &&
|
||||
visibleMetrics.contains(SensorMetric.lastSeen))
|
||||
_InfoPill(
|
||||
icon: Icons.schedule,
|
||||
label:
|
||||
'${l10n.lastSeen}: ${contact!.timeSinceLastSeen}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (contact == null)
|
||||
const Text(
|
||||
'This node is no longer available in the contact list.',
|
||||
)
|
||||
else if (telemetry == null)
|
||||
const Text('No telemetry received yet. Pull down to fetch it.')
|
||||
else if (metrics.isEmpty)
|
||||
const Text(
|
||||
'All fields are hidden. Use Visible fields to choose what to show.',
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: metrics
|
||||
.map((metric) => _MetricTile(data: metric))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_MetricCardData> _buildMetricCards(
|
||||
AppLocalizations l10n,
|
||||
dynamic telemetry,
|
||||
Contact contact,
|
||||
) {
|
||||
final items = <_MetricCardData>[];
|
||||
|
||||
if (visibleMetrics.contains(SensorMetric.voltage) &&
|
||||
telemetry.batteryMilliVolts != null) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.bolt,
|
||||
label: l10n.voltage,
|
||||
value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V',
|
||||
accent: const Color(0xFF0A7D61),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (visibleMetrics.contains(SensorMetric.battery) &&
|
||||
telemetry.batteryPercentage != null) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.battery_5_bar,
|
||||
label: l10n.battery,
|
||||
value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%',
|
||||
accent: const Color(0xFF4B8E2F),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (visibleMetrics.contains(SensorMetric.temperature) &&
|
||||
telemetry.temperature != null) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.thermostat,
|
||||
label: l10n.temperature,
|
||||
value: '${telemetry.temperature!.toStringAsFixed(1)}°C',
|
||||
accent: const Color(0xFFC76821),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (visibleMetrics.contains(SensorMetric.humidity) &&
|
||||
telemetry.humidity != null) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.water_drop,
|
||||
label: l10n.humidity,
|
||||
value: '${telemetry.humidity!.toStringAsFixed(1)}%',
|
||||
accent: const Color(0xFF246BB2),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (visibleMetrics.contains(SensorMetric.pressure) &&
|
||||
telemetry.pressure != null) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.compress,
|
||||
label: l10n.pressure,
|
||||
value: '${telemetry.pressure!.toStringAsFixed(1)} hPa',
|
||||
accent: const Color(0xFF6B4BAE),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (visibleMetrics.contains(SensorMetric.gps) &&
|
||||
telemetry.gpsLocation != null) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.place,
|
||||
label: l10n.gpsTelemetry,
|
||||
value:
|
||||
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
|
||||
accent: const Color(0xFFAA3F57),
|
||||
wide: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (visibleMetrics.contains(SensorMetric.updated)) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.update,
|
||||
label: l10n.updated,
|
||||
value: _formatTelemetryTime(telemetry.timestamp),
|
||||
accent: const Color(0xFF6C727F),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (telemetry.extraSensorData != null) {
|
||||
for (final entry in telemetry.extraSensorData!.entries) {
|
||||
items.add(
|
||||
_MetricCardData(
|
||||
icon: Icons.sensors,
|
||||
label: entry.key,
|
||||
value: '${entry.value}',
|
||||
accent: const Color(0xFF3E657C),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
String _formatTelemetryTime(DateTime timestamp) {
|
||||
final diff = DateTime.now().difference(timestamp);
|
||||
if (diff.inMinutes < 1) return 'just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
String _formatTelemetryDateTime(DateTime timestamp) {
|
||||
final local = timestamp.toLocal();
|
||||
final year = local.year.toString().padLeft(4, '0');
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final hour = local.hour.toString().padLeft(2, '0');
|
||||
final minute = local.minute.toString().padLeft(2, '0');
|
||||
return '$year-$month-$day $hour:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusPill extends StatelessWidget {
|
||||
final SensorRefreshState state;
|
||||
|
||||
const _StatusPill({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color, icon) = switch (state) {
|
||||
SensorRefreshState.idle => (
|
||||
'Idle',
|
||||
const Color(0xFF5D7185),
|
||||
Icons.sensors,
|
||||
),
|
||||
SensorRefreshState.refreshing => (
|
||||
'Refreshing',
|
||||
const Color(0xFF266AC2),
|
||||
Icons.sync,
|
||||
),
|
||||
SensorRefreshState.success => (
|
||||
'Updated',
|
||||
const Color(0xFF218B63),
|
||||
Icons.check_circle,
|
||||
),
|
||||
SensorRefreshState.timeout => (
|
||||
'No response',
|
||||
const Color(0xFFC17B1D),
|
||||
Icons.schedule,
|
||||
),
|
||||
SensorRefreshState.unavailable => (
|
||||
'Unavailable',
|
||||
const Color(0xFFB13B55),
|
||||
Icons.error_outline,
|
||||
),
|
||||
};
|
||||
|
||||
return _InfoPill(
|
||||
icon: icon,
|
||||
label: label,
|
||||
foreground: color,
|
||||
background: color.withValues(alpha: 0.10),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoPill extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color? foreground;
|
||||
final Color? background;
|
||||
|
||||
const _InfoPill({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.foreground,
|
||||
this.background,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = foreground ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
background ?? Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InlineAlertBadge extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _InlineAlertBadge({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC17B1D).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: const Color(0xFFC17B1D),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricTile extends StatelessWidget {
|
||||
final _MetricCardData data;
|
||||
|
||||
const _MetricTile({required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = data.wide ? 320.0 : 168.0;
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: data.accent.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: data.accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(data.icon, color: data.accent, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
data.label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: data.accent,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
data.value,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricCardData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color accent;
|
||||
final bool wide;
|
||||
|
||||
const _MetricCardData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.accent,
|
||||
this.wide = false,
|
||||
});
|
||||
}
|
||||
|
||||
String _metricLabel(SensorMetric metric) {
|
||||
return switch (metric) {
|
||||
SensorMetric.lastSeen => 'Last seen',
|
||||
SensorMetric.voltage => 'Voltage',
|
||||
SensorMetric.battery => 'Battery',
|
||||
SensorMetric.temperature => 'Temperature',
|
||||
SensorMetric.humidity => 'Humidity',
|
||||
SensorMetric.pressure => 'Pressure',
|
||||
SensorMetric.gps => 'GPS',
|
||||
SensorMetric.updated => 'Updated',
|
||||
};
|
||||
}
|
||||
|
||||
IconData _typeIcon(Contact contact) {
|
||||
if (contact.isRepeater) {
|
||||
return Icons.router;
|
||||
}
|
||||
if (contact.isChat) {
|
||||
return Icons.sensors;
|
||||
}
|
||||
return Icons.device_hub;
|
||||
}
|
||||
@@ -737,6 +737,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
Consumer<AppProvider>(
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.sensors),
|
||||
title: const Text('Enable Sensors tab'),
|
||||
subtitle: const Text(
|
||||
'Show a dedicated tab for watched relay and node telemetry',
|
||||
),
|
||||
value: appProvider.isSensorsEnabled,
|
||||
onChanged: (value) async {
|
||||
await appProvider.toggleSensorsEnabled(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(AppLocalizations.of(context)!.language),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../widgets/common/contact_avatar.dart';
|
||||
import '../widgets/map/location_pointer.dart';
|
||||
|
||||
/// Centralized service for map marker management.
|
||||
@@ -77,9 +78,15 @@ class MapMarkerService {
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: getContactMarkerColor(contact, context),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
color: Colors.white,
|
||||
shape: contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BoxShape.rectangle
|
||||
: BoxShape.circle,
|
||||
borderRadius: contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BorderRadius.circular(14)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
@@ -88,17 +95,8 @@ class MapMarkerService {
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
)
|
||||
: Icon(
|
||||
getContactMarkerIcon(contact),
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ContactAvatar(contact: contact, radius: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
|
||||
@@ -35,6 +35,7 @@ class MeshMapNodesService {
|
||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
||||
static const Duration traceTimeout = Duration(seconds: 30);
|
||||
static List<MeshMapNode>? _cachedNodes;
|
||||
static DateTime? _cachedAt;
|
||||
|
||||
@@ -52,7 +53,7 @@ class MeshMapNodesService {
|
||||
|
||||
final response = await http
|
||||
.get(Uri.parse(_nodesEndpoint))
|
||||
.timeout(const Duration(seconds: 12));
|
||||
.timeout(traceTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Map nodes API returned ${response.statusCode}');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../models/message_reception_details.dart';
|
||||
import '../models/message_transfer_details.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting messages to local storage
|
||||
@@ -10,12 +12,18 @@ class MessageStorageService {
|
||||
static const String _messagesKey = 'stored_messages';
|
||||
static const String _messageContactLocationsKey =
|
||||
'stored_message_contact_locations';
|
||||
static const String _messageReceptionDetailsKey =
|
||||
'stored_message_reception_details';
|
||||
static const String _messageTransferDetailsKey =
|
||||
'stored_message_transfer_details';
|
||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||
|
||||
/// Save messages to persistent storage
|
||||
Future<void> saveMessages(
|
||||
List<Message> messages, {
|
||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -34,15 +42,35 @@ class MessageStorageService {
|
||||
.map((entry) => entry['id'] as String)
|
||||
.toSet();
|
||||
final locationJson = <String, dynamic>{};
|
||||
final receptionJson = <String, dynamic>{};
|
||||
final transferJson = <String, dynamic>{};
|
||||
for (final entry in messageContactLocations.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
locationJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageReceptionDetails.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
receptionJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageTransferDetails.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
transferJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
await prefs.setString(
|
||||
_messageContactLocationsKey,
|
||||
jsonEncode(locationJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_messageReceptionDetailsKey,
|
||||
jsonEncode(receptionJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_messageTransferDetailsKey,
|
||||
jsonEncode(transferJson),
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||
@@ -52,8 +80,8 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
|
||||
async {
|
||||
Future<Map<String, MessageContactLocation>>
|
||||
loadMessageContactLocations() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageContactLocationsKey);
|
||||
@@ -82,6 +110,66 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageReceptionDetails>>
|
||||
loadMessageReceptionDetails() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageReceptionDetails>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||
if (snapshot != null) {
|
||||
result[entry.key] = snapshot;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageTransferDetails>>
|
||||
loadMessageTransferDetails() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageTransferDetailsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageTransferDetails>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final details = MessageTransferDetails.fromJson(value);
|
||||
if (details != null) {
|
||||
result[entry.key] = details;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading transfer details: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Load messages from persistent storage
|
||||
Future<List<Message>> loadMessages() async {
|
||||
try {
|
||||
@@ -116,6 +204,8 @@ class MessageStorageService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_messagesKey);
|
||||
await prefs.remove(_messageContactLocationsKey);
|
||||
await prefs.remove(_messageReceptionDetailsKey);
|
||||
await prefs.remove(_messageTransferDetailsKey);
|
||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||
|
||||
@@ -18,7 +18,7 @@ class VoiceRecorderService {
|
||||
|
||||
/// Request microphone permission. Returns true if granted.
|
||||
Future<bool> requestPermission() async {
|
||||
return _recorder.hasPermission();
|
||||
return _recorder.hasPermission(request: true);
|
||||
}
|
||||
|
||||
/// Start capturing PCM audio.
|
||||
@@ -38,9 +38,7 @@ class VoiceRecorderService {
|
||||
throw StateError('VoiceRecorderService: already recording');
|
||||
}
|
||||
|
||||
_controller = StreamController<Int16List>(
|
||||
onCancel: () => _stopInternal(),
|
||||
);
|
||||
_controller = StreamController<Int16List>(onCancel: () => _stopInternal());
|
||||
_isRecording = true;
|
||||
|
||||
_startRecording(
|
||||
@@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor {
|
||||
required int sampleRate,
|
||||
required bool enableCompressor,
|
||||
required bool enableLimiter,
|
||||
}) : _enableCompressor = enableCompressor,
|
||||
_enableLimiter = enableLimiter,
|
||||
_compressor = _SimpleCompressor(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
thresholdDb: -18.0,
|
||||
ratio: 2.5,
|
||||
attackMs: 8.0,
|
||||
releaseMs: 120.0,
|
||||
makeupGainDb: 4.0,
|
||||
),
|
||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||
}) : _enableCompressor = enableCompressor,
|
||||
_enableLimiter = enableLimiter,
|
||||
_compressor = _SimpleCompressor(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
thresholdDb: -18.0,
|
||||
ratio: 2.5,
|
||||
attackMs: 8.0,
|
||||
releaseMs: 120.0,
|
||||
makeupGainDb: 4.0,
|
||||
),
|
||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||
|
||||
Int16List process(Int16List input) {
|
||||
final output = Int16List(input.length);
|
||||
@@ -214,11 +212,11 @@ class _SimpleCompressor {
|
||||
required double attackMs,
|
||||
required double releaseMs,
|
||||
required double makeupGainDb,
|
||||
}) : _thresholdDb = thresholdDb,
|
||||
_ratio = ratio,
|
||||
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
||||
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
||||
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
||||
}) : _thresholdDb = thresholdDb,
|
||||
_ratio = ratio,
|
||||
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
||||
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
||||
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
||||
|
||||
double process(double x) {
|
||||
final absX = x.abs();
|
||||
@@ -266,14 +264,14 @@ class _VoiceBandPassFilter {
|
||||
required int sampleRate,
|
||||
required double lowCutHz,
|
||||
required double highCutHz,
|
||||
}) : _highPass = _BiquadFilter.highPass(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
cutoffHz: lowCutHz,
|
||||
),
|
||||
_lowPass = _BiquadFilter.lowPass(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
cutoffHz: highCutHz,
|
||||
);
|
||||
}) : _highPass = _BiquadFilter.highPass(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
cutoffHz: lowCutHz,
|
||||
),
|
||||
_lowPass = _BiquadFilter.lowPass(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
cutoffHz: highCutHz,
|
||||
);
|
||||
|
||||
Int16List process(Int16List input) {
|
||||
final output = Int16List(input.length);
|
||||
@@ -306,11 +304,11 @@ class _BiquadFilter {
|
||||
required double b2,
|
||||
required double a1,
|
||||
required double a2,
|
||||
}) : _b0 = b0,
|
||||
_b1 = b1,
|
||||
_b2 = b2,
|
||||
_a1 = a1,
|
||||
_a2 = a2;
|
||||
}) : _b0 = b0,
|
||||
_b1 = b1,
|
||||
_b2 = b2,
|
||||
_a1 = a1,
|
||||
_a2 = a2;
|
||||
|
||||
factory _BiquadFilter.lowPass({
|
||||
required double sampleRate,
|
||||
|
||||
@@ -4,7 +4,7 @@ const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
|
||||
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
|
||||
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
|
||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
||||
const int _imagePacketHeaderBytes = 8; // image packet binary header in payload
|
||||
const int _imagePacketHeaderBytes = 6; // image packet binary header in payload
|
||||
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||
@@ -31,7 +31,7 @@ enum ImageFormat {
|
||||
/// A single binary fragment of a compressed image.
|
||||
///
|
||||
/// Binary format (direct contacts, via pushRawData / cmdSendRawData):
|
||||
/// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...]
|
||||
/// [0x49 'I'][sessionId:4B][idx:1B][imageData...]
|
||||
///
|
||||
/// Legacy default is 152 data bytes per fragment.
|
||||
class ImagePacket {
|
||||
@@ -50,7 +50,7 @@ class ImagePacket {
|
||||
});
|
||||
|
||||
static const int _magic = 0x49; // 'I'
|
||||
static const int _headerLen = 8; // magic(1)+session(4)+fmt(1)+idx(1)+total(1)
|
||||
static const int _headerLen = 6; // magic(1)+session(4)+idx(1)
|
||||
static const int maxDataBytes =
|
||||
152; // Conservative default for compatibility.
|
||||
|
||||
@@ -65,16 +65,14 @@ class ImagePacket {
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final fmtId = payload[5];
|
||||
final index = payload[6];
|
||||
final total = payload[7];
|
||||
if (total < 1) return null;
|
||||
final index = payload[5];
|
||||
final data = payload.sublist(_headerLen);
|
||||
return ImagePacket(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.fromId(fmtId),
|
||||
format: ImageFormat.avif,
|
||||
index: index,
|
||||
total: total,
|
||||
data: payload.sublist(_headerLen),
|
||||
total: 0,
|
||||
data: data,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -92,16 +90,16 @@ class ImagePacket {
|
||||
final out = Uint8List(_headerLen + data.length);
|
||||
out[0] = _magic;
|
||||
out.setRange(1, 5, sessionBytes);
|
||||
out[5] = format.id;
|
||||
out[6] = index;
|
||||
out[7] = total;
|
||||
out[5] = index;
|
||||
out.setRange(_headerLen, out.length, data);
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)';
|
||||
String toString() {
|
||||
final suffix = total > 0 ? ' ${format.label} [$index/${total - 1}]' : ' [$index]';
|
||||
return 'ImagePacket($sessionId$suffix ${data.length}B)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the maximum safe image data bytes for a direct route path.
|
||||
@@ -246,11 +244,11 @@ int _resolveBandwidthHz(int? rawBw) {
|
||||
/// Envelope announcing image availability (control plane).
|
||||
///
|
||||
/// Text format:
|
||||
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
||||
/// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||
/// Example:
|
||||
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
|
||||
/// IE4:deadbeef:0:7:3k:3k:t6
|
||||
class ImageEnvelope {
|
||||
static const String _prefix = 'IE2:';
|
||||
static const String _prefixV4 = 'IE4:';
|
||||
|
||||
final String sessionId; // 8 hex chars
|
||||
final ImageFormat format;
|
||||
@@ -258,8 +256,6 @@ class ImageEnvelope {
|
||||
final int width;
|
||||
final int height;
|
||||
final int sizeBytes; // total compressed image size
|
||||
final String senderKey6; // 12 hex chars (6 bytes)
|
||||
final int timestampSec;
|
||||
final int version;
|
||||
|
||||
const ImageEnvelope({
|
||||
@@ -269,18 +265,16 @@ class ImageEnvelope {
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.sizeBytes,
|
||||
required this.senderKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 2,
|
||||
this.version = 4,
|
||||
});
|
||||
|
||||
static bool isEnvelope(String text) => text.startsWith(_prefix);
|
||||
static bool isEnvelope(String text) => text.startsWith(_prefixV4);
|
||||
|
||||
static ImageEnvelope? tryParse(String text) {
|
||||
if (!isEnvelope(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
final body = text.substring(_prefixV4.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 8) return null;
|
||||
if (parts.length != 6) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final fmtId = _parseInt(parts[1], base36: true);
|
||||
@@ -288,16 +282,12 @@ class ImageEnvelope {
|
||||
final w = _parseInt(parts[3], base36: true);
|
||||
final h = _parseInt(parts[4], base36: true);
|
||||
final bytes = _parseInt(parts[5], base36: true);
|
||||
final senderKey6 = parts[6];
|
||||
final ts = _parseInt(parts[7], base36: true);
|
||||
|
||||
if (sid == null) return null;
|
||||
if (fmtId == null) return null;
|
||||
if (total == null || total < 1 || total > 255) return null;
|
||||
if (w == null || h == null || w < 1 || h < 1) return null;
|
||||
if (bytes == null || bytes < 1) return null;
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
|
||||
return ImageEnvelope(
|
||||
sessionId: sid,
|
||||
@@ -306,9 +296,7 @@ class ImageEnvelope {
|
||||
width: w,
|
||||
height: h,
|
||||
sizeBytes: bytes,
|
||||
senderKey6: senderKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
version: 4,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -316,27 +304,25 @@ class ImageEnvelope {
|
||||
}
|
||||
|
||||
String encode() =>
|
||||
'$_prefix${_encodeSessionId(sessionId)}:'
|
||||
'$_prefixV4${_encodeSessionId(sessionId)}:'
|
||||
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
|
||||
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
|
||||
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
'${_toBase36(height)}:${_toBase36(sizeBytes)}';
|
||||
}
|
||||
|
||||
/// Direct request to fetch image fragments (control plane).
|
||||
///
|
||||
/// Text format:
|
||||
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||
/// IR4:{sid}:{want}:{requesterKey6}
|
||||
/// Example:
|
||||
/// IR2:deadbeef:a:aabbccddeeff:s44wea
|
||||
/// IR4:deadbeef:a:aabbccddeeff
|
||||
class ImageFetchRequest {
|
||||
static const String _prefix = 'IR2:';
|
||||
static const String _prefixV4 = 'IR4:';
|
||||
static const int _binaryMagic = 0x69; // 'i'
|
||||
|
||||
final String sessionId;
|
||||
final String want; // 'all' or 'missing'
|
||||
final List<int> missingIndices;
|
||||
final String requesterKey6; // 12 hex chars
|
||||
final int timestampSec;
|
||||
final int version;
|
||||
|
||||
const ImageFetchRequest({
|
||||
@@ -344,24 +330,22 @@ class ImageFetchRequest {
|
||||
this.want = 'all',
|
||||
this.missingIndices = const [],
|
||||
required this.requesterKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 2,
|
||||
this.version = 4,
|
||||
});
|
||||
|
||||
static bool isRequest(String text) => text.startsWith(_prefix);
|
||||
static bool isRequest(String text) => text.startsWith(_prefixV4);
|
||||
static bool isRequestBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||
|
||||
static ImageFetchRequest? tryParse(String text) {
|
||||
if (!isRequest(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
final body = text.substring(_prefixV4.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 4) return null;
|
||||
if (parts.length != 3) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final wantToken = parts[1];
|
||||
final requesterKey6 = parts[2];
|
||||
final ts = _parseInt(parts[3], base36: true);
|
||||
final normalizedWant = wantToken == 'a'
|
||||
? 'all'
|
||||
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||
@@ -377,15 +361,13 @@ class ImageFetchRequest {
|
||||
return null;
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
|
||||
return ImageFetchRequest(
|
||||
sessionId: sid,
|
||||
want: normalizedWant,
|
||||
missingIndices: missingIndices,
|
||||
requesterKey6: requesterKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
version: 4,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -394,7 +376,7 @@ class ImageFetchRequest {
|
||||
|
||||
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
|
||||
if (!isRequestBinary(payload)) return null;
|
||||
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
|
||||
if (payload.length < 13) return null; // magic+sid+flags+key6+count
|
||||
try {
|
||||
final sid = payload
|
||||
.sublist(1, 5)
|
||||
@@ -407,25 +389,19 @@ class ImageFetchRequest {
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final ts =
|
||||
(payload[12] << 24) |
|
||||
(payload[13] << 16) |
|
||||
(payload[14] << 8) |
|
||||
payload[15];
|
||||
final missingCount = payload[16];
|
||||
if (payload.length != 17 + missingCount) return null;
|
||||
final missingCount = payload[12];
|
||||
if (payload.length != 13 + missingCount) return null;
|
||||
final wantMissing = (flags & 0x01) == 0x01;
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < missingCount; i++) {
|
||||
missing.add(payload[17 + i]);
|
||||
missing.add(payload[13 + i]);
|
||||
}
|
||||
return ImageFetchRequest(
|
||||
sessionId: sid,
|
||||
want: wantMissing ? 'missing' : 'all',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
version: 4,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -436,7 +412,7 @@ class ImageFetchRequest {
|
||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||
: (want == 'all' ? 'a' : want);
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
@@ -455,7 +431,7 @@ class ImageFetchRequest {
|
||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||
: <int>[];
|
||||
|
||||
final out = Uint8List(17 + missing.length);
|
||||
final out = Uint8List(13 + missing.length);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
@@ -467,13 +443,9 @@ class ImageFetchRequest {
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
out[12] = (timestampSec >> 24) & 0xFF;
|
||||
out[13] = (timestampSec >> 16) & 0xFF;
|
||||
out[14] = (timestampSec >> 8) & 0xFF;
|
||||
out[15] = timestampSec & 0xFF;
|
||||
out[16] = missing.length;
|
||||
out[12] = missing.length;
|
||||
for (var i = 0; i < missing.length; i++) {
|
||||
out[17 + i] = missing[i];
|
||||
out[13 + i] = missing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
25
lib/utils/location_formats.dart
Normal file
25
lib/utils/location_formats.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
String formatPlusCode(double lat, double lon) {
|
||||
const base = '23456789CFGHJMPQRVWX';
|
||||
|
||||
var normalizedLat = (lat + 90) / 180;
|
||||
var normalizedLon = (lon + 180) / 360;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
if (i == 4) {
|
||||
buffer.write('+');
|
||||
}
|
||||
|
||||
final latDigit = (normalizedLat * 20).floor() % 20;
|
||||
final lonDigit = (normalizedLon * 20).floor() % 20;
|
||||
|
||||
buffer
|
||||
..write(base[latDigit])
|
||||
..write(base[lonDigit]);
|
||||
|
||||
normalizedLat = (normalizedLat * 20) % 1;
|
||||
normalizedLon = (normalizedLon * 20) % 1;
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
129
lib/utils/log_rx_route_decoder.dart
Normal file
129
lib/utils/log_rx_route_decoder.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class DecodedLogRxRoute {
|
||||
final int payloadType;
|
||||
final List<int> pathHashes;
|
||||
|
||||
const DecodedLogRxRoute({
|
||||
required this.payloadType,
|
||||
required this.pathHashes,
|
||||
});
|
||||
|
||||
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
|
||||
}
|
||||
|
||||
class ResolvedNodeHash {
|
||||
final int hash;
|
||||
final String label;
|
||||
final bool isOwnNode;
|
||||
final bool isUniqueMatch;
|
||||
final int matchCount;
|
||||
|
||||
const ResolvedNodeHash({
|
||||
required this.hash,
|
||||
required this.label,
|
||||
required this.isOwnNode,
|
||||
required this.isUniqueMatch,
|
||||
required this.matchCount,
|
||||
});
|
||||
|
||||
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
class LogRxRouteDecoder {
|
||||
const LogRxRouteDecoder._();
|
||||
|
||||
static DecodedLogRxRoute? decode(Uint8List rawData) {
|
||||
if (rawData.length < 5 || rawData[0] != 0x88) 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 pathLen = rawPacketData[index++];
|
||||
if (rawPacketData.length < index + pathLen) return null;
|
||||
|
||||
return DecodedLogRxRoute(
|
||||
payloadType: payloadType,
|
||||
pathHashes: rawPacketData.sublist(index, index + pathLen),
|
||||
);
|
||||
}
|
||||
|
||||
static ResolvedNodeHash resolveHash(
|
||||
int hash, {
|
||||
required Iterable<Contact> contacts,
|
||||
Uint8List? ownPublicKey,
|
||||
String? ownName,
|
||||
}) {
|
||||
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
|
||||
? ownPublicKey.first
|
||||
: null;
|
||||
if (ownHash == hash) {
|
||||
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
||||
? '$ownName (you)'
|
||||
: 'You';
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: ownLabel,
|
||||
isOwnNode: true,
|
||||
isUniqueMatch: true,
|
||||
matchCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final matches = contacts.where((contact) {
|
||||
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
|
||||
}).toList();
|
||||
|
||||
if (matches.isEmpty) {
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: 'Unknown',
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: false,
|
||||
matchCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length == 1) {
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: matches.first.displayName,
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: true,
|
||||
matchCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final candidateNames = matches
|
||||
.map((contact) => contact.displayName)
|
||||
.where((name) => name.trim().isNotEmpty)
|
||||
.take(2)
|
||||
.join(', ');
|
||||
final extraCount = matches.length - 2;
|
||||
final label = candidateNames.isEmpty
|
||||
? '${matches.length} contacts'
|
||||
: extraCount > 0
|
||||
? '$candidateNames +$extraCount'
|
||||
: candidateNames;
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: label,
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: false,
|
||||
matchCount: matches.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
168
lib/utils/media_swarm_protocol.dart
Normal file
168
lib/utils/media_swarm_protocol.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
const int _swarmMagic = 0x6d; // 'm'
|
||||
const int _swarmKindRequest = 0x01;
|
||||
const int _swarmKindAvailability = 0x02;
|
||||
|
||||
class MediaSwarmRequest {
|
||||
final String mediaType;
|
||||
final String sessionId;
|
||||
final String requesterKey6;
|
||||
final List<int> missingIndices;
|
||||
|
||||
const MediaSwarmRequest({
|
||||
required this.mediaType,
|
||||
required this.sessionId,
|
||||
required this.requesterKey6,
|
||||
this.missingIndices = const [],
|
||||
});
|
||||
|
||||
bool get requestsAll => missingIndices.isEmpty;
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final normalizedMissing = missingIndices.toSet().toList()..sort();
|
||||
final out = Uint8List(14 + normalizedMissing.length);
|
||||
out[0] = _swarmMagic;
|
||||
out[1] = _swarmKindRequest;
|
||||
out[2] = _encodeMediaType(mediaType);
|
||||
_writeSessionId(out, 3, sessionId);
|
||||
_writeKey6(out, 7, requesterKey6);
|
||||
out[13] = normalizedMissing.length;
|
||||
for (var i = 0; i < normalizedMissing.length; i++) {
|
||||
out[14 + i] = normalizedMissing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static MediaSwarmRequest? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length < 14 ||
|
||||
payload[0] != _swarmMagic ||
|
||||
payload[1] != _swarmKindRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final mediaType = _decodeMediaType(payload[2]);
|
||||
if (mediaType == null) return null;
|
||||
|
||||
final missingCount = payload[13];
|
||||
if (payload.length != 14 + missingCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaSwarmRequest(
|
||||
mediaType: mediaType,
|
||||
sessionId: _readSessionId(payload, 3),
|
||||
requesterKey6: _readKey6(payload, 7),
|
||||
missingIndices: payload.sublist(14),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaSwarmAvailability {
|
||||
final String mediaType;
|
||||
final String sessionId;
|
||||
final String requesterKey6;
|
||||
final String responderKey6;
|
||||
final List<int> availableIndices;
|
||||
|
||||
const MediaSwarmAvailability({
|
||||
required this.mediaType,
|
||||
required this.sessionId,
|
||||
required this.requesterKey6,
|
||||
required this.responderKey6,
|
||||
required this.availableIndices,
|
||||
});
|
||||
|
||||
bool get servesAll => availableIndices.isEmpty;
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final normalizedAvailable = availableIndices.toSet().toList()..sort();
|
||||
final out = Uint8List(20 + normalizedAvailable.length);
|
||||
out[0] = _swarmMagic;
|
||||
out[1] = _swarmKindAvailability;
|
||||
out[2] = _encodeMediaType(mediaType);
|
||||
_writeSessionId(out, 3, sessionId);
|
||||
_writeKey6(out, 7, requesterKey6);
|
||||
_writeKey6(out, 13, responderKey6);
|
||||
out[19] = normalizedAvailable.length;
|
||||
for (var i = 0; i < normalizedAvailable.length; i++) {
|
||||
out[20 + i] = normalizedAvailable[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static MediaSwarmAvailability? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length < 20 ||
|
||||
payload[0] != _swarmMagic ||
|
||||
payload[1] != _swarmKindAvailability) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final mediaType = _decodeMediaType(payload[2]);
|
||||
if (mediaType == null) return null;
|
||||
|
||||
final availableCount = payload[19];
|
||||
if (payload.length != 20 + availableCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaSwarmAvailability(
|
||||
mediaType: mediaType,
|
||||
sessionId: _readSessionId(payload, 3),
|
||||
requesterKey6: _readKey6(payload, 7),
|
||||
responderKey6: _readKey6(payload, 13),
|
||||
availableIndices: payload.sublist(20),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _encodeMediaType(String mediaType) {
|
||||
return switch (mediaType) {
|
||||
'voice' => 0x01,
|
||||
'image' => 0x02,
|
||||
_ => throw ArgumentError.value(mediaType, 'mediaType'),
|
||||
};
|
||||
}
|
||||
|
||||
String? _decodeMediaType(int raw) {
|
||||
return switch (raw) {
|
||||
0x01 => 'voice',
|
||||
0x02 => 'image',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
void _writeSessionId(Uint8List out, int offset, String sessionId) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[offset + i] = int.parse(
|
||||
sessionId.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _readSessionId(Uint8List payload, int offset) {
|
||||
return payload
|
||||
.sublist(offset, offset + 4)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
|
||||
void _writeKey6(Uint8List out, int offset, String key6) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(key6)) {
|
||||
throw ArgumentError.value(key6, 'key6', 'Expected 12 hex chars');
|
||||
}
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[offset + i] = int.parse(key6.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
}
|
||||
|
||||
String _readKey6(Uint8List payload, int offset) {
|
||||
return payload
|
||||
.sublist(offset, offset + 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
149
lib/utils/message_airtime_estimator.dart
Normal file
149
lib/utils/message_airtime_estimator.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import '../models/message.dart';
|
||||
import 'image_message_parser.dart';
|
||||
import 'voice_message_parser.dart';
|
||||
|
||||
const int _defaultLoRaSf = 10;
|
||||
const int _defaultLoRaCr = 5;
|
||||
const int _defaultLoRaBwHz = 250000;
|
||||
const int _defaultLoRaPreambleSymbols = 8;
|
||||
const int _defaultLoRaCrcEnabled = 1;
|
||||
const int _defaultLoRaExplicitHeader = 1;
|
||||
const double _defaultAirtimeBudgetFactor = 1.0;
|
||||
const int _meshPacketHeaderBytes = 2;
|
||||
const int _textFrameBaseBytes = 10;
|
||||
|
||||
Duration estimateMessageTransmitDuration(
|
||||
Message message, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||
if (imageEnvelope != null) {
|
||||
return estimateImageTransmitDuration(
|
||||
fragmentCount: imageEnvelope.total,
|
||||
sizeBytes: imageEnvelope.sizeBytes,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
|
||||
if (voiceEnvelope != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: voiceEnvelope.mode,
|
||||
packetCount: voiceEnvelope.total,
|
||||
durationMs: voiceEnvelope.durationMs,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final voicePacket = VoicePacket.tryParseText(message.text);
|
||||
if (voicePacket != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: voicePacket.mode,
|
||||
packetCount: voicePacket.total,
|
||||
durationMs: voicePacket.durationMs * voicePacket.total,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final normalizedPathLen = _normalizedPathLen(message.pathLen);
|
||||
final payloadBytes = _textFrameBaseBytes + message.text.length;
|
||||
final hops = normalizedPathLen + 1;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
_meshPacketHeaderBytes + normalizedPathLen + payloadBytes,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
|
||||
return Duration(
|
||||
milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops)
|
||||
.round(),
|
||||
);
|
||||
}
|
||||
|
||||
int _normalizedPathLen(int pathLen) {
|
||||
if (pathLen < 0 || pathLen >= 255) return 0;
|
||||
return pathLen.clamp(0, 64).toInt();
|
||||
}
|
||||
|
||||
double _estimateLoRaAirtimeMs(
|
||||
int payloadLenBytes, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final sf = _normalizeSf(radioSf);
|
||||
final bw = _resolveBandwidthHz(radioBw).toDouble();
|
||||
final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return preambleMs + payloadMs;
|
||||
}
|
||||
|
||||
int _normalizeSf(int? value) {
|
||||
if (value == null) return _defaultLoRaSf;
|
||||
if (value >= 5 && value <= 12) return value;
|
||||
return _defaultLoRaSf;
|
||||
}
|
||||
|
||||
int _normalizeCr(int? value) {
|
||||
if (value == null) return _defaultLoRaCr;
|
||||
if (value >= 5 && value <= 8) return value;
|
||||
return _defaultLoRaCr;
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return _defaultLoRaBwHz;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return _defaultLoRaBwHz;
|
||||
}
|
||||
}
|
||||
87
lib/utils/raw_route_probe.dart
Normal file
87
lib/utils/raw_route_probe.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class RawRouteProbeRequest {
|
||||
static const int _binaryMagic = 0x70; // 'p'
|
||||
|
||||
final int nonce;
|
||||
final String requesterKey6;
|
||||
|
||||
const RawRouteProbeRequest({
|
||||
required this.nonce,
|
||||
required this.requesterKey6,
|
||||
});
|
||||
|
||||
static RawRouteProbeRequest? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length != 11 || payload[0] != _binaryMagic) return null;
|
||||
try {
|
||||
final nonce =
|
||||
(payload[1] << 24) |
|
||||
(payload[2] << 16) |
|
||||
(payload[3] << 8) |
|
||||
payload[4];
|
||||
final requesterKey6 = payload
|
||||
.sublist(5, 11)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
throw ArgumentError.value(
|
||||
requesterKey6,
|
||||
'requesterKey6',
|
||||
'Expected 12 hex chars',
|
||||
);
|
||||
}
|
||||
final out = Uint8List(11);
|
||||
out[0] = _binaryMagic;
|
||||
out[1] = (nonce >> 24) & 0xFF;
|
||||
out[2] = (nonce >> 16) & 0xFF;
|
||||
out[3] = (nonce >> 8) & 0xFF;
|
||||
out[4] = nonce & 0xFF;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[5 + i] = int.parse(
|
||||
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class RawRouteProbeAck {
|
||||
static const int _binaryMagic = 0x71; // 'q'
|
||||
|
||||
final int nonce;
|
||||
|
||||
const RawRouteProbeAck({required this.nonce});
|
||||
|
||||
static RawRouteProbeAck? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length != 5 || payload[0] != _binaryMagic) return null;
|
||||
try {
|
||||
final nonce =
|
||||
(payload[1] << 24) |
|
||||
(payload[2] << 16) |
|
||||
(payload[3] << 8) |
|
||||
payload[4];
|
||||
return RawRouteProbeAck(nonce: nonce);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final out = Uint8List(5);
|
||||
out[0] = _binaryMagic;
|
||||
out[1] = (nonce >> 24) & 0xFF;
|
||||
out[2] = (nonce >> 16) & 0xFF;
|
||||
out[3] = (nonce >> 8) & 0xFF;
|
||||
out[4] = nonce & 0xFF;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import 'dart:typed_data';
|
||||
import '../models/contact.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
|
||||
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
|
||||
enum TransmissionTargetFailure {
|
||||
unknownContact,
|
||||
unknownRoute,
|
||||
tooFar,
|
||||
unreachable,
|
||||
}
|
||||
|
||||
class TransmissionTargetResolution {
|
||||
final Contact? target;
|
||||
@@ -16,7 +21,7 @@ class TransmissionTargetResolution {
|
||||
required this.maxHops,
|
||||
});
|
||||
|
||||
int get hops => target?.outPathLen ?? -1;
|
||||
int get hops => target?.routeHopCount ?? -1;
|
||||
bool get isValid => target != null && failure == null;
|
||||
}
|
||||
|
||||
@@ -32,11 +37,17 @@ class TransmissionTargetResolver {
|
||||
String? senderName,
|
||||
}) {
|
||||
if (isSentByMe) {
|
||||
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey);
|
||||
final recipient = _findByRecipientKey(
|
||||
contactsProvider,
|
||||
recipientPublicKey,
|
||||
);
|
||||
if (recipient != null) return recipient;
|
||||
}
|
||||
|
||||
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope);
|
||||
final byEnvelope = _findByEnvelopeKey6(
|
||||
contactsProvider,
|
||||
senderKey6FromEnvelope,
|
||||
);
|
||||
if (byEnvelope != null) return byEnvelope;
|
||||
|
||||
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
|
||||
@@ -64,7 +75,9 @@ class TransmissionTargetResolver {
|
||||
senderName: senderName,
|
||||
);
|
||||
|
||||
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) {
|
||||
if (target == null ||
|
||||
!target.routeHasPath ||
|
||||
target.routeHopCount > maxFetchHops) {
|
||||
await refreshContacts();
|
||||
target = resolveLocalTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
@@ -83,14 +96,14 @@ class TransmissionTargetResolver {
|
||||
maxHops: maxFetchHops,
|
||||
);
|
||||
}
|
||||
if (target.outPathLen < 0) {
|
||||
if (!target.routeHasPath) {
|
||||
return TransmissionTargetResolution(
|
||||
target: target,
|
||||
failure: TransmissionTargetFailure.unknownRoute,
|
||||
maxHops: maxFetchHops,
|
||||
);
|
||||
}
|
||||
if (target.outPathLen > maxFetchHops) {
|
||||
if (target.routeHopCount > maxFetchHops) {
|
||||
return TransmissionTargetResolution(
|
||||
target: target,
|
||||
failure: TransmissionTargetFailure.tooFar,
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
||||
const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload
|
||||
const int _voicePacketHeaderBytes = 6; // voice packet binary header in payload
|
||||
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||
@@ -38,7 +38,7 @@ enum VoicePacketMode {
|
||||
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
|
||||
///
|
||||
/// Binary format (direct contacts, received via pushRawData):
|
||||
/// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...]
|
||||
/// [0x56 'V'][sessionId:4B][index:1B][codec2Data...]
|
||||
class VoicePacket {
|
||||
final String sessionId; // 8 hex chars (4 bytes)
|
||||
final VoicePacketMode mode;
|
||||
@@ -104,8 +104,7 @@ class VoicePacket {
|
||||
// ── Binary format ────────────────────────────────────────────────────────
|
||||
|
||||
static const int _binaryMagic = 0x56; // 'V'
|
||||
static const int _binaryHeaderLen =
|
||||
8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
|
||||
static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1)
|
||||
|
||||
static bool isVoiceBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||
@@ -119,16 +118,13 @@ class VoicePacket {
|
||||
final sessionId = sessionBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final modeId = payload[5];
|
||||
final index = payload[6];
|
||||
final total = payload[7];
|
||||
if (total < 1) return null;
|
||||
final index = payload[5];
|
||||
final codec2Data = payload.sublist(_binaryHeaderLen);
|
||||
return VoicePacket(
|
||||
sessionId: sessionId,
|
||||
mode: VoicePacketMode.fromId(modeId),
|
||||
mode: VoicePacketMode.mode1300,
|
||||
index: index,
|
||||
total: total,
|
||||
total: 0,
|
||||
codec2Data: codec2Data,
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -148,9 +144,7 @@ class VoicePacket {
|
||||
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
|
||||
out[0] = _binaryMagic;
|
||||
out.setRange(1, 5, sessionBytes);
|
||||
out[5] = mode.id;
|
||||
out[6] = index;
|
||||
out[7] = total;
|
||||
out[5] = index;
|
||||
out.setRange(_binaryHeaderLen, out.length, codec2Data);
|
||||
return out;
|
||||
}
|
||||
@@ -174,25 +168,25 @@ class VoicePacket {
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
|
||||
String toString() {
|
||||
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
|
||||
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight public/direct message envelope advertising voice availability.
|
||||
///
|
||||
/// Text format:
|
||||
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
||||
/// VE3:{sid}:{mode}:{total}:{durS}
|
||||
/// Example:
|
||||
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
|
||||
/// VE3:00112233:1:4:4
|
||||
class VoiceEnvelope {
|
||||
static const String _prefix = 'VE2:';
|
||||
static const String _prefix = 'VE3:';
|
||||
|
||||
final String sessionId;
|
||||
final VoicePacketMode mode;
|
||||
final int total;
|
||||
final int durationMs;
|
||||
final String senderKey6;
|
||||
final int timestampSec;
|
||||
final int version;
|
||||
|
||||
const VoiceEnvelope({
|
||||
@@ -200,9 +194,7 @@ class VoiceEnvelope {
|
||||
required this.mode,
|
||||
required this.total,
|
||||
required this.durationMs,
|
||||
required this.senderKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 2,
|
||||
this.version = 3,
|
||||
});
|
||||
|
||||
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
||||
@@ -215,14 +207,12 @@ class VoiceEnvelope {
|
||||
|
||||
static VoiceEnvelope? _tryParse(String body) {
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 6) return null;
|
||||
if (parts.length != 4) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final mode = _parseInt(parts[1], base36: true);
|
||||
final total = _parseInt(parts[2], base36: true);
|
||||
final durS = _parseInt(parts[3], base36: true);
|
||||
final senderKey6 = parts[4];
|
||||
final ts = _parseInt(parts[5], base36: true);
|
||||
|
||||
if (sid == null) {
|
||||
return null;
|
||||
@@ -232,19 +222,13 @@ class VoiceEnvelope {
|
||||
}
|
||||
if (total == null || total < 1 || total > 255) return null;
|
||||
if (durS == null || durS < 0 || durS > 10 * 60) return null;
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
|
||||
return null;
|
||||
}
|
||||
if (ts == null || ts <= 0) return null;
|
||||
|
||||
return VoiceEnvelope(
|
||||
sessionId: sid,
|
||||
mode: VoicePacketMode.fromId(mode),
|
||||
total: total,
|
||||
durationMs: durS * 1000,
|
||||
senderKey6: senderKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
version: 3,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -253,7 +237,7 @@ class VoiceEnvelope {
|
||||
|
||||
String encodeText() {
|
||||
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,18 +398,17 @@ int _resolveBandwidthHz(int? rawBw) {
|
||||
/// Direct control-plane request to fetch voice packets for a session.
|
||||
///
|
||||
/// Text format:
|
||||
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||
/// VR3:{sid}:{want}:{requesterKey6}
|
||||
/// Example:
|
||||
/// VR2:00112233:a:aabbccddeeff:kf12oi
|
||||
/// VR3:00112233:a:aabbccddeeff
|
||||
class VoiceFetchRequest {
|
||||
static const String _prefix = 'VR2:';
|
||||
static const String _prefix = 'VR3:';
|
||||
static const int _binaryMagic = 0x72; // 'r'
|
||||
|
||||
final String sessionId;
|
||||
final String want;
|
||||
final List<int> missingIndices;
|
||||
final String requesterKey6;
|
||||
final int timestampSec;
|
||||
final int version;
|
||||
|
||||
const VoiceFetchRequest({
|
||||
@@ -433,8 +416,7 @@ class VoiceFetchRequest {
|
||||
this.want = 'all',
|
||||
this.missingIndices = const [],
|
||||
required this.requesterKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 2,
|
||||
this.version = 3,
|
||||
});
|
||||
|
||||
static bool isVoiceFetchRequestText(String text) =>
|
||||
@@ -450,7 +432,7 @@ class VoiceFetchRequest {
|
||||
|
||||
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
|
||||
if (!isVoiceFetchRequestBinary(payload)) return null;
|
||||
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
|
||||
if (payload.length < 13) return null; // magic+sid+flags+key6+count
|
||||
try {
|
||||
final sidBytes = payload.sublist(1, 5);
|
||||
final sid = sidBytes
|
||||
@@ -463,25 +445,19 @@ class VoiceFetchRequest {
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final ts =
|
||||
(payload[12] << 24) |
|
||||
(payload[13] << 16) |
|
||||
(payload[14] << 8) |
|
||||
payload[15];
|
||||
final missingCount = payload[16];
|
||||
if (payload.length != 17 + missingCount) return null;
|
||||
final missingCount = payload[12];
|
||||
if (payload.length != 13 + missingCount) return null;
|
||||
final wantMissing = (flags & 0x01) == 0x01;
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < missingCount; i++) {
|
||||
missing.add(payload[17 + i]);
|
||||
missing.add(payload[13 + i]);
|
||||
}
|
||||
return VoiceFetchRequest(
|
||||
sessionId: sid,
|
||||
want: wantMissing ? 'missing' : 'all',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
version: 3,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -490,12 +466,11 @@ class VoiceFetchRequest {
|
||||
|
||||
static VoiceFetchRequest? _tryParse(String body) {
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 4) return null;
|
||||
if (parts.length != 3) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final wantToken = parts[1];
|
||||
final requesterKey6 = parts[2];
|
||||
final ts = _parseInt(parts[3], base36: true);
|
||||
final normalizedWant = wantToken == 'a'
|
||||
? 'all'
|
||||
: ((wantToken.startsWith('m'))
|
||||
@@ -517,15 +492,13 @@ class VoiceFetchRequest {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
return null;
|
||||
}
|
||||
if (ts == null || ts <= 0) return null;
|
||||
|
||||
return VoiceFetchRequest(
|
||||
sessionId: sid,
|
||||
want: normalizedWant,
|
||||
missingIndices: missingIndices,
|
||||
requesterKey6: requesterKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
version: 3,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -536,7 +509,7 @@ class VoiceFetchRequest {
|
||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||
: (want == 'all' ? 'a' : want);
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
@@ -555,7 +528,7 @@ class VoiceFetchRequest {
|
||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||
: <int>[];
|
||||
|
||||
final out = Uint8List(17 + missing.length);
|
||||
final out = Uint8List(13 + missing.length);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
@@ -567,13 +540,9 @@ class VoiceFetchRequest {
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
out[12] = (timestampSec >> 24) & 0xFF;
|
||||
out[13] = (timestampSec >> 16) & 0xFF;
|
||||
out[14] = (timestampSec >> 8) & 0xFF;
|
||||
out[15] = timestampSec & 0xFF;
|
||||
out[16] = missing.length;
|
||||
out[12] = missing.length;
|
||||
for (var i = 0; i < missing.length; i++) {
|
||||
out[17 + i] = missing[i];
|
||||
out[13 + i] = missing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
|
||||
/// Reusable location display widget with tap-to-show modal
|
||||
/// Shows coordinates in a compact format with ability to view all formats
|
||||
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
Text(
|
||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
// Non-compact version (just text)
|
||||
return Text(
|
||||
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
|
||||
children: [
|
||||
const Text(
|
||||
'Location Formats',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
|
||||
_buildFormatRow(
|
||||
context,
|
||||
'Plus Code',
|
||||
_convertToPlusCode(location.latitude, location.longitude),
|
||||
formatPlusCode(location.latitude, location.longitude),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.copiedToClipboard(label),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
|
||||
// Full MGRS would require UTM conversion library
|
||||
return '$zone$letter (approximate)';
|
||||
}
|
||||
|
||||
/// Convert to Google Plus Code format
|
||||
/// Simplified implementation - returns approximate code
|
||||
String _convertToPlusCode(double lat, double lon) {
|
||||
// This is a simplified version - full Plus Code requires the open_location_code package
|
||||
const base = '23456789CFGHJMPQRVWX';
|
||||
|
||||
// Normalize coordinates
|
||||
lat = (lat + 90) / 180; // 0 to 1
|
||||
lon = (lon + 180) / 360; // 0 to 1
|
||||
|
||||
String code = '';
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if (i == 4) code += '+';
|
||||
|
||||
int latDigit = (lat * 20).floor() % 20;
|
||||
int lonDigit = (lon * 20).floor() % 20;
|
||||
|
||||
code += base[latDigit];
|
||||
code += base[lonDigit];
|
||||
|
||||
lat = (lat * 20) % 1;
|
||||
lon = (lon * 20) % 1;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,19 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
final List<DiscoveredServer> _discoveredServers = [];
|
||||
int _scannedCount = 0;
|
||||
int _totalToScan = 0;
|
||||
String? _connectingToServerKey; // Track which server is being connected to (ip:port)
|
||||
int _lastTabIndex = 0;
|
||||
String?
|
||||
_connectingToServerKey; // Track which server is being connected to (ip:port)
|
||||
|
||||
// Named listener method for proper cleanup
|
||||
void _onTabChanged() {
|
||||
if (_tabController.index == _lastTabIndex) return;
|
||||
_lastTabIndex = _tabController.index;
|
||||
|
||||
if (_tabController.index == 0) {
|
||||
_refreshBleDevices();
|
||||
}
|
||||
|
||||
if (_tabController.index == 1) {
|
||||
// Switched to network tab
|
||||
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
|
||||
@@ -47,13 +56,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
|
||||
_connectionProvider = Provider.of<ConnectionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
// Defer scan startup until after the first frame so Provider listeners
|
||||
// are not notified while this dialog is still being built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_connectionProvider.startScan();
|
||||
_refreshBleDevices();
|
||||
});
|
||||
|
||||
// Set up network scanner callbacks
|
||||
@@ -101,6 +113,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
_networkScanner.scan();
|
||||
}
|
||||
|
||||
Future<void> _refreshBleDevices() async {
|
||||
await _connectionProvider.stopScan();
|
||||
if (!mounted) return;
|
||||
await _connectionProvider.startScan();
|
||||
}
|
||||
|
||||
Color _getSignalColor(int rssi) {
|
||||
if (rssi >= -60) return Colors.green;
|
||||
if (rssi >= -75) return Colors.orange;
|
||||
@@ -218,10 +236,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
Icons.refresh,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
onPressed: () {
|
||||
connectionProvider.stopScan();
|
||||
connectionProvider.startScan();
|
||||
},
|
||||
onPressed: _refreshBleDevices,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -255,10 +270,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
connectionProvider.stopScan();
|
||||
connectionProvider.startScan();
|
||||
},
|
||||
onPressed: _refreshBleDevices,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(AppLocalizations.of(context)!.scanAgain),
|
||||
),
|
||||
|
||||
230
lib/widgets/contacts/contact_route_dialog.dart
Normal file
230
lib/widgets/contacts/contact_route_dialog.dart
Normal file
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
|
||||
class ContactRouteDialog extends StatefulWidget {
|
||||
final Contact contact;
|
||||
final List<Contact> availableContacts;
|
||||
|
||||
const ContactRouteDialog({
|
||||
super.key,
|
||||
required this.contact,
|
||||
required this.availableContacts,
|
||||
});
|
||||
|
||||
static Future<ParsedContactRoute?> show(
|
||||
BuildContext context, {
|
||||
required Contact contact,
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
return showDialog<ParsedContactRoute>(
|
||||
context: context,
|
||||
builder: (context) => ContactRouteDialog(
|
||||
contact: contact,
|
||||
availableContacts: availableContacts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ContactRouteDialog> createState() => _ContactRouteDialogState();
|
||||
}
|
||||
|
||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
late final TextEditingController _controller;
|
||||
late int _selectedHashSize;
|
||||
ParsedContactRoute? _parsedRoute;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedHashSize = widget.contact.routeHasPath
|
||||
? widget.contact.routeHashSize
|
||||
: 1;
|
||||
_controller = TextEditingController(
|
||||
text: widget.contact.routeCanonicalText,
|
||||
);
|
||||
_controller.addListener(_reparse);
|
||||
_reparse();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller
|
||||
..removeListener(_reparse)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reparse() {
|
||||
final input = _controller.text.trim();
|
||||
if (input.isEmpty) {
|
||||
setState(() {
|
||||
_parsedRoute = null;
|
||||
_errorText = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = ContactRouteCodec.parse(input);
|
||||
setState(() {
|
||||
_parsedRoute = parsed;
|
||||
_selectedHashSize = parsed.hashSize;
|
||||
_errorText = null;
|
||||
});
|
||||
} on ContactRouteFormatException catch (error) {
|
||||
setState(() {
|
||||
_parsedRoute = null;
|
||||
_errorText = error.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _tokenFor(Contact contact, int hashSize) {
|
||||
final hex = contact.publicKeyHex.toUpperCase();
|
||||
final length = hashSize * 2;
|
||||
if (hex.length < length) {
|
||||
return hex;
|
||||
}
|
||||
return hex.substring(0, length);
|
||||
}
|
||||
|
||||
void _appendHop(Contact contact) {
|
||||
final token = _tokenFor(contact, _selectedHashSize);
|
||||
final current = _controller.text.trim();
|
||||
_controller.text = current.isEmpty ? token : '$current,$token';
|
||||
_controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _controller.text.length),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final routeCandidates =
|
||||
widget.availableContacts
|
||||
.where((contact) => contact.isRepeater || contact.isRoom)
|
||||
.toList()
|
||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Set Route for ${widget.contact.displayName}'),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Path hash size',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [1, 2, 3]
|
||||
.map(
|
||||
(hashSize) => ChoiceChip(
|
||||
label: Text(
|
||||
'$hashSize byte${hashSize == 1 ? '' : 's'}',
|
||||
),
|
||||
selected: _selectedHashSize == hashSize,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_selectedHashSize = hashSize;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Route',
|
||||
hintText: _selectedHashSize == 1
|
||||
? 'AA,BB,CC'
|
||||
: _selectedHashSize == 2
|
||||
? 'AABB,CCDD'
|
||||
: 'AABBCC,DDEEFF',
|
||||
helperText:
|
||||
'Use comma-separated hops. Colon form like AA:BB is also accepted.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_parsedRoute == null
|
||||
? 'Preview: enter a route to validate it.'
|
||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_parsedRoute != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
_parsedRoute!.canonicalText,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Pick hops from contacts',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (routeCandidates.isEmpty)
|
||||
const Text(
|
||||
'No repeater or room contacts are available for route building.',
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routeCandidates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final candidate = routeCandidates[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(candidate.displayName),
|
||||
subtitle: Text(
|
||||
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _appendHop(candidate),
|
||||
child: Text(
|
||||
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _parsedRoute == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(_parsedRoute),
|
||||
child: const Text('Set Route'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,452 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
class DirectMessageSheet extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const DirectMessageSheet({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<DirectMessageSheet> createState() => _DirectMessageSheetState();
|
||||
}
|
||||
|
||||
class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
int _characterCount = 0;
|
||||
static const int _maxCharacters = 160;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController.addListener(_updateCharacterCount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateCharacterCount() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_characterCount = _textController.text.length;
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert current GPS location at cursor position
|
||||
Future<void> _insertCurrentLocation() async {
|
||||
try {
|
||||
// Check location permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Location permission denied');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Location permission permanently denied');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current position
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
),
|
||||
);
|
||||
|
||||
// Format location text
|
||||
final locationText =
|
||||
'📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}';
|
||||
|
||||
// Check if adding location would exceed limit
|
||||
final currentText = _textController.text;
|
||||
if (currentText.length + locationText.length > _maxCharacters) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(
|
||||
context,
|
||||
'Adding location would exceed 160 character limit',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert at cursor position or append
|
||||
final selection = _textController.selection;
|
||||
final newText = currentText.replaceRange(
|
||||
selection.start >= 0 ? selection.start : currentText.length,
|
||||
selection.end >= 0 ? selection.end : currentText.length,
|
||||
locationText,
|
||||
);
|
||||
|
||||
_textController.text = newText;
|
||||
|
||||
// Move cursor to end of inserted text
|
||||
final newCursorPosition =
|
||||
(selection.start >= 0 ? selection.start : currentText.length) +
|
||||
locationText.length;
|
||||
_textController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newCursorPosition),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Failed to get location: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendDirectMessage() async {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.notConnectedToDevice,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create message ID
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent';
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Get current device's public key (first 6 bytes)
|
||||
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
|
||||
|
||||
// Create sent message object with recipient public key for retry support
|
||||
final sentMessage = Message(
|
||||
id: messageId,
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey:
|
||||
widget.contact.publicKey, // Store recipient for retry
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
// Pass contact for retry logic
|
||||
messagesProvider.addSentMessage(sentMessage, contact: widget.contact);
|
||||
|
||||
// Send direct message to contact (include contact for path logging)
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: widget.contact.publicKey,
|
||||
text: text,
|
||||
messageId: messageId, // Pass message ID for tracking
|
||||
contact: widget.contact,
|
||||
);
|
||||
|
||||
if (!sentSuccessfully) {
|
||||
// Mark message as failed if sending failed
|
||||
messagesProvider.markMessageFailed(messageId);
|
||||
}
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context); // Close the dialog
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.failedToSend(e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
final isSimpleMode = appProvider.isSimpleMode;
|
||||
final contactLocation = widget.contact.displayLocation;
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.directMessage,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.contact.displayName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48), // Spacer to keep title centered
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Mini map in simple mode (scrollable content)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
if (isSimpleMode && contactLocation != null) ...[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// Hide keyboard when tapping on map
|
||||
_focusNode.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(
|
||||
contactLocation.latitude,
|
||||
contactLocation.longitude,
|
||||
),
|
||||
initialZoom: 13.0,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags:
|
||||
InteractiveFlag.pinchZoom |
|
||||
InteractiveFlag.drag,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(
|
||||
contactLocation.latitude,
|
||||
contactLocation.longitude,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on,
|
||||
color: colorScheme.primary,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Location coordinates
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.gps_fixed,
|
||||
size: 14,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Message input
|
||||
Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: 3,
|
||||
autofocus: true,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
counterText: '', // Hide default counter
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendDirectMessage(),
|
||||
),
|
||||
// Always-visible character counter
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$_characterCount / $_maxCharacters',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _characterCount > 155
|
||||
? Colors.red
|
||||
: (_characterCount > 140
|
||||
? Colors.orange
|
||||
: colorScheme.onSurfaceVariant),
|
||||
fontWeight: _characterCount > 140
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Location and Send buttons
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _insertCurrentLocation,
|
||||
icon: const Icon(Icons.my_location, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.myLocation),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendDirectMessage,
|
||||
icon: const Icon(Icons.send),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.sendDirectMessage,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
disabledBackgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
disabledForegroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_avif/flutter_avif.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/image_provider.dart' as ip;
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/transmission_target_resolver.dart';
|
||||
import 'transfer_timeout.dart';
|
||||
@@ -32,7 +35,9 @@ class ImageMessageBubble extends StatefulWidget {
|
||||
|
||||
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
static const int _maxFetchHops = 3;
|
||||
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
|
||||
bool _isRequesting = false;
|
||||
bool _isPartialRequest = false;
|
||||
String? _errorText;
|
||||
Timer? _requestTimeoutTimer;
|
||||
|
||||
@@ -58,6 +63,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
|
||||
return Consumer<ip.ImageProvider>(
|
||||
builder: (context, imageProvider, _) {
|
||||
final transferCount = context.select<MessagesProvider, int>(
|
||||
(provider) => provider.transferCountForSession(
|
||||
imageSessionId: envelope.sessionId,
|
||||
),
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final session = imageProvider.session(envelope.sessionId);
|
||||
final sender = TransmissionTargetResolver.resolveLocalTarget(
|
||||
@@ -65,11 +75,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
||||
final eta = imageProvider.estimateRemainingTransferTime(
|
||||
@@ -81,6 +90,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
}
|
||||
@@ -90,6 +100,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? envelope.total;
|
||||
final imageBytes = isComplete ? session?.imageBytes : null;
|
||||
final fragmentPresence =
|
||||
session?.fragments.map((fragment) => fragment != null).toList() ??
|
||||
List<bool>.filled(total, false);
|
||||
final isReceivingData =
|
||||
!_isRequesting &&
|
||||
!isComplete &&
|
||||
_hasRecentInboundActivity(
|
||||
lastReceivedAt: session?.lastFragmentAt,
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isComplete
|
||||
@@ -109,8 +130,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
imageBytes: imageBytes,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
received: received,
|
||||
total: total,
|
||||
fragmentPresence: fragmentPresence,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
@@ -124,6 +147,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_statusText(
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
received: received,
|
||||
total: total,
|
||||
envelope: envelope,
|
||||
@@ -134,6 +159,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
eta: eta,
|
||||
pathLen: effectivePathLen,
|
||||
transferCount: transferCount,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -155,8 +181,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required Uint8List? imageBytes,
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required int received,
|
||||
required int total,
|
||||
required List<bool> fragmentPresence,
|
||||
required ImageEnvelope envelope,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
@@ -179,19 +207,28 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isRequesting) ...[
|
||||
// Download progress ring.
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
value: total > 0 ? received / total : null,
|
||||
strokeWidth: 3,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_PacketBlockProgress(
|
||||
presence: fragmentPresence,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
highlightMissing: _isPartialRequest,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$received/$total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$received/$total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
@@ -228,16 +265,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
] else ...[
|
||||
// Tap-to-load icon.
|
||||
IconButton(
|
||||
onPressed: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
onPressed: isReceivingData
|
||||
? null
|
||||
: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
),
|
||||
icon: Icon(
|
||||
isReceivingData
|
||||
? Icons.downloading_rounded
|
||||
: Icons.download_rounded,
|
||||
size: 40,
|
||||
),
|
||||
icon: const Icon(Icons.download_rounded, size: 40),
|
||||
color: Colors.white70,
|
||||
tooltip: 'Load image',
|
||||
tooltip: isReceivingData
|
||||
? 'Image is already being received'
|
||||
: 'Load image',
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -254,23 +300,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
|
||||
final conn = context.read<ConnectionProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
imageProvider.resumeIncomingSession(envelope.sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
final appProvider = context.read<AppProvider>();
|
||||
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: conn.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
@@ -278,6 +326,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
@@ -285,23 +334,94 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unreachable) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route did not respond to a path check. Sync contacts/path and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final sender = resolution.target!;
|
||||
if (sender.outPathLen >= 2) {
|
||||
var sender = resolution.target!;
|
||||
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
await conn.getContacts();
|
||||
if (!mounted) return;
|
||||
resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: conn.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
sender = resolution.target!;
|
||||
routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route did not respond on the raw transport path.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
||||
'Image fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => _errorText = null);
|
||||
final deviceKey = conn.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Device key is unavailable.',
|
||||
@@ -318,30 +438,31 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
|
||||
final isPartialResume =
|
||||
missing.isNotEmpty && missing.length < envelope.total;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_isPartialRequest = isPartialResume;
|
||||
_errorText = null;
|
||||
});
|
||||
final request = isPartialResume
|
||||
? ImageFetchRequest(
|
||||
sessionId: envelope.sessionId,
|
||||
want: 'missing',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
)
|
||||
: ImageFetchRequest(
|
||||
sessionId: envelope.sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final payload = request.encodeBinary();
|
||||
try {
|
||||
debugPrint(
|
||||
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
|
||||
);
|
||||
await conn.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -349,6 +470,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image fetch failed to send request');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image unavailable right now';
|
||||
});
|
||||
}
|
||||
@@ -357,8 +479,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (!mounted) return;
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||
@@ -380,6 +502,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image fetch timed out');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image fetch timed out';
|
||||
});
|
||||
}
|
||||
@@ -401,10 +524,19 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image receive canceled';
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRequestState() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showBlockingAlert(String title, String message) async {
|
||||
if (!mounted) return;
|
||||
_showToast('$title: $message');
|
||||
@@ -426,6 +558,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
static String _statusText({
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required int received,
|
||||
required int total,
|
||||
required ImageEnvelope envelope,
|
||||
@@ -436,6 +570,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required String? error,
|
||||
required bool isSentByMe,
|
||||
required Duration? eta,
|
||||
required int transferCount,
|
||||
}) {
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: envelope.total,
|
||||
@@ -450,16 +585,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (error != null) return error;
|
||||
if (isRequesting) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
|
||||
final actionLabel = isPartialRequest
|
||||
? '📥 Fetching missing fragments…'
|
||||
: '📥 Loading…';
|
||||
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
return '📥 Receiving… $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isComplete) {
|
||||
final base =
|
||||
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
||||
return isSentByMe
|
||||
? '$base · ${envelope.total} seg · $txEstimateLabel'
|
||||
? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '$base · $txEstimateLabel';
|
||||
}
|
||||
return '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
}
|
||||
|
||||
static String _formatTransmitEstimate(Duration value) {
|
||||
@@ -477,6 +621,22 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return 'ETA ~${minutes}m ${seconds}s';
|
||||
}
|
||||
|
||||
static String _formatTransferCount(int transferCount) {
|
||||
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
bool _hasRecentInboundActivity({
|
||||
required DateTime? lastReceivedAt,
|
||||
required int received,
|
||||
required int total,
|
||||
}) {
|
||||
if (lastReceivedAt == null || received <= 0 || received >= total) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(lastReceivedAt) <=
|
||||
_recentInboundActivityWindow;
|
||||
}
|
||||
|
||||
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
||||
showGeneralDialog<void>(
|
||||
context: context,
|
||||
@@ -523,3 +683,67 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PacketBlockProgress extends StatelessWidget {
|
||||
final List<bool> presence;
|
||||
final Color activeColor;
|
||||
final bool highlightMissing;
|
||||
|
||||
const _PacketBlockProgress({
|
||||
required this.presence,
|
||||
required this.activeColor,
|
||||
this.highlightMissing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (presence.isEmpty) {
|
||||
return const SizedBox(width: 96, height: 12);
|
||||
}
|
||||
|
||||
final bucketCount = presence.length <= 24 ? presence.length : 24;
|
||||
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
|
||||
final start = (bucketIndex * presence.length) ~/ bucketCount;
|
||||
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
|
||||
final safeEnd = end <= start ? start + 1 : end;
|
||||
final slice = presence.sublist(start, safeEnd);
|
||||
final received = slice.where((value) => value).length;
|
||||
return slice.isEmpty ? 0.0 : received / slice.length;
|
||||
});
|
||||
final missingColor = highlightMissing
|
||||
? Colors.amberAccent
|
||||
: Colors.white.withValues(alpha: 0.14);
|
||||
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final fill in bucketFill)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 12,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fill > 0
|
||||
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.45 : 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(
|
||||
color: fill > 0
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.7 : 0.18,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
180
lib/widgets/messages/message_bubble_header.dart
Normal file
180
lib/widgets/messages/message_bubble_header.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
|
||||
Widget buildMessageHeaderAvatar(
|
||||
BuildContext context, {
|
||||
required bool isOwnMessage,
|
||||
required bool isChannelMessage,
|
||||
required dynamic senderContact,
|
||||
required String displayName,
|
||||
}) {
|
||||
if (isOwnMessage) {
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.account_circle,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (senderContact is Contact) {
|
||||
return ContactAvatar(
|
||||
contact: senderContact,
|
||||
radius: 10.5,
|
||||
displayName: displayName,
|
||||
);
|
||||
}
|
||||
|
||||
final background = isChannelMessage
|
||||
? Colors.teal.withValues(alpha: 0.16)
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
final foreground = isChannelMessage
|
||||
? Colors.teal.shade800
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: background,
|
||||
child: Text(
|
||||
AvatarLabelHelper.buildLabel(displayName),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBubbleMetaFooter(
|
||||
BuildContext context, {
|
||||
required Message message,
|
||||
required bool isSarMarker,
|
||||
}) {
|
||||
final metaColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||
|
||||
final items = <Widget>[];
|
||||
final sentEchoLabel = message.isSentMessage && message.echoCount > 0
|
||||
? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}'
|
||||
: null;
|
||||
|
||||
if (!isSarMarker && sentEchoLabel != null) {
|
||||
items.addAll([
|
||||
Icon(Icons.hub_outlined, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
sentEchoLabel,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
} else if (!isSarMarker && message.pathLen < 255) {
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
items.add(
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: items),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChannelHeaderPill(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
IconData icon = Icons.campaign_outlined,
|
||||
}) {
|
||||
final labelColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDirectHeaderCounterpart(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
}) {
|
||||
return buildChannelHeaderPill(
|
||||
context,
|
||||
label: label,
|
||||
icon: Icons.alternate_email,
|
||||
);
|
||||
}
|
||||
382
lib/widgets/messages/message_bubble_signal.dart
Normal file
382
lib/widgets/messages/message_bubble_signal.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_reception_details.dart';
|
||||
|
||||
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Icons.schedule;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Icons.done;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Icons.done_all;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Icons.error_outline;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Icons.inbox;
|
||||
}
|
||||
}
|
||||
|
||||
Color getDeliveryStatusColor(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Colors.orange;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Colors.blue;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Colors.green;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Colors.red;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildChannelEchoStatus(BuildContext context, Message message) {
|
||||
final hasEcho = message.echoCount > 0;
|
||||
|
||||
if (!hasEcho) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final statusColor = getDeliveryStatusColor(message.deliveryStatus);
|
||||
final rssi = message.lastEchoRssiDbm;
|
||||
final snr = message.lastEchoSnrRaw != null
|
||||
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
|
||||
: null;
|
||||
final quality = linkQualityLabel(rssi, snr);
|
||||
final qualityColor = linkQualityColor(quality);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hub_outlined,
|
||||
label: 'x${message.echoCount}',
|
||||
color: statusColor,
|
||||
),
|
||||
if (message.expectedAckTag != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.tag,
|
||||
label:
|
||||
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(context, icon: Icons.bolt, label: quality, color: qualityColor),
|
||||
if (message.lastEchoRssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: message.lastEchoRssiDbm!.toString(),
|
||||
filled: rssiScore(message.lastEchoRssiDbm!),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (message.lastEchoSnrRaw != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1),
|
||||
filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool shouldShowSentChannelStats(
|
||||
Message message, {
|
||||
required bool showReceivedStats,
|
||||
}) {
|
||||
if (!message.isSentMessage || !message.isChannelMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasSignalData =
|
||||
message.echoCount > 0 ||
|
||||
message.lastEchoRssiDbm != null ||
|
||||
message.lastEchoSnrRaw != null ||
|
||||
message.expectedAckTag != null;
|
||||
return showReceivedStats && hasSignalData;
|
||||
}
|
||||
|
||||
Widget buildReceivedSignalStatus(
|
||||
BuildContext context,
|
||||
Message message, {
|
||||
MessageReceptionDetails? receptionDetails,
|
||||
required int? rssiDbm,
|
||||
required double? snrDb,
|
||||
}) {
|
||||
final hopLabel = hopDisplayLabel(message);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopLabel,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
if (receptionDetails?.senderToReceiptMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.schedule,
|
||||
label: _formatMs(receptionDetails!.senderToReceiptMs!),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
if (receptionDetails?.estimatedTransmitMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.timelapse,
|
||||
label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx',
|
||||
color: Colors.blue,
|
||||
),
|
||||
if (receptionDetails?.postTransmitDelayMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hourglass_bottom,
|
||||
label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag',
|
||||
color: Colors.orange,
|
||||
),
|
||||
if (receptionDetails?.pathBytesHex != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.route,
|
||||
label: receptionDetails!.pathBytesHex!,
|
||||
color: Colors.brown,
|
||||
),
|
||||
if (rssiDbm != null || snrDb != null) ...[
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.bolt,
|
||||
label: linkQualityLabel(rssiDbm, snrDb),
|
||||
color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)),
|
||||
),
|
||||
if (rssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: '$rssiDbm',
|
||||
filled: rssiScore(rssiDbm),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (snrDb != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: snrDb.toStringAsFixed(1),
|
||||
filled: snrScore(snrDb),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSentDirectSignalStatus(
|
||||
BuildContext context,
|
||||
Message message, {
|
||||
required int roundTripTimeMs,
|
||||
required Duration txEstimate,
|
||||
}) {
|
||||
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
|
||||
estimatedTransmitMs: txEstimate > Duration.zero
|
||||
? txEstimate.inMilliseconds
|
||||
: null,
|
||||
senderToReceiptMs: roundTripTimeMs,
|
||||
);
|
||||
final postTransmitDelayMs = estimatedTransmitMs != null
|
||||
? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
|
||||
: null;
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopDisplayLabel(message),
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.schedule,
|
||||
label: _formatMs(roundTripTimeMs),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
if (estimatedTransmitMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.timelapse,
|
||||
label: '~${_formatMs(estimatedTransmitMs)} tx',
|
||||
color: Colors.blue,
|
||||
),
|
||||
if (postTransmitDelayMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hourglass_bottom,
|
||||
label: '+${_formatMs(postTransmitDelayMs)} lag',
|
||||
color: Colors.orange,
|
||||
),
|
||||
if (message.retryAttempt > 0)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.refresh,
|
||||
label: 'retry ${message.retryAttempt}/3',
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
if (message.suggestedTimeoutMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.timer_outlined,
|
||||
label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}',
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (message.usedFloodFallback)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.waves,
|
||||
label: 'flood fallback',
|
||||
color: Colors.teal,
|
||||
)
|
||||
else if (message.expectedAckTag != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.route,
|
||||
label: 'direct ACK',
|
||||
color: Colors.indigo,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatMs(int value) {
|
||||
if (value >= 60000) {
|
||||
final minutes = value ~/ 60000;
|
||||
final seconds = (value % 60000) ~/ 1000;
|
||||
return '${minutes}m ${seconds}s';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s';
|
||||
}
|
||||
return '${value}ms';
|
||||
}
|
||||
|
||||
String hopDisplayLabel(Message message) {
|
||||
if (message.pathLen == 0) return 'Direct';
|
||||
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||
if (message.pathLen >= 255) return 'Unknown';
|
||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
Widget _techChip(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _signalCapsule(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required int filled,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(5, (i) {
|
||||
final active = i < filled;
|
||||
return Container(
|
||||
width: 3,
|
||||
height: (4 + i).toDouble(),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color : color.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
||||
|
||||
int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
|
||||
|
||||
String linkQualityLabel(int? rssiDbm, double? snrDb) {
|
||||
var score = 0;
|
||||
if (rssiDbm != null) score += rssiScore(rssiDbm);
|
||||
if (snrDb != null) score += snrScore(snrDb);
|
||||
if (score >= 8) return 'Excellent';
|
||||
if (score >= 6) return 'Good';
|
||||
if (score >= 4) return 'Fair';
|
||||
return 'Weak';
|
||||
}
|
||||
|
||||
Color linkQualityColor(String quality) {
|
||||
switch (quality) {
|
||||
case 'Excellent':
|
||||
return Colors.green;
|
||||
case 'Good':
|
||||
return Colors.lightGreen;
|
||||
case 'Fair':
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../services/mesh_map_nodes_service.dart';
|
||||
|
||||
class MessageTraceSheet extends StatefulWidget {
|
||||
@@ -30,9 +31,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
Future<_TraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final nodes = await MeshMapNodesService.fetchNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final packetPath = _extractPathFromPacketLogs(
|
||||
logs: connectionProvider.bleService.packetLogs,
|
||||
message: widget.message,
|
||||
@@ -43,45 +42,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
? _toPrefixHex(widget.message.recipientPublicKey)
|
||||
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
||||
|
||||
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
||||
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
||||
|
||||
if (packetPath != null && packetPath.isNotEmpty) {
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
pathHashes: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: packetPath,
|
||||
matchedPathNodes: matched,
|
||||
);
|
||||
final localNodes = _localNodesFromContacts(contactsProvider);
|
||||
var trace = _buildTraceResult(
|
||||
nodes: localNodes,
|
||||
packetPath: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
|
||||
return trace;
|
||||
}
|
||||
|
||||
// Fallback when packet path is unavailable.
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
relayCount: math.max(0, widget.message.pathLen),
|
||||
final remoteNodes = await MeshMapNodesService.fetchNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
final matchedPathNodes = <MeshMapNode?>[
|
||||
if (senderNode != null) senderNode,
|
||||
...inferred,
|
||||
if (recipientNode != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
packetPath: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return trace;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -109,12 +90,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
}
|
||||
|
||||
final trace = snapshot.data!;
|
||||
final mapPoints = trace.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
final routeEntries = _displayRouteEntries(trace);
|
||||
final concretePathNodes = routeEntries
|
||||
.where((entry) => entry.node != null)
|
||||
.map((entry) => entry.node!)
|
||||
.toList();
|
||||
final mapPoints = concretePathNodes
|
||||
.map((n) => LatLng(n.latitude, n.longitude))
|
||||
.toList();
|
||||
final hasMapPath = mapPoints.length >= 2;
|
||||
final relayNodes = _relayNodes(trace.matchedPathNodes);
|
||||
final relayNodes = _relayNodes(trace);
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
@@ -197,9 +182,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: trace.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
.toList()
|
||||
markers: concretePathNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
@@ -216,10 +199,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key ==
|
||||
trace.matchedPathNodes
|
||||
.whereType<
|
||||
MeshMapNode
|
||||
>()
|
||||
concretePathNodes
|
||||
.length -
|
||||
1
|
||||
? Colors.red
|
||||
@@ -250,6 +230,48 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Route',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (routeEntries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'No named nodes could be matched for this trace.',
|
||||
),
|
||||
),
|
||||
...routeEntries.asMap().entries.map(
|
||||
(entry) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key == routeEntries.length - 1
|
||||
? Colors.red
|
||||
: Colors.blue),
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(entry.value.label),
|
||||
subtitle: Text(
|
||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
@@ -288,12 +310,71 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
List<MeshMapNode> _relayNodes(List<MeshMapNode?> path) {
|
||||
final concrete = path.whereType<MeshMapNode>().toList();
|
||||
List<MeshMapNode> _relayNodes(_TraceResult trace) {
|
||||
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
||||
if (concrete.isEmpty) return const [];
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
if (concrete.length <= 1) return const [];
|
||||
return concrete.sublist(1);
|
||||
}
|
||||
|
||||
if (concrete.length <= 2) return const [];
|
||||
return concrete.sublist(1, concrete.length - 1);
|
||||
}
|
||||
|
||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
||||
if (pathNodes.isEmpty) {
|
||||
return [
|
||||
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
|
||||
if (trace.recipient != null &&
|
||||
trace.recipient!.publicKey != trace.sender?.publicKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||
final hashHex = trace.pathHashes[entry.key]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0');
|
||||
return _RouteDisplayEntry(
|
||||
node: entry.value,
|
||||
label: entry.value?.name ?? 'Unknown',
|
||||
keyLabel: entry.value != null
|
||||
? _prefixKeyLabel(entry.value!.publicKey)
|
||||
: hashHex,
|
||||
);
|
||||
}).toList();
|
||||
final lastKey = pathNodes.last.publicKey;
|
||||
return [
|
||||
...entries,
|
||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
final firstKey = pathNodes.first.publicKey;
|
||||
final lastKey = pathNodes.last.publicKey;
|
||||
return [
|
||||
if (trace.sender != null && trace.sender!.publicKey != firstKey)
|
||||
_RouteDisplayEntry.fromNode(trace.sender!),
|
||||
...pathNodes.map(_RouteDisplayEntry.fromNode),
|
||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
String _prefixKeyLabel(String publicKey) =>
|
||||
publicKey.substring(0, math.min(12, publicKey.length));
|
||||
|
||||
String _routeRoleLabel(int index, int total) {
|
||||
if (index == 0) return 'Sender';
|
||||
if (index == total - 1) return 'Recipient';
|
||||
return 'Relay';
|
||||
}
|
||||
|
||||
String? _toPrefixHex(List<int>? key) {
|
||||
if (key == null || key.isEmpty) return null;
|
||||
final take = key.length < 6 ? key.length : 6;
|
||||
@@ -312,6 +393,98 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
|
||||
return contactsProvider.contactsWithLocation
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return MeshMapNode(
|
||||
type: contact.type.index,
|
||||
name: contact.displayName,
|
||||
publicKey: contact.publicKeyHex.toLowerCase(),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
updatedAtMs: contact.lastAdvert * 1000,
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<MeshMapNode> _mergeNodes(
|
||||
List<MeshMapNode> preferred,
|
||||
List<MeshMapNode> fallback,
|
||||
) {
|
||||
final merged = <String, MeshMapNode>{};
|
||||
for (final node in fallback) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
for (final node in preferred) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
return merged.values.toList();
|
||||
}
|
||||
|
||||
_TraceResult _buildTraceResult({
|
||||
required List<MeshMapNode> nodes,
|
||||
required List<int>? packetPath,
|
||||
required String? senderPrefix,
|
||||
required String? recipientPrefix,
|
||||
}) {
|
||||
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
||||
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
||||
|
||||
if (packetPath != null && packetPath.isNotEmpty) {
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
pathHashes: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: packetPath,
|
||||
matchedPathNodes: matched,
|
||||
);
|
||||
}
|
||||
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
relayCount: math.max(0, widget.message.pathLen),
|
||||
);
|
||||
final matchedPathNodes = <MeshMapNode?>[
|
||||
if (senderNode != null) senderNode,
|
||||
...inferred,
|
||||
if (recipientNode != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
|
||||
if (trace.sender == null || trace.recipient == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
return trace.matchedPathNodes.length == trace.pathHashes.length &&
|
||||
trace.matchedPathNodes.every((node) => node != null);
|
||||
}
|
||||
|
||||
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
|
||||
return concreteCount >= expectedRelayCount + 2;
|
||||
}
|
||||
|
||||
List<int>? _extractPathFromPacketLogs({
|
||||
required List<BlePacketLog> logs,
|
||||
required Message message,
|
||||
@@ -371,11 +544,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
.where((n) => n.publicKey.startsWith(senderPrefix))
|
||||
.toList();
|
||||
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
||||
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
||||
final recipientMatches = filtered
|
||||
.where((n) => n.publicKey.startsWith(recipientPrefix))
|
||||
.toList();
|
||||
if (recipientMatches.isNotEmpty) filtered = recipientMatches;
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||
@@ -461,3 +629,23 @@ class _TraceResult {
|
||||
required this.matchedPathNodes,
|
||||
});
|
||||
}
|
||||
|
||||
class _RouteDisplayEntry {
|
||||
final MeshMapNode? node;
|
||||
final String label;
|
||||
final String? keyLabel;
|
||||
|
||||
const _RouteDisplayEntry({
|
||||
required this.node,
|
||||
required this.label,
|
||||
required this.keyLabel,
|
||||
});
|
||||
|
||||
factory _RouteDisplayEntry.fromNode(MeshMapNode node) {
|
||||
return _RouteDisplayEntry(
|
||||
node: node,
|
||||
label: node.name,
|
||||
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
432
lib/widgets/messages/messages_composer.dart
Normal file
432
lib/widgets/messages/messages_composer.dart
Normal file
@@ -0,0 +1,432 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
class MessagesComposer extends StatelessWidget {
|
||||
final TextEditingController textController;
|
||||
final FocusNode focusNode;
|
||||
final TextInputFormatter messageByteLimiter;
|
||||
final int messageByteCount;
|
||||
final int maxMessageBytes;
|
||||
final bool isRecording;
|
||||
final bool isSendingVoice;
|
||||
final bool voiceSupported;
|
||||
final double bottomPadding;
|
||||
final String destinationLabel;
|
||||
final Widget destinationAvatar;
|
||||
final VoidCallback onShowComposerActions;
|
||||
final VoidCallback onShowRecipientSelector;
|
||||
final Future<void> Function() onStartVoiceRecording;
|
||||
final Future<void> Function() onStopAndSendVoice;
|
||||
final Future<void> Function() onSendMessage;
|
||||
|
||||
const MessagesComposer({
|
||||
super.key,
|
||||
required this.textController,
|
||||
required this.focusNode,
|
||||
required this.messageByteLimiter,
|
||||
required this.messageByteCount,
|
||||
required this.maxMessageBytes,
|
||||
required this.isRecording,
|
||||
required this.isSendingVoice,
|
||||
required this.voiceSupported,
|
||||
required this.bottomPadding,
|
||||
required this.destinationLabel,
|
||||
required this.destinationAvatar,
|
||||
required this.onShowComposerActions,
|
||||
required this.onShowRecipientSelector,
|
||||
required this.onStartVoiceRecording,
|
||||
required this.onStopAndSendVoice,
|
||||
required this.onSendMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(color: Colors.transparent),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_ComposerActionButton(
|
||||
isRecording: isRecording,
|
||||
onPressed: isRecording
|
||||
? onStopAndSendVoice
|
||||
: onShowComposerActions,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DestinationSelector(
|
||||
destinationLabel: destinationLabel,
|
||||
destinationAvatar: destinationAvatar,
|
||||
onTap: onShowRecipientSelector,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
textController,
|
||||
focusNode,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final canSendText =
|
||||
!isRecording &&
|
||||
!isSendingVoice &&
|
||||
textController.text.trim().isNotEmpty;
|
||||
final semanticsLabel = isRecording
|
||||
? 'Recording... release to send voice'
|
||||
: (isSendingVoice
|
||||
? 'Sending voice...'
|
||||
: voiceSupported
|
||||
? 'Send (long press to record voice)'
|
||||
: 'Send');
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MessageInput(
|
||||
textController: textController,
|
||||
focusNode: focusNode,
|
||||
messageByteLimiter: messageByteLimiter,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SendButton(
|
||||
canSendText: canSendText,
|
||||
isRecording: isRecording,
|
||||
isSendingVoice: isSendingVoice,
|
||||
voiceSupported: voiceSupported,
|
||||
semanticsLabel: semanticsLabel,
|
||||
messageByteCount: messageByteCount,
|
||||
maxMessageBytes: maxMessageBytes,
|
||||
onSendMessage: onSendMessage,
|
||||
onStartVoiceRecording: onStartVoiceRecording,
|
||||
onStopAndSendVoice: onStopAndSendVoice,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComposerActionButton extends StatelessWidget {
|
||||
final bool isRecording;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _ComposerActionButton({
|
||||
required this.isRecording,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
|
||||
tooltip: isRecording ? 'Stop recording' : 'More actions',
|
||||
onPressed: onPressed,
|
||||
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DestinationSelector extends StatelessWidget {
|
||||
final String destinationLabel;
|
||||
final Widget destinationAvatar;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DestinationSelector({
|
||||
required this.destinationLabel,
|
||||
required this.destinationAvatar,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Ink(
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
destinationAvatar,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
destinationLabel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.expand_more_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageInput extends StatelessWidget {
|
||||
final TextEditingController textController;
|
||||
final FocusNode focusNode;
|
||||
final TextInputFormatter messageByteLimiter;
|
||||
|
||||
const _MessageInput({
|
||||
required this.textController,
|
||||
required this.focusNode,
|
||||
required this.messageByteLimiter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: focusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
width: focusNode.hasFocus ? 1.4 : 1,
|
||||
),
|
||||
boxShadow: focusNode.hasFocus
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.10),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: [messageByteLimiter],
|
||||
style: const TextStyle(fontSize: 15),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
|
||||
),
|
||||
filled: false,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
),
|
||||
textInputAction: TextInputAction.newline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SendButton extends StatelessWidget {
|
||||
final bool canSendText;
|
||||
final bool isRecording;
|
||||
final bool isSendingVoice;
|
||||
final bool voiceSupported;
|
||||
final String semanticsLabel;
|
||||
final int messageByteCount;
|
||||
final int maxMessageBytes;
|
||||
final Future<void> Function() onSendMessage;
|
||||
final Future<void> Function() onStartVoiceRecording;
|
||||
final Future<void> Function() onStopAndSendVoice;
|
||||
|
||||
const _SendButton({
|
||||
required this.canSendText,
|
||||
required this.isRecording,
|
||||
required this.isSendingVoice,
|
||||
required this.voiceSupported,
|
||||
required this.semanticsLabel,
|
||||
required this.messageByteCount,
|
||||
required this.maxMessageBytes,
|
||||
required this.onSendMessage,
|
||||
required this.onStartVoiceRecording,
|
||||
required this.onStopAndSendVoice,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: canSendText || (voiceSupported && !isSendingVoice),
|
||||
label: semanticsLabel,
|
||||
onTap: canSendText ? onSendMessage : null,
|
||||
onLongPress: (voiceSupported && !isSendingVoice)
|
||||
? () {
|
||||
if (isRecording) {
|
||||
onStopAndSendVoice();
|
||||
return;
|
||||
}
|
||||
onStartVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message: semanticsLabel,
|
||||
excludeFromSemantics: true,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: canSendText ? onSendMessage : null,
|
||||
onLongPressStart: (voiceSupported && !isSendingVoice)
|
||||
? (_) => onStartVoiceRecording()
|
||||
: null,
|
||||
onLongPressEnd: (voiceSupported && isRecording)
|
||||
? (_) => onStopAndSendVoice()
|
||||
: null,
|
||||
onLongPressCancel: (voiceSupported && isRecording)
|
||||
? onStopAndSendVoice
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: canSendText || isRecording
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: canSendText || isRecording
|
||||
? Colors.transparent
|
||||
: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: canSendText || isRecording
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.22),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: isSendingVoice
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
isRecording ? Icons.mic_rounded : Icons.send_rounded,
|
||||
size: 22,
|
||||
color: canSendText || isRecording
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$messageByteCount/$maxMessageBytes',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: messageByteCount > maxMessageBytes * 0.9
|
||||
? Colors.orange.shade800
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/widgets/messages/messages_content.dart
Normal file
94
lib/widgets/messages/messages_content.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../widgets/messages/message_bubble.dart';
|
||||
|
||||
class MessagesContent extends StatelessWidget {
|
||||
static const double defaultPadding = 8;
|
||||
|
||||
final List<Message> messages;
|
||||
final ScrollController scrollController;
|
||||
final String? highlightedMessageId;
|
||||
final double bottomContentPadding;
|
||||
final Future<void> Function() onRefresh;
|
||||
final VoidCallback? onNavigateToMap;
|
||||
final ValueChanged<Message>? onMessageTap;
|
||||
|
||||
const MessagesContent({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.scrollController,
|
||||
required this.highlightedMessageId,
|
||||
this.bottomContentPadding = 0,
|
||||
required this.onRefresh,
|
||||
this.onNavigateToMap,
|
||||
this.onMessageTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: messages.isEmpty
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noMessagesYet,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.pullDownToSync,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: scrollController,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
defaultPadding,
|
||||
defaultPadding,
|
||||
defaultPadding,
|
||||
defaultPadding + bottomContentPadding,
|
||||
),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
|
||||
return MessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
isHighlighted: message.id == highlightedMessageId,
|
||||
onNavigateToMap: onNavigateToMap,
|
||||
onTap: onMessageTap == null
|
||||
? null
|
||||
: () => onMessageTap!(message),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
|
||||
/// Bottom sheet for selecting message recipient (channel, contact, or room)
|
||||
class RecipientSelectorSheet extends StatefulWidget {
|
||||
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
...filteredChannels.map((channel) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.public,
|
||||
contact: channel,
|
||||
title: channel.getLocalizedDisplayName(context),
|
||||
subtitle: channel.isPublicChannel
|
||||
? l10n.broadcastToAllNearby
|
||||
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
...filteredContacts.map((contact) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.person,
|
||||
contact: contact,
|
||||
title: contact.displayName,
|
||||
subtitle: contact.publicKeyShort,
|
||||
emoji: contact.roleEmoji,
|
||||
isSelected: _isSelected('contact', contact),
|
||||
onTap: () {
|
||||
widget.onSelect('contact', contact);
|
||||
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
...filteredRooms.map((room) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.meeting_room,
|
||||
contact: room,
|
||||
title: room.displayName,
|
||||
subtitle: room.publicKeyShort,
|
||||
emoji: room.roleEmoji,
|
||||
isSelected: _isSelected('room', room),
|
||||
onTap: () {
|
||||
widget.onSelect('room', room);
|
||||
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
],
|
||||
|
||||
// Empty state
|
||||
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
|
||||
if (widget.contacts.isEmpty &&
|
||||
widget.rooms.isEmpty &&
|
||||
widget.channels.isEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
|
||||
Widget _buildRecipientTile({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required Contact contact,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
String? emoji,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
|
||||
title: Row(
|
||||
children: [
|
||||
if (emoji != null && emoji.isNotEmpty) ...[
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
|
||||
107
lib/widgets/messages/system_message_bubble.dart
Normal file
107
lib/widgets/messages/system_message_bubble.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
|
||||
class SystemMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
const SystemMessageBubble({super.key, required this.message});
|
||||
|
||||
Color _getLevelColor(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Colors.green;
|
||||
case 'warning':
|
||||
return Colors.orange;
|
||||
case 'error':
|
||||
return Colors.red;
|
||||
case 'info':
|
||||
default:
|
||||
return Colors.blue.shade300;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getLevelIcon(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Icons.check_circle_outline;
|
||||
case 'warning':
|
||||
return Icons.warning_amber_outlined;
|
||||
case 'error':
|
||||
return Icons.error_outline;
|
||||
case 'info':
|
||||
default:
|
||||
return Icons.info_outline;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final level = message.senderName ?? 'info';
|
||||
final levelColor = _getLevelColor(level);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(_getLevelIcon(level), size: 16, color: levelColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
level.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
height: 1.3,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../utils/transmission_target_resolver.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
@@ -27,7 +30,9 @@ class VoiceMessageBubble extends StatefulWidget {
|
||||
|
||||
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
static const int _maxFetchHops = 3;
|
||||
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
|
||||
bool _isRequesting = false;
|
||||
bool _isPartialRequest = false;
|
||||
bool _autoPlayWhenReady = false;
|
||||
String? _errorText;
|
||||
Timer? _requestTimeoutTimer;
|
||||
@@ -54,6 +59,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
|
||||
return Consumer<VoiceProvider>(
|
||||
builder: (context, voiceProvider, _) {
|
||||
final transferCount = context.select<MessagesProvider, int>(
|
||||
(provider) =>
|
||||
provider.transferCountForSession(voiceSessionId: voiceId),
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final session = voiceProvider.session(voiceId);
|
||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||
@@ -62,11 +71,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||
final isComplete = voiceProvider.isComplete(voiceId);
|
||||
@@ -76,6 +84,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
});
|
||||
@@ -92,9 +101,17 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? envelope?.total ?? 0;
|
||||
final playbackProgress = voiceProvider.playbackProgress(voiceId);
|
||||
final requestProgress = total > 0
|
||||
? (received / total).clamp(0.0, 1.0)
|
||||
: null;
|
||||
final packetPresence =
|
||||
session?.packets.map((packet) => packet != null).toList() ??
|
||||
List<bool>.filled(total, false);
|
||||
final isReceivingData =
|
||||
!_isRequesting &&
|
||||
!isComplete &&
|
||||
_hasRecentInboundActivity(
|
||||
lastReceivedAt: session?.lastPacketAt,
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
final durationSec =
|
||||
session?.estimatedDurationSeconds ??
|
||||
((envelope?.durationMs ?? 0) / 1000.0);
|
||||
@@ -116,32 +133,37 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
|
||||
|
||||
Future<void> handlePrimaryTap() async {
|
||||
if (isPlaying) {
|
||||
await voiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (_isRequesting) {
|
||||
_cancelReceive(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isComplete) {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isReceivingData) {
|
||||
return;
|
||||
}
|
||||
await _requestAndPlayVoice(
|
||||
voiceId,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: effectivePathLen,
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
if (isPlaying) {
|
||||
await voiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (_isRequesting) {
|
||||
_cancelReceive(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isComplete) {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
}
|
||||
await _requestAndPlayVoice(
|
||||
voiceId,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: effectivePathLen,
|
||||
);
|
||||
},
|
||||
onTap: handlePrimaryTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
width: 48,
|
||||
@@ -155,11 +177,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
child: Icon(
|
||||
isPlaying
|
||||
? Icons.stop
|
||||
: (_isRequesting ? Icons.close : Icons.play_arrow),
|
||||
: (_isRequesting
|
||||
? Icons.close
|
||||
: (isReceivingData
|
||||
? Icons.downloading_rounded
|
||||
: Icons.play_arrow)),
|
||||
size: 28,
|
||||
color: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer
|
||||
.withValues(alpha: isReceivingData ? 0.6 : 1.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -168,14 +195,22 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPlaying || _isRequesting)
|
||||
if (isPlaying)
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: LinearProgressIndicator(
|
||||
value: isPlaying ? playbackProgress : requestProgress,
|
||||
value: playbackProgress,
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||
),
|
||||
)
|
||||
else if ((_isRequesting || isReceivingData) && total > 0)
|
||||
_PacketBlockProgress(
|
||||
presence: packetPresence,
|
||||
activeColor: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
highlightMissing: _isPartialRequest,
|
||||
)
|
||||
else
|
||||
_WaveformBar(isComplete: isComplete, bars: waveformBars),
|
||||
const SizedBox(height: 4),
|
||||
@@ -188,11 +223,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
total: total,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
errorText: _errorText,
|
||||
requestingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.requestingVoice,
|
||||
eta: eta,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
transferCount: transferCount,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -218,23 +257,31 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final voiceProvider = context.read<VoiceProvider>();
|
||||
voiceProvider.resumeIncomingSession(sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
final appProvider = context.read<AppProvider>();
|
||||
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: connectionProvider.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
@@ -242,6 +289,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
@@ -249,27 +297,93 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unreachable) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route did not respond to a path check. Sync contacts/path and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final sender = resolution.target!;
|
||||
if (sender.outPathLen >= 2) {
|
||||
var sender = resolution.target!;
|
||||
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
await connectionProvider.getContacts();
|
||||
if (!mounted) return;
|
||||
resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: connectionProvider.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender contact is unknown. Sync contacts first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route is unknown. Sync contacts/path first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
sender = resolution.target!;
|
||||
routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route did not respond on the raw transport path.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
||||
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Device key is unavailable.',
|
||||
@@ -281,23 +395,38 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
final request = VoiceFetchRequest(
|
||||
final missing = voiceProvider.missingPacketIndices(sessionId);
|
||||
final totalPackets = sessionPacketCount(
|
||||
voiceProvider: voiceProvider,
|
||||
sessionId: sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 2,
|
||||
envelope: envelope,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_autoPlayWhenReady = true;
|
||||
_errorText = null;
|
||||
});
|
||||
final isPartialResume =
|
||||
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
|
||||
if (_isPartialRequest != isPartialResume && mounted) {
|
||||
setState(() {
|
||||
_isPartialRequest = isPartialResume;
|
||||
});
|
||||
}
|
||||
final request = isPartialResume
|
||||
? VoiceFetchRequest(
|
||||
sessionId: sessionId,
|
||||
want: 'missing',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
)
|
||||
: VoiceFetchRequest(
|
||||
sessionId: sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
);
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
|
||||
);
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -306,14 +435,21 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final estimatedDurationMs =
|
||||
envelope != null &&
|
||||
totalPackets > 0 &&
|
||||
missing.isNotEmpty &&
|
||||
missing.length < totalPackets
|
||||
? ((envelope.durationMs * missing.length) / totalPackets).round()
|
||||
: envelope?.durationMs;
|
||||
final txEstimate = envelope != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
packetCount: envelope.total,
|
||||
packetCount: isPartialResume ? missing.length : envelope.total,
|
||||
mode: envelope.mode,
|
||||
durationMs: envelope.durationMs,
|
||||
durationMs: estimatedDurationMs ?? envelope.durationMs,
|
||||
pathLen: effectivePathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
@@ -331,16 +467,34 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
int sessionPacketCount({
|
||||
required VoiceProvider voiceProvider,
|
||||
required String sessionId,
|
||||
required VoiceEnvelope? envelope,
|
||||
}) {
|
||||
return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0;
|
||||
}
|
||||
|
||||
void _setUnavailable() {
|
||||
if (!mounted) return;
|
||||
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = AppLocalizations.of(context)!.voiceUnavailable;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRequestState() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReceive(String sessionId) {
|
||||
if (!mounted) return;
|
||||
_requestTimeoutTimer?.cancel();
|
||||
@@ -348,6 +502,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
_showToast('Voice receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = 'Voice receive canceled';
|
||||
});
|
||||
@@ -392,19 +547,33 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
required int total,
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required String? errorText,
|
||||
required String requestingLabel,
|
||||
required Duration? eta,
|
||||
required bool isSentByMe,
|
||||
required int transferCount,
|
||||
}) {
|
||||
if (errorText != null) return errorText;
|
||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||
if (isRequesting) {
|
||||
return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
final actionLabel = isPartialRequest
|
||||
? 'Fetching missing voice fragments'
|
||||
: requestingLabel;
|
||||
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
return 'Receiving voice$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (!isComplete && total > 0) {
|
||||
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
}
|
||||
return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
}
|
||||
|
||||
List<double> _resolveWaveformBars({
|
||||
@@ -487,6 +656,86 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final seconds = eta.inSeconds % 60;
|
||||
return 'ETA ~${minutes}m ${seconds}s';
|
||||
}
|
||||
|
||||
static String _formatTransferCount(int transferCount) {
|
||||
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
bool _hasRecentInboundActivity({
|
||||
required DateTime? lastReceivedAt,
|
||||
required int received,
|
||||
required int total,
|
||||
}) {
|
||||
if (lastReceivedAt == null || received <= 0 || received >= total) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(lastReceivedAt) <=
|
||||
_recentInboundActivityWindow;
|
||||
}
|
||||
}
|
||||
|
||||
class _PacketBlockProgress extends StatelessWidget {
|
||||
final List<bool> presence;
|
||||
final Color activeColor;
|
||||
final bool highlightMissing;
|
||||
|
||||
const _PacketBlockProgress({
|
||||
required this.presence,
|
||||
required this.activeColor,
|
||||
this.highlightMissing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (presence.isEmpty) {
|
||||
return const SizedBox(width: 100, height: 16);
|
||||
}
|
||||
|
||||
final bucketCount = presence.length <= 20 ? presence.length : 20;
|
||||
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
|
||||
final start = (bucketIndex * presence.length) ~/ bucketCount;
|
||||
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
|
||||
final safeEnd = end <= start ? start + 1 : end;
|
||||
final slice = presence.sublist(start, safeEnd);
|
||||
final received = slice.where((value) => value).length;
|
||||
return slice.isEmpty ? 0.0 : received / slice.length;
|
||||
});
|
||||
final missingColor = highlightMissing
|
||||
? Colors.amberAccent
|
||||
: Colors.white.withValues(alpha: 0.14);
|
||||
|
||||
return SizedBox(
|
||||
width: 100,
|
||||
height: 16,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final fill in bucketFill)
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fill > 0
|
||||
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.45 : 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(
|
||||
color: fill > 0
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.7 : 0.18,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Voice waveform rendered as a row of bars.
|
||||
|
||||
Reference in New Issue
Block a user