mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-17 14:20:49 +10:00
upgraded flutter and other fixes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart' as crypto;
|
import 'package:crypto/crypto.dart' as crypto;
|
||||||
import 'package:pointycastle/export.dart';
|
import 'package:pointycastle/export.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
import '../models/channel_message.dart';
|
import '../models/channel_message.dart';
|
||||||
@@ -66,6 +64,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final Map<String, List<Message>> _conversations = {};
|
final Map<String, List<Message>> _conversations = {};
|
||||||
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
||||||
final Set<String> _loadedConversationKeys = {};
|
final Set<String> _loadedConversationKeys = {};
|
||||||
|
final Map<int, Set<String>> _processedChannelReactions = {}; // channelIndex -> Set of "reactionKey_emoji"
|
||||||
|
final Map<String, Set<String>> _processedContactReactions = {}; // contactPubKeyHex -> Set of "reactionKey_emoji"
|
||||||
|
|
||||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||||
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
|
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
|
||||||
@@ -101,6 +101,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
bool _queuedMessageSyncInFlight = false;
|
bool _queuedMessageSyncInFlight = false;
|
||||||
bool _didInitialQueueSync = false;
|
bool _didInitialQueueSync = false;
|
||||||
bool _pendingQueueSync = false;
|
bool _pendingQueueSync = false;
|
||||||
|
Timer? _queueSyncTimeout;
|
||||||
|
int _queueSyncRetries = 0;
|
||||||
|
static const int _maxQueueSyncRetries = 3;
|
||||||
|
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
MessageRetryService? _retryService;
|
MessageRetryService? _retryService;
|
||||||
@@ -316,6 +320,19 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int getTotalUnreadCount() {
|
||||||
|
var total = 0;
|
||||||
|
// Count unread contact messages
|
||||||
|
for (final contact in _contacts) {
|
||||||
|
total += getUnreadCountForContact(contact);
|
||||||
|
}
|
||||||
|
// Count unread channel messages
|
||||||
|
for (final channelIndex in _channelMessages.keys) {
|
||||||
|
total += getUnreadCountForChannelIndex(channelIndex);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
bool isChannelSmazEnabled(int channelIndex) {
|
bool isChannelSmazEnabled(int channelIndex) {
|
||||||
return _channelSmazEnabled[channelIndex] ?? false;
|
return _channelSmazEnabled[channelIndex] ?? false;
|
||||||
}
|
}
|
||||||
@@ -675,6 +692,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
_setState(MeshCoreConnectionState.connected);
|
_setState(MeshCoreConnectionState.connected);
|
||||||
|
|
||||||
|
// Enable wake lock to prevent BLE disconnection when screen turns off
|
||||||
|
await WakelockPlus.enable();
|
||||||
|
|
||||||
await _requestDeviceInfo();
|
await _requestDeviceInfo();
|
||||||
final gotSelfInfo = await _waitForSelfInfo(
|
final gotSelfInfo = await _waitForSelfInfo(
|
||||||
timeout: const Duration(seconds: 3),
|
timeout: const Duration(seconds: 3),
|
||||||
@@ -778,6 +798,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_setState(MeshCoreConnectionState.disconnecting);
|
_setState(MeshCoreConnectionState.disconnecting);
|
||||||
|
|
||||||
|
// Disable wake lock when disconnecting
|
||||||
|
await WakelockPlus.disable();
|
||||||
|
|
||||||
await _notifySubscription?.cancel();
|
await _notifySubscription?.cancel();
|
||||||
_notifySubscription = null;
|
_notifySubscription = null;
|
||||||
|
|
||||||
@@ -785,6 +808,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_connectionSubscription = null;
|
_connectionSubscription = null;
|
||||||
_selfInfoRetryTimer?.cancel();
|
_selfInfoRetryTimer?.cancel();
|
||||||
_selfInfoRetryTimer = null;
|
_selfInfoRetryTimer = null;
|
||||||
|
_queueSyncTimeout?.cancel();
|
||||||
|
_queueSyncTimeout = null;
|
||||||
|
_queueSyncRetries = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
|
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
|
||||||
@@ -912,16 +938,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> getContactByKey(Uint8List pubKey) async {
|
||||||
|
if (!isConnected) return;
|
||||||
|
await sendFrame(buildGetContactByKeyFrame(pubKey));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(
|
Future<void> sendMessage(
|
||||||
Contact contact,
|
Contact contact,
|
||||||
String text, {
|
String text,
|
||||||
bool clearPath = false,
|
) async {
|
||||||
}) async {
|
|
||||||
if (!isConnected || text.isEmpty) return;
|
if (!isConnected || text.isEmpty) return;
|
||||||
|
|
||||||
// Handle auto-rotation if enabled
|
// Handle auto-rotation if enabled
|
||||||
PathSelection? autoSelection;
|
PathSelection? autoSelection;
|
||||||
if (_appSettingsService?.settings.autoRouteRotationEnabled == true && !clearPath) {
|
if (_appSettingsService?.settings.autoRouteRotationEnabled == true) {
|
||||||
autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex);
|
autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex);
|
||||||
if (autoSelection != null) {
|
if (autoSelection != null) {
|
||||||
_pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection);
|
_pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection);
|
||||||
@@ -936,21 +966,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_retryService != null) {
|
if (_retryService != null) {
|
||||||
final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection);
|
final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
|
||||||
final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection);
|
final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
|
||||||
final selectedContact = _applyAutoSelection(contact, autoSelection);
|
final selectedContact = _applyAutoSelection(contact, autoSelection);
|
||||||
await _retryService!.sendMessageWithRetry(
|
await _retryService!.sendMessageWithRetry(
|
||||||
contact: selectedContact,
|
contact: selectedContact,
|
||||||
text: text,
|
text: text,
|
||||||
clearPath: clearPath,
|
|
||||||
pathSelection: autoSelection,
|
pathSelection: autoSelection,
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
pathLength: pathLength,
|
pathLength: pathLength,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to old behavior if retry service not initialized
|
// Fallback to old behavior if retry service not initialized
|
||||||
final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection);
|
final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
|
||||||
final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection);
|
final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
|
||||||
final message = Message.outgoing(
|
final message = Message.outgoing(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
text,
|
text,
|
||||||
@@ -985,9 +1014,72 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set path override for a contact (persists across contact refreshes)
|
||||||
|
/// pathLen: -1 = force flood, null = auto (use device path), >= 0 = specific path
|
||||||
|
Future<void> setPathOverride(
|
||||||
|
Contact contact, {
|
||||||
|
int? pathLen,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
}) async {
|
||||||
|
// Find contact in list
|
||||||
|
final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
||||||
|
if (index == -1) return;
|
||||||
|
|
||||||
|
// Update contact with new path override
|
||||||
|
_contacts[index] = _contacts[index].copyWith(
|
||||||
|
pathOverride: pathLen,
|
||||||
|
pathOverrideBytes: pathBytes,
|
||||||
|
clearPathOverride: pathLen == null, // Clear if pathLen is null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
await _contactStore.saveContacts(_contacts);
|
||||||
|
|
||||||
|
// If setting a specific path (not flood, not auto), also sync with device
|
||||||
|
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
|
||||||
|
await setContactPath(contact, pathBytes, pathLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendChannelMessage(Channel channel, String text) async{
|
Future<void> sendChannelMessage(Channel channel, String text) async{
|
||||||
if (!isConnected || text.isEmpty) return;
|
if (!isConnected || text.isEmpty) return;
|
||||||
|
|
||||||
|
// Check if this is a reaction - if so, process it immediately instead of adding as a message
|
||||||
|
final reactionInfo = ReactionHelper.parseReaction(text);
|
||||||
|
if (reactionInfo != null) {
|
||||||
|
// Check if we've already processed this reaction
|
||||||
|
_processedChannelReactions.putIfAbsent(channel.index, () => {});
|
||||||
|
final reactionKey = reactionInfo.reactionKey;
|
||||||
|
final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
|
||||||
|
|
||||||
|
if (reactionIdentifier != null && _processedChannelReactions[channel.index]!.contains(reactionIdentifier)) {
|
||||||
|
// Already processed, don't process again
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the in-memory messages list (same as _addChannelMessage uses)
|
||||||
|
_channelMessages.putIfAbsent(channel.index, () => []);
|
||||||
|
final messages = _channelMessages[channel.index]!;
|
||||||
|
|
||||||
|
// Process reaction locally to update the UI immediately
|
||||||
|
_processReaction(messages, reactionInfo);
|
||||||
|
await _channelMessageStore.saveChannelMessages(channel.index, messages);
|
||||||
|
|
||||||
|
// Mark this reaction as processed
|
||||||
|
if (reactionIdentifier != null) {
|
||||||
|
_processedChannelReactions[channel.index]!.add(reactionIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Send the reaction to the device (don't add as a visible message)
|
||||||
|
await sendFrame(buildSendChannelTextMsgFrame(channel.index, text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index);
|
final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index);
|
||||||
_addChannelMessage(channel.index, message);
|
_addChannelMessage(channel.index, message);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1025,16 +1117,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
_contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
final existing = _contacts[existingIndex];
|
final existing = _contacts[existingIndex];
|
||||||
_contacts[existingIndex] = Contact(
|
// Use copyWith to preserve pathOverride and pathOverrideBytes
|
||||||
publicKey: existing.publicKey,
|
_contacts[existingIndex] = existing.copyWith(
|
||||||
name: existing.name,
|
|
||||||
type: existing.type,
|
|
||||||
pathLength: -1,
|
pathLength: -1,
|
||||||
path: Uint8List(0),
|
path: Uint8List(0),
|
||||||
latitude: existing.latitude,
|
|
||||||
longitude: existing.longitude,
|
|
||||||
lastSeen: existing.lastSeen,
|
|
||||||
lastMessageAt: existing.lastMessageAt,
|
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
unawaited(_persistContacts());
|
unawaited(_persistContacts());
|
||||||
@@ -1082,15 +1168,47 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
_isSyncingQueuedMessages = false;
|
_isSyncingQueuedMessages = false;
|
||||||
_queuedMessageSyncInFlight = false;
|
_queuedMessageSyncInFlight = false;
|
||||||
|
_queueSyncRetries = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_queuedMessageSyncInFlight) return;
|
if (_queuedMessageSyncInFlight) return;
|
||||||
_queuedMessageSyncInFlight = true;
|
_queuedMessageSyncInFlight = true;
|
||||||
|
|
||||||
|
// Cancel any existing timeout
|
||||||
|
_queueSyncTimeout?.cancel();
|
||||||
|
|
||||||
|
// Set up timeout for this request
|
||||||
|
_queueSyncTimeout = Timer(Duration(milliseconds: _queueSyncTimeoutMs), () {
|
||||||
|
_handleQueueSyncTimeout();
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendFrame(buildSyncNextMessageFrame());
|
await sendFrame(buildSyncNextMessageFrame());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('[QueueSync] Error sending sync request: $e');
|
||||||
_queuedMessageSyncInFlight = false;
|
_queuedMessageSyncInFlight = false;
|
||||||
_isSyncingQueuedMessages = false;
|
_isSyncingQueuedMessages = false;
|
||||||
|
_queueSyncTimeout?.cancel();
|
||||||
|
_queueSyncRetries = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleQueueSyncTimeout() {
|
||||||
|
debugPrint('[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
|
||||||
|
|
||||||
|
if (_queueSyncRetries < _maxQueueSyncRetries) {
|
||||||
|
// Retry
|
||||||
|
_queueSyncRetries++;
|
||||||
|
_queuedMessageSyncInFlight = false;
|
||||||
|
_requestNextQueuedMessage();
|
||||||
|
} else {
|
||||||
|
// Max retries reached, give up
|
||||||
|
debugPrint('[QueueSync] Max retries reached, stopping sync');
|
||||||
|
_queuedMessageSyncInFlight = false;
|
||||||
|
_isSyncingQueuedMessages = false;
|
||||||
|
_queueSyncRetries = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,22 +1246,51 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
|
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Set<int> _expectedChannelIndices = {};
|
||||||
|
|
||||||
Future<void> getChannels({int? maxChannels}) async {
|
Future<void> getChannels({int? maxChannels}) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
_isLoadingChannels = true;
|
_isLoadingChannels = true;
|
||||||
|
final previousChannels = List<Channel>.from(_channels);
|
||||||
_channels.clear();
|
_channels.clear();
|
||||||
|
_expectedChannelIndices.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Request each channel index
|
// Request each channel index (send all requests in parallel)
|
||||||
final channelCount = maxChannels ?? _maxChannels;
|
final channelCount = maxChannels ?? _maxChannels;
|
||||||
for (int i = 0; i < channelCount; i++) {
|
for (int i = 0; i < channelCount; i++) {
|
||||||
await sendFrame(buildGetChannelFrame(i));
|
_expectedChannelIndices.add(i);
|
||||||
|
sendFrame(buildGetChannelFrame(i)); // No await - send all at once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait a bit for responses to arrive, then apply final sort
|
// Wait for responses with timeout
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
final stopwatch = Stopwatch()..start();
|
||||||
|
const maxWaitTime = Duration(seconds: 5);
|
||||||
|
const checkInterval = Duration(milliseconds: 100);
|
||||||
|
|
||||||
|
while (_expectedChannelIndices.isNotEmpty && stopwatch.elapsed < maxWaitTime) {
|
||||||
|
await Future.delayed(checkInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.stop();
|
||||||
|
|
||||||
|
// If timeout expired and we're still missing channels, restore them from previous load
|
||||||
|
if (_expectedChannelIndices.isNotEmpty) {
|
||||||
|
debugPrint('Channel loading timeout - missing ${_expectedChannelIndices.length} channels, restoring from cache');
|
||||||
|
for (final prevChannel in previousChannels) {
|
||||||
|
if (_expectedChannelIndices.contains(prevChannel.index) &&
|
||||||
|
!_channels.any((c) => c.index == prevChannel.index)) {
|
||||||
|
_channels.add(prevChannel);
|
||||||
|
debugPrint('Restored channel ${prevChannel.index} (${prevChannel.name}) from cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Channel loading completed: received ${_channels.length}/$channelCount channels in ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
_isLoadingChannels = false;
|
_isLoadingChannels = false;
|
||||||
|
_expectedChannelIndices.clear();
|
||||||
_applyChannelOrder();
|
_applyChannelOrder();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -1266,7 +1413,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
_pathHistoryService!.handlePathUpdated(contact);
|
_pathHistoryService!.handlePathUpdated(contact);
|
||||||
refreshContactsSinceLastmod();
|
// Refresh just this specific contact instead of all contacts.
|
||||||
|
// This avoids race conditions with _preserveContactsOnRefresh flag
|
||||||
|
// that can occur when using refreshContactsSinceLastmod().
|
||||||
|
getContactByKey(pubKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1341,13 +1491,19 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleNoMoreMessages() {
|
void _handleNoMoreMessages() {
|
||||||
|
debugPrint('[QueueSync] No more messages, sync complete');
|
||||||
|
_queueSyncTimeout?.cancel();
|
||||||
_isSyncingQueuedMessages = false;
|
_isSyncingQueuedMessages = false;
|
||||||
_queuedMessageSyncInFlight = false;
|
_queuedMessageSyncInFlight = false;
|
||||||
|
_queueSyncRetries = 0; // Reset retry counter on successful completion
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleQueuedMessageReceived() {
|
void _handleQueuedMessageReceived() {
|
||||||
if (!_isSyncingQueuedMessages) return;
|
if (!_isSyncingQueuedMessages) return;
|
||||||
|
debugPrint('[QueueSync] Message received, requesting next');
|
||||||
|
_queueSyncTimeout?.cancel(); // Cancel timeout - message arrived
|
||||||
_queuedMessageSyncInFlight = false;
|
_queuedMessageSyncInFlight = false;
|
||||||
|
_queueSyncRetries = 0; // Reset retry counter on successful message
|
||||||
unawaited(_requestNextQueuedMessage());
|
unawaited(_requestNextQueuedMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,8 +1588,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
|
final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
|
||||||
? existing.lastMessageAt
|
? existing.lastMessageAt
|
||||||
: contact.lastMessageAt;
|
: contact.lastMessageAt;
|
||||||
|
// CRITICAL: Preserve user's path override when contact is refreshed from device
|
||||||
_contacts[existingIndex] = contact.copyWith(
|
_contacts[existingIndex] = contact.copyWith(
|
||||||
lastMessageAt: mergedLastMessageAt,
|
lastMessageAt: mergedLastMessageAt,
|
||||||
|
pathOverride: existing.pathOverride, // Preserve user's path choice
|
||||||
|
pathOverrideBytes: existing.pathOverrideBytes,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_contacts.add(contact);
|
_contacts.add(contact);
|
||||||
@@ -1559,10 +1718,28 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleIncomingMessage(Uint8List frame) {
|
void _handleIncomingMessage(Uint8List frame) async {
|
||||||
if (_selfPublicKey == null) return;
|
if (_selfPublicKey == null) return;
|
||||||
|
|
||||||
var message = _parseContactMessage(frame);
|
var message = _parseContactMessage(frame);
|
||||||
|
|
||||||
|
// If message parsing failed due to unknown contact, refresh contacts and retry
|
||||||
|
if (message == null && !_isLoadingContacts) {
|
||||||
|
final senderPrefix = _extractSenderPrefix(frame);
|
||||||
|
if (senderPrefix != null) {
|
||||||
|
final hasContact = _contacts.any((c) => _matchesPrefix(c.publicKey, senderPrefix));
|
||||||
|
if (!hasContact) {
|
||||||
|
debugPrint('Received message from unknown contact, refreshing contacts...');
|
||||||
|
await refreshContactsSinceLastmod();
|
||||||
|
// Retry parsing after refresh
|
||||||
|
message = _parseContactMessage(frame);
|
||||||
|
if (message != null) {
|
||||||
|
debugPrint('Successfully parsed message after contact refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
final contact = _contacts.cast<Contact?>().firstWhere(
|
final contact = _contacts.cast<Contact?>().firstWhere(
|
||||||
(c) => c?.publicKeyHex == message!.senderKeyHex,
|
(c) => c?.publicKeyHex == message!.senderKeyHex,
|
||||||
@@ -1605,6 +1782,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
contactName: contact?.name ?? 'Unknown',
|
contactName: contact?.name ?? 'Unknown',
|
||||||
message: message.text,
|
message: message.text,
|
||||||
contactId: message.senderKeyHex,
|
contactId: message.senderKeyHex,
|
||||||
|
badgeCount: getTotalUnreadCount(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1680,6 +1858,21 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List? _extractSenderPrefix(Uint8List frame) {
|
||||||
|
if (frame.isEmpty) return null;
|
||||||
|
final code = frame[0];
|
||||||
|
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefixOffset = code == respCodeContactMsgRecvV3 ? 4 : 1;
|
||||||
|
const prefixLen = 6;
|
||||||
|
|
||||||
|
if (frame.length < prefixOffset + prefixLen) return null;
|
||||||
|
|
||||||
|
return frame.sublist(prefixOffset, prefixOffset + prefixLen);
|
||||||
|
}
|
||||||
|
|
||||||
void _ensureContactSmazSettingLoaded(String contactKeyHex) {
|
void _ensureContactSmazSettingLoaded(String contactKeyHex) {
|
||||||
if (_contactSmazEnabled.containsKey(contactKeyHex)) return;
|
if (_contactSmazEnabled.containsKey(contactKeyHex)) return;
|
||||||
_contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) {
|
_contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) {
|
||||||
@@ -1729,6 +1922,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
channelName: label,
|
channelName: label,
|
||||||
message: message.text,
|
message: message.text,
|
||||||
channelIndex: channelIndex,
|
channelIndex: channelIndex,
|
||||||
|
badgeCount: getTotalUnreadCount(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1884,14 +2078,21 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
void _handleChannelInfo(Uint8List frame) {
|
void _handleChannelInfo(Uint8List frame) {
|
||||||
final channel = Channel.fromFrame(frame);
|
final channel = Channel.fromFrame(frame);
|
||||||
if (channel != null && !channel.isEmpty) {
|
if (channel != null) {
|
||||||
_channels.add(channel);
|
// Mark this channel index as received
|
||||||
|
_expectedChannelIndices.remove(channel.index);
|
||||||
|
|
||||||
|
// Only add non-empty channels to the list
|
||||||
|
if (!channel.isEmpty) {
|
||||||
|
_channels.add(channel);
|
||||||
|
}
|
||||||
|
|
||||||
// Only sort and notify if we're not currently loading channels
|
// Only sort and notify if we're not currently loading channels
|
||||||
// This prevents the list from jumping around as channels arrive during refresh
|
// This prevents the list from jumping around as channels arrive during refresh
|
||||||
if (!_isLoadingChannels) {
|
if (!_isLoadingChannels) {
|
||||||
_applyChannelOrder();
|
_applyChannelOrder();
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1999,17 +2200,24 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
// Parse reaction info
|
// Parse reaction info
|
||||||
final reactionInfo = Message.parseReaction(message.text);
|
final reactionInfo = Message.parseReaction(message.text);
|
||||||
if (reactionInfo != null) {
|
if (reactionInfo != null) {
|
||||||
// Check if we've already processed this exact reaction
|
// Check if we've already processed this exact reaction using lightweight key
|
||||||
final isDuplicate = messages.any((m) =>
|
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
|
||||||
m.text == message.text &&
|
final reactionKey = reactionInfo.reactionKey;
|
||||||
m.senderKey == message.senderKey &&
|
final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
|
||||||
m.timestamp.millisecondsSinceEpoch == message.timestamp.millisecondsSinceEpoch
|
|
||||||
);
|
final isDuplicate = reactionIdentifier != null &&
|
||||||
|
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
|
||||||
|
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
// New reaction - process it
|
// New reaction - process it
|
||||||
_processContactReaction(messages, reactionInfo);
|
_processContactReaction(messages, reactionInfo);
|
||||||
_messageStore.saveMessages(pubKeyHex, messages);
|
_messageStore.saveMessages(pubKeyHex, messages);
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
if (reactionIdentifier != null) {
|
||||||
|
_processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
return; // Don't add reaction as a visible message
|
return; // Don't add reaction as a visible message
|
||||||
@@ -2115,29 +2323,50 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
Uint8List _resolveOutgoingPathBytes(
|
Uint8List _resolveOutgoingPathBytes(
|
||||||
Contact contact,
|
Contact contact,
|
||||||
bool clearPath,
|
|
||||||
PathSelection? selection,
|
PathSelection? selection,
|
||||||
) {
|
) {
|
||||||
if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) {
|
// Priority 1: Check user's path override
|
||||||
|
if (contact.pathOverride != null) {
|
||||||
|
if (contact.pathOverride! < 0) {
|
||||||
|
return Uint8List(0); // Force flood
|
||||||
|
}
|
||||||
|
return contact.pathOverrideBytes ?? Uint8List(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Check device flood mode or PathSelection flood
|
||||||
|
if (contact.pathLength < 0 || selection?.useFlood == true) {
|
||||||
return Uint8List(0);
|
return Uint8List(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 3: Check PathSelection (auto-rotation)
|
||||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||||
return Uint8List.fromList(selection.pathBytes);
|
return Uint8List.fromList(selection.pathBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 4: Use device's discovered path
|
||||||
return contact.path;
|
return contact.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _resolveOutgoingPathLength(
|
int? _resolveOutgoingPathLength(
|
||||||
Contact contact,
|
Contact contact,
|
||||||
bool clearPath,
|
|
||||||
PathSelection? selection,
|
PathSelection? selection,
|
||||||
) {
|
) {
|
||||||
if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) {
|
// Priority 1: Check user's path override
|
||||||
|
if (contact.pathOverride != null) {
|
||||||
|
return contact.pathOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Check device flood mode or PathSelection flood
|
||||||
|
if (contact.pathLength < 0 || selection?.useFlood == true) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 3: Check PathSelection (auto-rotation)
|
||||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||||
return selection.hopCount;
|
return selection.hopCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 4: Use device's discovered path
|
||||||
return contact.pathLength;
|
return contact.pathLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2148,19 +2377,24 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
// Parse reaction info
|
// Parse reaction info
|
||||||
final reactionInfo = ChannelMessage.parseReaction(message.text);
|
final reactionInfo = ChannelMessage.parseReaction(message.text);
|
||||||
if (reactionInfo != null) {
|
if (reactionInfo != null) {
|
||||||
// Check if we've already processed this exact reaction by looking for duplicate in messages
|
// Check if we've already processed this exact reaction using lightweight key
|
||||||
// Reaction messages are kept in the list but won't be displayed (filtered in UI or here)
|
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
|
||||||
final isDuplicate = messages.any((m) =>
|
final reactionKey = reactionInfo.reactionKey;
|
||||||
m.text == message.text &&
|
final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
|
||||||
m.senderName == message.senderName &&
|
|
||||||
m.timestamp.millisecondsSinceEpoch == message.timestamp.millisecondsSinceEpoch
|
final isDuplicate = reactionIdentifier != null &&
|
||||||
);
|
_processedChannelReactions[channelIndex]!.contains(reactionIdentifier);
|
||||||
|
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
// New reaction - process it
|
// New reaction - process it
|
||||||
_processReaction(messages, reactionInfo);
|
_processReaction(messages, reactionInfo);
|
||||||
// Save updated messages
|
// Save updated messages
|
||||||
_channelMessageStore.saveChannelMessages(channelIndex, messages);
|
_channelMessageStore.saveChannelMessages(channelIndex, messages);
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
if (reactionIdentifier != null) {
|
||||||
|
_processedChannelReactions[channelIndex]!.add(reactionIdentifier);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false; // Don't add reaction as a visible message
|
return false; // Don't add reaction as a visible message
|
||||||
}
|
}
|
||||||
@@ -2208,11 +2442,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
processedMessage.pathLength,
|
processedMessage.pathLength,
|
||||||
mergedPathBytes.length,
|
mergedPathBytes.length,
|
||||||
);
|
);
|
||||||
|
final newRepeatCount = existing.repeatCount + 1;
|
||||||
messages[existingIndex] = existing.copyWith(
|
messages[existingIndex] = existing.copyWith(
|
||||||
repeatCount: existing.repeatCount + 1,
|
repeatCount: newRepeatCount,
|
||||||
pathLength: mergedPathLength,
|
pathLength: mergedPathLength,
|
||||||
pathBytes: mergedPathBytes,
|
pathBytes: mergedPathBytes,
|
||||||
pathVariants: mergedPathVariants,
|
pathVariants: mergedPathVariants,
|
||||||
|
// Mark as sent when first repeat is heard
|
||||||
|
status: newRepeatCount == 1 && existing.status == ChannelMessageStatus.pending
|
||||||
|
? ChannelMessageStatus.sent
|
||||||
|
: existing.status,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
messages.add(processedMessage);
|
messages.add(processedMessage);
|
||||||
@@ -2357,6 +2596,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleDisconnection() {
|
void _handleDisconnection() {
|
||||||
|
// Disable wake lock when connection is lost
|
||||||
|
WakelockPlus.disable();
|
||||||
|
|
||||||
_notifySubscription?.cancel();
|
_notifySubscription?.cancel();
|
||||||
_notifySubscription = null;
|
_notifySubscription = null;
|
||||||
_connectionSubscription?.cancel();
|
_connectionSubscription?.cancel();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const int cmdGetBattAndStorage = 20;
|
|||||||
const int cmdDeviceQuery = 22;
|
const int cmdDeviceQuery = 22;
|
||||||
const int cmdSendLogin = 26;
|
const int cmdSendLogin = 26;
|
||||||
const int cmdSendStatusReq = 27;
|
const int cmdSendStatusReq = 27;
|
||||||
|
const int cmdGetContactByKey = 30;
|
||||||
const int cmdGetChannel = 31;
|
const int cmdGetChannel = 31;
|
||||||
const int cmdSetChannel = 32;
|
const int cmdSetChannel = 32;
|
||||||
const int cmdGetRadioSettings = 57;
|
const int cmdGetRadioSettings = 57;
|
||||||
@@ -507,6 +508,15 @@ Uint8List buildUpdateContactPathFrame(
|
|||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build CMD_GET_CONTACT_BY_KEY frame
|
||||||
|
// Format: [cmd][pub_key x32]
|
||||||
|
Uint8List buildGetContactByKeyFrame(Uint8List pubKey) {
|
||||||
|
final frame = Uint8List(1 + pubKeySize);
|
||||||
|
frame[0] = cmdGetContactByKey;
|
||||||
|
frame.setRange(1, 1 + pubKeySize, pubKey);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
// Build CMD_GET_RADIO_SETTINGS frame
|
// Build CMD_GET_RADIO_SETTINGS frame
|
||||||
Uint8List buildGetRadioSettingsFrame() {
|
Uint8List buildGetRadioSettingsFrame() {
|
||||||
return Uint8List.fromList([cmdGetRadioSettings]);
|
return Uint8List.fromList([cmdGetRadioSettings]);
|
||||||
|
|||||||
@@ -1,22 +1,53 @@
|
|||||||
class ReactionInfo {
|
class ReactionInfo {
|
||||||
final String targetMessageId;
|
final String targetMessageId;
|
||||||
final String emoji;
|
final String emoji;
|
||||||
|
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
|
||||||
|
|
||||||
ReactionInfo({
|
ReactionInfo({
|
||||||
required this.targetMessageId,
|
required this.targetMessageId,
|
||||||
required this.emoji,
|
required this.emoji,
|
||||||
|
this.reactionKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReactionHelper {
|
class ReactionHelper {
|
||||||
/// Parse reaction format: r:[messageId]:[emoji]
|
/// Parse reaction format: r:[messageId]:[emoji]
|
||||||
|
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
|
||||||
static ReactionInfo? parseReaction(String text) {
|
static ReactionInfo? parseReaction(String text) {
|
||||||
final regex = RegExp(r'^r:([^:]+):(.+)$');
|
final regex = RegExp(r'^r:([^:]+):(.+)$');
|
||||||
final match = regex.firstMatch(text);
|
final match = regex.firstMatch(text);
|
||||||
if (match == null) return null;
|
if (match == null) return null;
|
||||||
|
|
||||||
|
final targetId = match.group(1)!;
|
||||||
|
final emoji = match.group(2)!;
|
||||||
|
|
||||||
|
// Extract reaction key for deduplication
|
||||||
|
// If targetId is in new format (timestamp_senderPrefix), use it directly
|
||||||
|
// Otherwise, extract timestamp from old format (timestamp_nameHash_textHash)
|
||||||
|
String? reactionKey;
|
||||||
|
if (targetId.contains('_')) {
|
||||||
|
final parts = targetId.split('_');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// New format: timestamp_senderPrefix, or old format with at least timestamp
|
||||||
|
reactionKey = '${parts[0]}_${parts[1]}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ReactionInfo(
|
return ReactionInfo(
|
||||||
targetMessageId: match.group(1)!,
|
targetMessageId: targetId,
|
||||||
emoji: match.group(2)!,
|
emoji: emoji,
|
||||||
|
reactionKey: reactionKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a lightweight reaction key for a message
|
||||||
|
/// Format: r:[timestamp]_[senderPrefix]:[emoji]
|
||||||
|
static String buildReactionText(String timestamp, String senderPrefix, String emoji) {
|
||||||
|
return 'r:${timestamp}_$senderPrefix:$emoji';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract sender prefix from public key hex (first 8 chars)
|
||||||
|
static String getSenderPrefix(String senderKeyHex) {
|
||||||
|
return senderKeyHex.substring(0, 8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -5,8 +5,10 @@ class Contact {
|
|||||||
final Uint8List publicKey;
|
final Uint8List publicKey;
|
||||||
final String name;
|
final String name;
|
||||||
final int type;
|
final int type;
|
||||||
final int pathLength; // -1 = flood, 0+ = direct hops
|
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
|
||||||
final Uint8List path;
|
final Uint8List path; // Path bytes from device
|
||||||
|
final int? pathOverride; // User's path override: -1 = force flood, null = auto
|
||||||
|
final Uint8List? pathOverrideBytes; // User's path override bytes
|
||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
final double? longitude;
|
||||||
final DateTime lastSeen;
|
final DateTime lastSeen;
|
||||||
@@ -18,6 +20,8 @@ class Contact {
|
|||||||
required this.type,
|
required this.type,
|
||||||
required this.pathLength,
|
required this.pathLength,
|
||||||
required this.path,
|
required this.path,
|
||||||
|
this.pathOverride,
|
||||||
|
this.pathOverrideBytes,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
required this.lastSeen,
|
required this.lastSeen,
|
||||||
@@ -55,6 +59,9 @@ class Contact {
|
|||||||
int? type,
|
int? type,
|
||||||
int? pathLength,
|
int? pathLength,
|
||||||
Uint8List? path,
|
Uint8List? path,
|
||||||
|
int? pathOverride,
|
||||||
|
Uint8List? pathOverrideBytes,
|
||||||
|
bool clearPathOverride = false,
|
||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
DateTime? lastSeen,
|
DateTime? lastSeen,
|
||||||
@@ -66,6 +73,8 @@ class Contact {
|
|||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
pathLength: pathLength ?? this.pathLength,
|
pathLength: pathLength ?? this.pathLength,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
|
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride),
|
||||||
|
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes),
|
||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
lastSeen: lastSeen ?? this.lastSeen,
|
lastSeen: lastSeen ?? this.lastSeen,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -42,6 +41,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
|
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
|
||||||
|
|
||||||
|
// Scroll to bottom when opening channel chat
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,12 +356,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
|
|
||||||
Widget contentPreview;
|
Widget contentPreview;
|
||||||
if (gifId != null) {
|
if (gifId != null) {
|
||||||
contentPreview = Row(
|
contentPreview = ClipRRect(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(4),
|
||||||
Icon(Icons.gif_box, size: 14, color: previewTextColor),
|
child: GifMessage(
|
||||||
const SizedBox(width: 4),
|
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||||
Text('GIF', style: TextStyle(fontSize: 12, color: previewTextColor)),
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
],
|
fallbackTextColor: previewTextColor,
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (poi != null) {
|
} else if (poi != null) {
|
||||||
contentPreview = Row(
|
contentPreview = Row(
|
||||||
@@ -843,6 +850,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
|
|
||||||
void _sendReaction(ChannelMessage message, String emoji) {
|
void _sendReaction(ChannelMessage message, String emoji) {
|
||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
// Send reaction with full messageId to find target, but parser will extract
|
||||||
|
// lightweight reactionKey (timestamp_senderPrefix) for deduplication
|
||||||
final reactionText = 'r:${message.messageId}:$emoji';
|
final reactionText = 'r:${message.messageId}:$emoji';
|
||||||
connector.sendChannelMessage(widget.channel, reactionText);
|
connector.sendChannelMessage(widget.channel, reactionText);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,48 +78,46 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Consumer<MeshCoreConnector>(
|
body: () {
|
||||||
builder: (context, connector, child) {
|
if (connector.isLoadingChannels) {
|
||||||
if (connector.isLoadingChannels) {
|
return const Center(child: CircularProgressIndicator());
|
||||||
return const Center(child: CircularProgressIndicator());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
final channels = connector.channels;
|
final channels = connector.channels;
|
||||||
|
|
||||||
if (channels.isEmpty) {
|
if (channels.isEmpty) {
|
||||||
return EmptyState(
|
return EmptyState(
|
||||||
icon: Icons.tag,
|
icon: Icons.tag,
|
||||||
title: 'No channels configured',
|
title: 'No channels configured',
|
||||||
action: FilledButton.icon(
|
action: FilledButton.icon(
|
||||||
onPressed: () => _addPublicChannel(context, connector),
|
onPressed: () => _addPublicChannel(context, connector),
|
||||||
icon: const Icon(Icons.public),
|
icon: const Icon(Icons.public),
|
||||||
label: const Text('Add Public Channel'),
|
label: const Text('Add Public Channel'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReorderableListView.builder(
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 88),
|
||||||
|
buildDefaultDragHandles: false,
|
||||||
|
itemCount: channels.length,
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
final reordered = List<Channel>.from(channels);
|
||||||
|
final item = reordered.removeAt(oldIndex);
|
||||||
|
reordered.insert(newIndex, item);
|
||||||
|
unawaited(
|
||||||
|
connector.setChannelOrder(
|
||||||
|
reordered.map((c) => c.index).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
return ReorderableListView.builder(
|
final channel = channels[index];
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
|
return _buildChannelTile(context, connector, channel, index);
|
||||||
buildDefaultDragHandles: false,
|
},
|
||||||
itemCount: channels.length,
|
);
|
||||||
onReorder: (oldIndex, newIndex) {
|
}(),
|
||||||
if (newIndex > oldIndex) newIndex -= 1;
|
|
||||||
final reordered = List<Channel>.from(channels);
|
|
||||||
final item = reordered.removeAt(oldIndex);
|
|
||||||
reordered.insert(newIndex, item);
|
|
||||||
unawaited(
|
|
||||||
connector.setChannelOrder(
|
|
||||||
reordered.map((c) => c.index).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final channel = channels[index];
|
|
||||||
return _buildChannelTile(context, connector, channel, index);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => _showAddChannelDialog(context),
|
onPressed: () => _showAddChannelDialog(context),
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
@@ -144,7 +142,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
final unreadCount = connector.getUnreadCountForChannel(channel);
|
final unreadCount = connector.getUnreadCountForChannel(channel);
|
||||||
return Card(
|
return Card(
|
||||||
key: ValueKey('channel_${channel.index}'),
|
key: ValueKey('channel_${channel.index}'),
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
minVerticalPadding: 0,
|
minVerticalPadding: 0,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class ChatScreen extends StatefulWidget {
|
|||||||
class _ChatScreenState extends State<ChatScreen> {
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
final _textController = TextEditingController();
|
final _textController = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
bool _clearPath = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,6 +39,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
|
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
|
||||||
|
|
||||||
|
// Scroll to bottom when opening chat
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,75 +64,86 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final contact = _resolveContact(connector);
|
final contact = _resolveContact(connector);
|
||||||
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
|
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
|
||||||
final unreadLabel = 'Unread: $unreadCount';
|
final unreadLabel = 'Unread: $unreadCount';
|
||||||
final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact);
|
final pathLabel = _currentPathLabel(contact);
|
||||||
final canShowPathDetails = !_clearPath && contact.path.isNotEmpty;
|
|
||||||
|
// Show path details if we have path data (from device or override)
|
||||||
|
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
||||||
|
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(contact.name),
|
Text(contact.name),
|
||||||
if (canShowPathDetails)
|
GestureDetector(
|
||||||
GestureDetector(
|
behavior: HitTestBehavior.opaque,
|
||||||
behavior: HitTestBehavior.opaque,
|
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null,
|
||||||
onLongPress: () => _showFullPathDialog(context, contact.path),
|
child: Text(
|
||||||
child: Text(
|
|
||||||
'$pathLabel • $unreadLabel',
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Text(
|
|
||||||
'$pathLabel • $unreadLabel',
|
'$pathLabel • $unreadLabel',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
decoration: hasPathData ? TextDecoration.underline : null,
|
||||||
|
decorationStyle: TextDecorationStyle.dotted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
PopupMenuButton<String>(
|
Consumer<MeshCoreConnector>(
|
||||||
icon: Icon(_clearPath ? Icons.waves : Icons.route),
|
builder: (context, connector, _) {
|
||||||
tooltip: 'Routing mode',
|
final contact = _resolveContact(connector);
|
||||||
onSelected: (mode) {
|
final isFloodMode = contact.pathOverride == -1;
|
||||||
setState(() {
|
|
||||||
_clearPath = (mode == 'flood');
|
return PopupMenuButton<String>(
|
||||||
});
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
|
tooltip: 'Routing mode',
|
||||||
|
onSelected: (mode) async {
|
||||||
|
if (mode == 'flood') {
|
||||||
|
await connector.setPathOverride(contact, pathLen: -1);
|
||||||
|
} else {
|
||||||
|
await connector.setPathOverride(contact, pathLen: null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'auto',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Auto (use saved path)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'flood',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Force Flood Mode',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'auto',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.auto_mode, size: 20, color: !_clearPath ? Theme.of(context).primaryColor : null),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Auto (use saved path)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'flood',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.waves, size: 20, color: _clearPath ? Theme.of(context).primaryColor : null),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Force Flood Mode',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.timeline),
|
icon: const Icon(Icons.timeline),
|
||||||
@@ -304,7 +319,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
connector.sendMessage(
|
connector.sendMessage(
|
||||||
widget.contact,
|
widget.contact,
|
||||||
text,
|
text,
|
||||||
clearPath: _clearPath,
|
|
||||||
);
|
);
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
|
|
||||||
@@ -416,23 +430,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final pathBytes = Uint8List.fromList(path.pathBytes);
|
final pathBytes = Uint8List.fromList(path.pathBytes);
|
||||||
final pathLength = path.pathBytes.length;
|
final pathLength = path.pathBytes.length;
|
||||||
|
|
||||||
await connector.setContactPath(
|
// Set the path override to persist user's choice
|
||||||
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
widget.contact,
|
||||||
pathBytes,
|
pathLen: pathLength,
|
||||||
pathLength,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update contact in memory directly for immediate UI feedback
|
|
||||||
connector.updateContactInMemory(
|
|
||||||
widget.contact.publicKeyHex,
|
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
pathLength: pathLength,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
setState(() {
|
|
||||||
_clearPath = false;
|
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
||||||
@@ -499,10 +504,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
|
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
|
||||||
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
|
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
setState(() {
|
await connector.setPathOverride(widget.contact, pathLen: -1);
|
||||||
_clearPath = true;
|
if (!context.mounted) return;
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
|
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
|
||||||
@@ -573,9 +577,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _currentPathLabel(Contact contact) {
|
String _currentPathLabel(Contact contact) {
|
||||||
|
// Check if user has set a path override
|
||||||
|
if (contact.pathOverride != null) {
|
||||||
|
if (contact.pathOverride! < 0) return 'Flood (forced)';
|
||||||
|
if (contact.pathOverride == 0) return 'Direct (forced)';
|
||||||
|
return '${contact.pathOverride} hops (forced)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use device's path
|
||||||
if (contact.pathLength < 0) return 'Flood (auto)';
|
if (contact.pathLength < 0) return 'Flood (auto)';
|
||||||
if (contact.pathLength == 0) return 'Direct';
|
if (contact.pathLength == 0) return 'Direct';
|
||||||
if (contact.pathIdList.isNotEmpty) return contact.pathIdList;
|
|
||||||
return '${contact.pathLength} hops';
|
return '${contact.pathLength} hops';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +615,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
'Location',
|
'Location',
|
||||||
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
||||||
),
|
),
|
||||||
_buildInfoRow('Public Key', contact.publicKeyHex.substring(0, 16) + '...'),
|
_buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
@@ -1125,12 +1136,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
void _retryMessage(Message message) {
|
void _retryMessage(Message message) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
// Retry with clearPath if the message has no path or pathLength is -1 (indicating flood was used)
|
// Retry using the contact's current path override setting
|
||||||
final shouldClearPath = message.pathLength != null && message.pathLength! < 0;
|
|
||||||
connector.sendMessage(
|
connector.sendMessage(
|
||||||
widget.contact,
|
widget.contact,
|
||||||
message.text,
|
message.text,
|
||||||
clearPath: shouldClearPath,
|
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Retrying message')),
|
const SnackBar(content: Text('Retrying message')),
|
||||||
@@ -1151,7 +1160,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
void _sendReaction(Message message, String emoji) {
|
void _sendReaction(Message message, String emoji) {
|
||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final reactionText = 'r:${message.messageId}:$emoji';
|
// Send reaction with messageId if available, otherwise use lightweight format
|
||||||
|
// Parser will extract reactionKey (timestamp_senderPrefix) for deduplication
|
||||||
|
final messageId = message.messageId ??
|
||||||
|
'${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}';
|
||||||
|
final reactionText = 'r:$messageId:$emoji';
|
||||||
connector.sendMessage(widget.contact, reactionText);
|
connector.sendMessage(widget.contact, reactionText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1176,7 +1189,6 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
final gifId = _parseGifId(message.text);
|
final gifId = _parseGifId(message.text);
|
||||||
final poi = _parsePoiMessage(message.text);
|
final poi = _parsePoiMessage(message.text);
|
||||||
final isFailed = message.status == MessageStatus.failed;
|
final isFailed = message.status == MessageStatus.failed;
|
||||||
final attempts = message.retryCount + 1;
|
|
||||||
final bubbleColor = isFailed
|
final bubbleColor = isFailed
|
||||||
? colorScheme.errorContainer
|
? colorScheme.errorContainer
|
||||||
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
|
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
|
||||||
@@ -1240,13 +1252,14 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
color: textColor,
|
color: textColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isOutgoing) ...[
|
if (isOutgoing && message.retryCount > 0) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Attempts: $attempts',
|
'Retry ${message.retryCount}/4',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: metaColor,
|
color: metaColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -682,7 +682,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final exists = _groups.any((g) {
|
final exists = _groups.any((g) {
|
||||||
if (isEditing && g.name == group!.name) return false;
|
if (isEditing && g.name == group.name) return false;
|
||||||
return g.name.toLowerCase() == name.toLowerCase();
|
return g.name.toLowerCase() == name.toLowerCase();
|
||||||
});
|
});
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -693,7 +693,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
final index = _groups.indexWhere((g) => g.name == group!.name);
|
final index = _groups.indexWhere((g) => g.name == group.name);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
_groups[index] = ContactGroup(
|
_groups[index] = ContactGroup(
|
||||||
name: name,
|
name: name,
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
|||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final cacheService = context.read<MapTileCacheService>();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDownloading = true;
|
_isDownloading = true;
|
||||||
@@ -150,7 +152,6 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
_failedTiles = 0;
|
_failedTiles = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
final cacheService = context.read<MapTileCacheService>();
|
|
||||||
final result = await cacheService.downloadRegion(
|
final result = await cacheService.downloadRegion(
|
||||||
bounds: bounds,
|
bounds: bounds,
|
||||||
minZoom: _minZoom,
|
minZoom: _minZoom,
|
||||||
@@ -198,7 +199,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
final cacheService = context.read<MapTileCacheService>();
|
final cacheService = context.read<MapTileCacheService>();
|
||||||
await cacheService.clearCache();
|
await cacheService.clearCache();
|
||||||
|
|||||||
@@ -785,10 +785,13 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final label = await _promptForLabel(context, defaultLabel);
|
final label = await _promptForLabel(context, defaultLabel);
|
||||||
if (label == null) return;
|
if (label == null || !mounted) return;
|
||||||
|
|
||||||
final markerText = _formatMarkerMessage(position, label, flags);
|
final markerText = _formatMarkerMessage(position, label, flags);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
await _showRecipientSheet(
|
await _showRecipientSheet(
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
context: context,
|
context: context,
|
||||||
connector: connector,
|
connector: connector,
|
||||||
markerText: markerText,
|
markerText: markerText,
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
side: BorderSide(color: colorScheme.outlineVariant),
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:provider/provider.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../models/radio_settings.dart';
|
import '../models/radio_settings.dart';
|
||||||
import '../services/app_settings_service.dart';
|
|
||||||
import 'app_settings_screen.dart';
|
import 'app_settings_screen.dart';
|
||||||
import 'ble_debug_log_screen.dart';
|
import 'ble_debug_log_screen.dart';
|
||||||
|
|
||||||
@@ -63,6 +62,8 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
_buildInfoRow('Node Name', connector.selfName!),
|
_buildInfoRow('Node Name', connector.selfName!),
|
||||||
if (connector.selfPublicKey != null)
|
if (connector.selfPublicKey != null)
|
||||||
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
|
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
|
||||||
|
_buildInfoRow('Contacts Count', '${connector.contacts.length}'),
|
||||||
|
_buildInfoRow('Channel Count', '${connector.channels.length}'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -619,7 +620,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<LoRaBandwidth>(
|
DropdownButtonFormField<LoRaBandwidth>(
|
||||||
value: _bandwidth,
|
initialValue: _bandwidth,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Bandwidth',
|
labelText: 'Bandwidth',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -636,7 +637,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<LoRaSpreadingFactor>(
|
DropdownButtonFormField<LoRaSpreadingFactor>(
|
||||||
value: _spreadingFactor,
|
initialValue: _spreadingFactor,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Spreading Factor',
|
labelText: 'Spreading Factor',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -653,7 +654,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<LoRaCodingRate>(
|
DropdownButtonFormField<LoRaCodingRate>(
|
||||||
value: _codingRate,
|
initialValue: _codingRate,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Coding Rate',
|
labelText: 'Coding Rate',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import 'dart:ffi';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:ffi/ffi.dart';
|
|
||||||
|
|
||||||
const int _codec2Mode1300 = 4;
|
|
||||||
|
|
||||||
class Codec2Ffi {
|
|
||||||
Codec2Ffi._(this._lib)
|
|
||||||
: _codec2Create = _lib
|
|
||||||
.lookupFunction<_codec2_create_c, _codec2_create_d>('codec2_create'),
|
|
||||||
_codec2Destroy = _lib
|
|
||||||
.lookupFunction<_codec2_destroy_c, _codec2_destroy_d>('codec2_destroy'),
|
|
||||||
_codec2Encode = _lib
|
|
||||||
.lookupFunction<_codec2_encode_c, _codec2_encode_d>('codec2_encode'),
|
|
||||||
_codec2Decode = _lib
|
|
||||||
.lookupFunction<_codec2_decode_c, _codec2_decode_d>('codec2_decode'),
|
|
||||||
_codec2SamplesPerFrame = _lib.lookupFunction<_codec2_samples_per_frame_c,
|
|
||||||
_codec2_samples_per_frame_d>('codec2_samples_per_frame'),
|
|
||||||
_codec2BytesPerFrame = _lib.lookupFunction<_codec2_bytes_per_frame_c,
|
|
||||||
_codec2_bytes_per_frame_d>('codec2_bytes_per_frame');
|
|
||||||
|
|
||||||
static final Codec2Ffi instance = Codec2Ffi._(_openLibrary());
|
|
||||||
|
|
||||||
final DynamicLibrary _lib;
|
|
||||||
final _codec2_create_d _codec2Create;
|
|
||||||
final _codec2_destroy_d _codec2Destroy;
|
|
||||||
final _codec2_encode_d _codec2Encode;
|
|
||||||
final _codec2_decode_d _codec2Decode;
|
|
||||||
final _codec2_samples_per_frame_d _codec2SamplesPerFrame;
|
|
||||||
final _codec2_bytes_per_frame_d _codec2BytesPerFrame;
|
|
||||||
|
|
||||||
Codec2Session createSession() {
|
|
||||||
final handle = _codec2Create(_codec2Mode1300);
|
|
||||||
if (handle == nullptr) {
|
|
||||||
throw StateError('codec2_create returned null');
|
|
||||||
}
|
|
||||||
return Codec2Session._(
|
|
||||||
handle: handle,
|
|
||||||
destroy: _codec2Destroy,
|
|
||||||
encode: _codec2Encode,
|
|
||||||
decode: _codec2Decode,
|
|
||||||
samplesPerFrame: _codec2SamplesPerFrame,
|
|
||||||
bytesPerFrame: _codec2BytesPerFrame,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DynamicLibrary _openLibrary() {
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
return DynamicLibrary.open('libcodec2.so');
|
|
||||||
}
|
|
||||||
if (Platform.isIOS || Platform.isMacOS) {
|
|
||||||
return DynamicLibrary.process();
|
|
||||||
}
|
|
||||||
throw UnsupportedError('Codec2 is only supported on Android and iOS.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Codec2Session {
|
|
||||||
Codec2Session._({
|
|
||||||
required this.handle,
|
|
||||||
required this.destroy,
|
|
||||||
required this.encode,
|
|
||||||
required this.decode,
|
|
||||||
required this.samplesPerFrame,
|
|
||||||
required this.bytesPerFrame,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Pointer<Void> handle;
|
|
||||||
final _codec2_destroy_d destroy;
|
|
||||||
final _codec2_encode_d encode;
|
|
||||||
final _codec2_decode_d decode;
|
|
||||||
final _codec2_samples_per_frame_d samplesPerFrame;
|
|
||||||
final _codec2_bytes_per_frame_d bytesPerFrame;
|
|
||||||
|
|
||||||
int get samplesPerFrameValue => samplesPerFrame(handle);
|
|
||||||
int get bytesPerFrameValue => bytesPerFrame(handle);
|
|
||||||
|
|
||||||
Uint8List encodePcmFrame(Int16List pcmFrame) {
|
|
||||||
final bytesOut = calloc<Uint8>(bytesPerFrameValue);
|
|
||||||
final pcmIn = calloc<Int16>(samplesPerFrameValue);
|
|
||||||
try {
|
|
||||||
final sampleCount = samplesPerFrameValue;
|
|
||||||
final pcmBuffer = pcmIn.asTypedList(sampleCount);
|
|
||||||
final copyLen = pcmFrame.length < sampleCount ? pcmFrame.length : sampleCount;
|
|
||||||
pcmBuffer.setRange(0, copyLen, pcmFrame);
|
|
||||||
if (copyLen < sampleCount) {
|
|
||||||
for (var i = copyLen; i < sampleCount; i++) {
|
|
||||||
pcmBuffer[i] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encode(handle, bytesOut, pcmIn);
|
|
||||||
return Uint8List.fromList(bytesOut.asTypedList(bytesPerFrameValue));
|
|
||||||
} finally {
|
|
||||||
calloc.free(bytesOut);
|
|
||||||
calloc.free(pcmIn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Int16List decodeCodecFrame(Uint8List codecFrame) {
|
|
||||||
final pcmOut = calloc<Int16>(samplesPerFrameValue);
|
|
||||||
final bytesIn = calloc<Uint8>(bytesPerFrameValue);
|
|
||||||
try {
|
|
||||||
final codecBuffer = bytesIn.asTypedList(bytesPerFrameValue);
|
|
||||||
codecBuffer.setRange(0, bytesPerFrameValue, codecFrame);
|
|
||||||
decode(handle, pcmOut, bytesIn);
|
|
||||||
return Int16List.fromList(pcmOut.asTypedList(samplesPerFrameValue));
|
|
||||||
} finally {
|
|
||||||
calloc.free(bytesIn);
|
|
||||||
calloc.free(pcmOut);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
destroy(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef _codec2_create_c = Pointer<Void> Function(Int32 mode);
|
|
||||||
typedef _codec2_create_d = Pointer<Void> Function(int mode);
|
|
||||||
|
|
||||||
typedef _codec2_destroy_c = Void Function(Pointer<Void> codec2State);
|
|
||||||
typedef _codec2_destroy_d = void Function(Pointer<Void> codec2State);
|
|
||||||
|
|
||||||
typedef _codec2_encode_c = Void Function(
|
|
||||||
Pointer<Void> codec2State,
|
|
||||||
Pointer<Uint8> bytes,
|
|
||||||
Pointer<Int16> speechIn,
|
|
||||||
);
|
|
||||||
typedef _codec2_encode_d = void Function(
|
|
||||||
Pointer<Void> codec2State,
|
|
||||||
Pointer<Uint8> bytes,
|
|
||||||
Pointer<Int16> speechIn,
|
|
||||||
);
|
|
||||||
|
|
||||||
typedef _codec2_decode_c = Void Function(
|
|
||||||
Pointer<Void> codec2State,
|
|
||||||
Pointer<Int16> speechOut,
|
|
||||||
Pointer<Uint8> bytes,
|
|
||||||
);
|
|
||||||
typedef _codec2_decode_d = void Function(
|
|
||||||
Pointer<Void> codec2State,
|
|
||||||
Pointer<Int16> speechOut,
|
|
||||||
Pointer<Uint8> bytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
typedef _codec2_samples_per_frame_c = Int32 Function(Pointer<Void> codec2State);
|
|
||||||
typedef _codec2_samples_per_frame_d = int Function(Pointer<Void> codec2State);
|
|
||||||
|
|
||||||
typedef _codec2_bytes_per_frame_c = Int32 Function(Pointer<Void> codec2State);
|
|
||||||
typedef _codec2_bytes_per_frame_d = int Function(Pointer<Void> codec2State);
|
|
||||||
@@ -183,7 +183,7 @@ class MapTileCacheService {
|
|||||||
int _lonToTileX(double lon, int zoom, int maxIndex) {
|
int _lonToTileX(double lon, int zoom, int maxIndex) {
|
||||||
final n = 1 << zoom;
|
final n = 1 << zoom;
|
||||||
final value = ((lon + 180.0) / 360.0 * n).floor();
|
final value = ((lon + 180.0) / 360.0 * n).floor();
|
||||||
return value.clamp(0, maxIndex) as int;
|
return value.clamp(0, maxIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _latToTileY(double lat, int zoom, int maxIndex) {
|
int _latToTileY(double lat, int zoom, int maxIndex) {
|
||||||
@@ -194,12 +194,12 @@ class MapTileCacheService {
|
|||||||
2 *
|
2 *
|
||||||
n)
|
n)
|
||||||
.floor();
|
.floor();
|
||||||
return value.clamp(0, maxIndex) as int;
|
return value.clamp(0, maxIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
double _clampLatitude(double lat) {
|
double _clampLatitude(double lat) {
|
||||||
const maxLat = 85.05112878;
|
const maxLat = 85.05112878;
|
||||||
return lat.clamp(-maxLat, maxLat) as double;
|
return lat.clamp(-maxLat, maxLat);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildTileUrl(int x, int y, int zoom) {
|
String _buildTileUrl(int x, int y, int zoom) {
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ class _AckHistoryEntry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AckHashMapping {
|
||||||
|
final String messageId;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
_AckHashMapping({
|
||||||
|
required this.messageId,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class MessageRetryService extends ChangeNotifier {
|
class MessageRetryService extends ChangeNotifier {
|
||||||
static const int maxRetries = 5;
|
static const int maxRetries = 5;
|
||||||
static const int maxAckHistorySize = 100;
|
static const int maxAckHistorySize = 100;
|
||||||
@@ -28,8 +38,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
final Map<String, Message> _pendingMessages = {};
|
final Map<String, Message> _pendingMessages = {};
|
||||||
final Map<String, Contact> _pendingContacts = {};
|
final Map<String, Contact> _pendingContacts = {};
|
||||||
final Map<String, PathSelection> _pendingPathSelections = {};
|
final Map<String, PathSelection> _pendingPathSelections = {};
|
||||||
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries
|
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex → messageId + timestamp for O(1) lookup
|
||||||
|
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history)
|
||||||
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
|
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
|
||||||
|
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds
|
||||||
|
|
||||||
Function(Contact, String, int, int)? _sendMessageCallback;
|
Function(Contact, String, int, int)? _sendMessageCallback;
|
||||||
Function(String, Message)? _addMessageCallback;
|
Function(String, Message)? _addMessageCallback;
|
||||||
@@ -65,17 +77,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
Future<void> sendMessageWithRetry({
|
Future<void> sendMessageWithRetry({
|
||||||
required Contact contact,
|
required Contact contact,
|
||||||
required String text,
|
required String text,
|
||||||
bool clearPath = false,
|
|
||||||
PathSelection? pathSelection,
|
PathSelection? pathSelection,
|
||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
int? pathLength,
|
int? pathLength,
|
||||||
}) async {
|
}) async {
|
||||||
final messageId = const Uuid().v4();
|
final messageId = const Uuid().v4();
|
||||||
final useClearPath = clearPath || (pathSelection?.useFlood ?? false);
|
final useFlood = pathSelection?.useFlood ?? false;
|
||||||
final messagePathBytes =
|
final messagePathBytes =
|
||||||
pathBytes ?? _resolveMessagePathBytes(contact, useClearPath, pathSelection);
|
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
|
||||||
final messagePathLength =
|
final messagePathLength =
|
||||||
pathLength ?? _resolveMessagePathLength(contact, useClearPath, pathSelection);
|
pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection);
|
||||||
final message = Message(
|
final message = Message(
|
||||||
senderKey: contact.publicKey,
|
senderKey: contact.publicKey,
|
||||||
text: text,
|
text: text,
|
||||||
@@ -126,6 +137,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
|
|
||||||
final attempt = message.retryCount.clamp(0, 3);
|
final attempt = message.retryCount.clamp(0, 3);
|
||||||
|
|
||||||
|
// Enqueue this message to track send order for ACK hash mapping (FIFO)
|
||||||
|
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
|
||||||
|
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
|
||||||
|
debugPrint('Enqueued message $messageId for ${contact.name} (queue size: ${_pendingMessageQueuePerContact[contact.publicKeyHex]!.length})');
|
||||||
|
|
||||||
if (_sendMessageCallback != null) {
|
if (_sendMessageCallback != null) {
|
||||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||||
_sendMessageCallback!(
|
_sendMessageCallback!(
|
||||||
@@ -138,58 +154,103 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
||||||
for (var entry in _pendingMessages.entries) {
|
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
final message = entry.value;
|
|
||||||
// Only update if pending (waiting to send) or already sent with matching ACK
|
|
||||||
if (message.status == MessageStatus.pending ||
|
|
||||||
(message.status == MessageStatus.sent &&
|
|
||||||
message.expectedAckHash != null &&
|
|
||||||
listEquals(message.expectedAckHash, ackHash))) {
|
|
||||||
final contact = _pendingContacts[entry.key];
|
|
||||||
final selection = _pendingPathSelections[entry.key];
|
|
||||||
|
|
||||||
// Add this ACK hash to the list of expected ACKs for this message
|
// Dequeue the next message from the FIFO queue to match with this RESP_CODE_SENT
|
||||||
_expectedAckHashes[entry.key] ??= [];
|
// We iterate through contacts to find which one has a pending message in their queue
|
||||||
if (!_expectedAckHashes[entry.key]!.any((hash) => listEquals(hash, ackHash))) {
|
String? messageId;
|
||||||
_expectedAckHashes[entry.key]!.add(Uint8List.fromList(ackHash));
|
Contact? contact;
|
||||||
debugPrint('Added ACK hash ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()} to message ${entry.key} (total: ${_expectedAckHashes[entry.key]!.length})');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||||
int actualTimeout = timeoutMs;
|
final contactKey = entry.key;
|
||||||
if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) {
|
final queue = entry.value;
|
||||||
int pathLengthValue;
|
|
||||||
if (selection != null) {
|
if (queue.isNotEmpty) {
|
||||||
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
// Dequeue the first (oldest) message from this contact's queue
|
||||||
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
|
final candidateMessageId = queue.removeAt(0);
|
||||||
} else if (message.pathLength != null) {
|
|
||||||
pathLengthValue = message.pathLength!;
|
// Verify this message is still pending
|
||||||
} else {
|
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||||
pathLengthValue = contact.pathLength;
|
messageId = candidateMessageId;
|
||||||
|
contact = _pendingContacts[candidateMessageId];
|
||||||
|
debugPrint('Dequeued message $messageId for $contactKey (remaining in queue: ${queue.length})');
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||||
|
// Continue to next message in queue
|
||||||
|
if (queue.isNotEmpty) {
|
||||||
|
final nextMessageId = queue.removeAt(0);
|
||||||
|
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||||
|
messageId = nextMessageId;
|
||||||
|
contact = _pendingContacts[nextMessageId];
|
||||||
|
debugPrint('Dequeued next message $messageId for $contactKey (remaining: ${queue.length})');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
|
|
||||||
debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedMessage = message.copyWith(
|
|
||||||
status: MessageStatus.sent,
|
|
||||||
expectedAckHash: ackHash, // Keep the most recent one for display
|
|
||||||
estimatedTimeoutMs: actualTimeout,
|
|
||||||
sentAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
_pendingMessages[entry.key] = updatedMessage;
|
|
||||||
|
|
||||||
if (_updateMessageCallback != null) {
|
|
||||||
_updateMessageCallback!(updatedMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
_startTimeoutTimer(entry.key, actualTimeout);
|
|
||||||
debugPrint('Updated message ${entry.key} with ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debugPrint('No pending message found for ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
|
|
||||||
|
if (messageId == null || contact == null) {
|
||||||
|
debugPrint('No pending message found for ACK hash: $ackHashHex (all queues empty or stale)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the mapping for future lookups (e.g., when ACK arrives)
|
||||||
|
// Keep timestamp so we can clean up old mappings later
|
||||||
|
_ackHashToMessageId[ackHashHex] = _AckHashMapping(
|
||||||
|
messageId: messageId,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
debugPrint('Mapped ACK hash $ackHashHex to message $messageId');
|
||||||
|
|
||||||
|
final message = _pendingMessages[messageId];
|
||||||
|
final selection = _pendingPathSelections[messageId];
|
||||||
|
|
||||||
|
if (message == null) {
|
||||||
|
debugPrint('Message $messageId no longer pending for ACK hash: $ackHashHex');
|
||||||
|
_ackHashToMessageId.remove(ackHashHex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this ACK hash to the list of expected ACKs for this message (for history)
|
||||||
|
_expectedAckHashes[messageId] ??= [];
|
||||||
|
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) {
|
||||||
|
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
|
||||||
|
debugPrint('Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
||||||
|
int actualTimeout = timeoutMs;
|
||||||
|
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) {
|
||||||
|
int pathLengthValue;
|
||||||
|
if (selection != null) {
|
||||||
|
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||||
|
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
|
||||||
|
} else if (message.pathLength != null) {
|
||||||
|
pathLengthValue = message.pathLength!;
|
||||||
|
} else {
|
||||||
|
pathLengthValue = contact.pathLength;
|
||||||
|
}
|
||||||
|
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
|
||||||
|
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue');
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedMessage = message.copyWith(
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
expectedAckHash: ackHash,
|
||||||
|
estimatedTimeoutMs: actualTimeout,
|
||||||
|
sentAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages[messageId] = updatedMessage;
|
||||||
|
|
||||||
|
if (_updateMessageCallback != null) {
|
||||||
|
_updateMessageCallback!(updatedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startTimeoutTimer(messageId, actualTimeout);
|
||||||
|
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startTimeoutTimer(String messageId, int timeoutMs) {
|
void _startTimeoutTimer(String messageId, int timeoutMs) {
|
||||||
@@ -204,7 +265,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
final contact = _pendingContacts[messageId];
|
final contact = _pendingContacts[messageId];
|
||||||
final selection = _pendingPathSelections[messageId];
|
final selection = _pendingPathSelections[messageId];
|
||||||
|
|
||||||
if (message == null || contact == null) return;
|
if (message == null || contact == null) {
|
||||||
|
debugPrint('Timeout fired but message $messageId no longer pending (likely already delivered)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
|
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
|
||||||
|
|
||||||
@@ -225,7 +289,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
|
|
||||||
debugPrint('Scheduling retry after ${backoffMs}ms');
|
debugPrint('Scheduling retry after ${backoffMs}ms');
|
||||||
Timer(Duration(milliseconds: backoffMs), () {
|
Timer(Duration(milliseconds: backoffMs), () {
|
||||||
_attemptSend(messageId);
|
// Double-check message is still pending before retry
|
||||||
|
if (_pendingMessages.containsKey(messageId)) {
|
||||||
|
_attemptSend(messageId);
|
||||||
|
} else {
|
||||||
|
debugPrint('Retry cancelled: message $messageId was delivered while waiting');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Max retries reached - mark as failed
|
// Max retries reached - mark as failed
|
||||||
@@ -240,6 +309,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_timeoutTimers[messageId]?.cancel();
|
_timeoutTimers[messageId]?.cancel();
|
||||||
_timeoutTimers.remove(messageId);
|
_timeoutTimers.remove(messageId);
|
||||||
|
|
||||||
|
// Clean up the queue entry for this contact
|
||||||
|
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||||
|
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||||
|
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we should clear the path on max retry
|
// Check if we should clear the path on max retry
|
||||||
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||||
_clearContactPathCallback != null) {
|
_clearContactPathCallback != null) {
|
||||||
@@ -291,28 +366,44 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
|
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
|
||||||
debugPrint('Pending messages:');
|
|
||||||
for (var entry in _pendingMessages.entries) {
|
// First, clean up old ACK hash mappings (older than 15 minutes)
|
||||||
final message = entry.value;
|
final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
|
||||||
final expectedHex = message.expectedAckHash?.map((b) => b.toRadixString(16).padLeft(2, '0')).join() ?? 'none';
|
final hashesToRemove = <String>[];
|
||||||
final allExpectedHashes = _expectedAckHashes[entry.key]?.map((h) => h.map((b) => b.toRadixString(16).padLeft(2, '0')).join()).join(', ') ?? 'none';
|
for (var entry in _ackHashToMessageId.entries) {
|
||||||
debugPrint(' ${entry.key}: status=${message.status}, latestAck=$expectedHex, allAcks=[$allExpectedHashes], retry=${message.retryCount}');
|
if (entry.value.timestamp.isBefore(cutoffTime)) {
|
||||||
|
hashesToRemove.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var hash in hashesToRemove) {
|
||||||
|
_ackHashToMessageId.remove(hash);
|
||||||
|
}
|
||||||
|
if (hashesToRemove.isNotEmpty) {
|
||||||
|
debugPrint('Cleaned up ${hashesToRemove.length} old ACK hash mappings');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check against ALL expected ACK hashes (from all retry attempts)
|
// Use direct O(1) lookup via ACK hash mapping
|
||||||
for (var entry in _expectedAckHashes.entries) {
|
final mapping = _ackHashToMessageId[ackHashHex];
|
||||||
final messageId = entry.key;
|
if (mapping != null) {
|
||||||
final expectedHashes = entry.value;
|
matchedMessageId = mapping.messageId;
|
||||||
|
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
|
||||||
|
} else {
|
||||||
|
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
|
||||||
|
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
|
||||||
|
for (var entry in _expectedAckHashes.entries) {
|
||||||
|
final messageId = entry.key;
|
||||||
|
final expectedHashes = entry.value;
|
||||||
|
|
||||||
for (final expectedHash in expectedHashes) {
|
for (final expectedHash in expectedHashes) {
|
||||||
if (listEquals(expectedHash, ackHash)) {
|
if (listEquals(expectedHash, ackHash)) {
|
||||||
matchedMessageId = messageId;
|
matchedMessageId = messageId;
|
||||||
debugPrint('Matched ACK to message: $matchedMessageId (matched hash from attempt ${expectedHashes.indexOf(expectedHash)})');
|
debugPrint('Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})');
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedMessageId != null) break;
|
if (matchedMessageId != null) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedMessageId != null) {
|
if (matchedMessageId != null) {
|
||||||
@@ -337,6 +428,14 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_pendingContacts.remove(matchedMessageId);
|
_pendingContacts.remove(matchedMessageId);
|
||||||
_pendingPathSelections.remove(matchedMessageId);
|
_pendingPathSelections.remove(matchedMessageId);
|
||||||
|
|
||||||
|
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||||
|
if (contact != null) {
|
||||||
|
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId);
|
||||||
|
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||||
|
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_updateMessageCallback != null) {
|
if (_updateMessageCallback != null) {
|
||||||
_updateMessageCallback!(deliveredMessage);
|
_updateMessageCallback!(deliveredMessage);
|
||||||
}
|
}
|
||||||
@@ -361,12 +460,25 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
bool forceFlood,
|
bool forceFlood,
|
||||||
PathSelection? selection,
|
PathSelection? selection,
|
||||||
) {
|
) {
|
||||||
|
// Priority 1: Check user's path override
|
||||||
|
if (contact.pathOverride != null) {
|
||||||
|
if (contact.pathOverride! < 0) {
|
||||||
|
return Uint8List(0); // Force flood
|
||||||
|
}
|
||||||
|
return contact.pathOverrideBytes ?? Uint8List(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Check forceFlood or device flood mode
|
||||||
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||||
return Uint8List(0);
|
return Uint8List(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 3: Check PathSelection (auto-rotation)
|
||||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||||
return Uint8List.fromList(selection.pathBytes);
|
return Uint8List.fromList(selection.pathBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 4: Use device's discovered path
|
||||||
return contact.path;
|
return contact.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,12 +487,22 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
bool forceFlood,
|
bool forceFlood,
|
||||||
PathSelection? selection,
|
PathSelection? selection,
|
||||||
) {
|
) {
|
||||||
|
// Priority 1: Check user's path override
|
||||||
|
if (contact.pathOverride != null) {
|
||||||
|
return contact.pathOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Check forceFlood or device flood mode
|
||||||
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 3: Check PathSelection (auto-rotation)
|
||||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||||
return selection.hopCount;
|
return selection.hopCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 4: Use device's discovered path
|
||||||
return contact.pathLength;
|
return contact.pathLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +564,8 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_pendingPathSelections.clear();
|
_pendingPathSelections.clear();
|
||||||
_expectedAckHashes.clear();
|
_expectedAckHashes.clear();
|
||||||
_ackHistory.clear();
|
_ackHistory.clear();
|
||||||
|
_ackHashToMessageId.clear();
|
||||||
|
_pendingMessageQueuePerContact.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,27 +67,30 @@ class NotificationService {
|
|||||||
required String contactName,
|
required String contactName,
|
||||||
required String message,
|
required String message,
|
||||||
String? contactId,
|
String? contactId,
|
||||||
|
int? badgeCount,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
await initialize();
|
await initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
final androidDetails = AndroidNotificationDetails(
|
||||||
'messages',
|
'messages',
|
||||||
'Messages',
|
'Messages',
|
||||||
channelDescription: 'New message notifications',
|
channelDescription: 'New message notifications',
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
icon: '@mipmap/ic_launcher',
|
icon: '@mipmap/ic_launcher',
|
||||||
|
number: badgeCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
const iosDetails = DarwinNotificationDetails(
|
final iosDetails = DarwinNotificationDetails(
|
||||||
presentAlert: true,
|
presentAlert: true,
|
||||||
presentBadge: true,
|
presentBadge: true,
|
||||||
presentSound: true,
|
presentSound: true,
|
||||||
|
badgeNumber: badgeCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
const notificationDetails = NotificationDetails(
|
final notificationDetails = NotificationDetails(
|
||||||
android: androidDetails,
|
android: androidDetails,
|
||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
@@ -143,27 +146,30 @@ class NotificationService {
|
|||||||
required String channelName,
|
required String channelName,
|
||||||
required String message,
|
required String message,
|
||||||
int? channelIndex,
|
int? channelIndex,
|
||||||
|
int? badgeCount,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
await initialize();
|
await initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
final androidDetails = AndroidNotificationDetails(
|
||||||
'channel_messages',
|
'channel_messages',
|
||||||
'Channel Messages',
|
'Channel Messages',
|
||||||
channelDescription: 'New channel message notifications',
|
channelDescription: 'New channel message notifications',
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
icon: '@mipmap/ic_launcher',
|
icon: '@mipmap/ic_launcher',
|
||||||
|
number: badgeCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
const iosDetails = DarwinNotificationDetails(
|
final iosDetails = DarwinNotificationDetails(
|
||||||
presentAlert: true,
|
presentAlert: true,
|
||||||
presentBadge: true,
|
presentBadge: true,
|
||||||
presentSound: true,
|
presentSound: true,
|
||||||
|
badgeNumber: badgeCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
const notificationDetails = NotificationDetails(
|
final notificationDetails = NotificationDetails(
|
||||||
android: androidDetails,
|
android: androidDetails,
|
||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class ContactStore {
|
|||||||
'type': contact.type,
|
'type': contact.type,
|
||||||
'pathLength': contact.pathLength,
|
'pathLength': contact.pathLength,
|
||||||
'path': base64Encode(contact.path),
|
'path': base64Encode(contact.path),
|
||||||
|
'pathOverride': contact.pathOverride,
|
||||||
|
'pathOverrideBytes': contact.pathOverrideBytes != null
|
||||||
|
? base64Encode(contact.pathOverrideBytes!)
|
||||||
|
: null,
|
||||||
'latitude': contact.latitude,
|
'latitude': contact.latitude,
|
||||||
'longitude': contact.longitude,
|
'longitude': contact.longitude,
|
||||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||||
@@ -51,6 +55,10 @@ class ContactStore {
|
|||||||
path: json['path'] != null
|
path: json['path'] != null
|
||||||
? Uint8List.fromList(base64Decode(json['path'] as String))
|
? Uint8List.fromList(base64Decode(json['path'] as String))
|
||||||
: Uint8List(0),
|
: Uint8List(0),
|
||||||
|
pathOverride: json['pathOverride'] as int?,
|
||||||
|
pathOverrideBytes: json['pathOverrideBytes'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String))
|
||||||
|
: null,
|
||||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
shadowColor: Colors.transparent,
|
shadowColor: Colors.transparent,
|
||||||
indicatorColor: colorScheme.primaryContainer,
|
indicatorColor: colorScheme.primaryContainer,
|
||||||
labelTextStyle: MaterialStateProperty.resolveWith((states) {
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
final isSelected = states.contains(MaterialState.selected);
|
final isSelected = states.contains(WidgetState.selected);
|
||||||
return labelStyle.copyWith(
|
return labelStyle.copyWith(
|
||||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
@@ -46,8 +46,8 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
iconTheme: MaterialStateProperty.resolveWith((states) {
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
final isSelected = states.contains(MaterialState.selected);
|
final isSelected = states.contains(WidgetState.selected);
|
||||||
return IconThemeData(
|
return IconThemeData(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -101,16 +100,16 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we got a response, login succeeded
|
// If we got a response, login succeeded
|
||||||
if (mounted) {
|
// Save password if requested
|
||||||
// Save password if requested
|
if (_savePassword) {
|
||||||
if (_savePassword) {
|
await _storage.saveRepeaterPassword(
|
||||||
await _storage.saveRepeaterPassword(
|
widget.repeater.publicKeyHex, password);
|
||||||
widget.repeater.publicKeyHex, password);
|
} else {
|
||||||
} else {
|
// Remove saved password if user unchecked the box
|
||||||
// Remove saved password if user unchecked the box
|
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
|
||||||
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
Navigator.pop(context, password);
|
Navigator.pop(context, password);
|
||||||
Future.microtask(() => widget.onLogin(password));
|
Future.microtask(() => widget.onLogin(password));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ import Foundation
|
|||||||
|
|
||||||
import flutter_blue_plus_darwin
|
import flutter_blue_plus_darwin
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
import wakelock_plus
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-7
@@ -66,7 +66,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
@@ -234,10 +234,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus_winrt
|
name: flutter_blue_plus_winrt
|
||||||
sha256: "5cfa5960ac8723771cbc59586588b100f38494390154b8a3268c95db37e21617"
|
sha256: "0c87ca5bdf1a110d42847edeca8fbb11a9701738dc8526aefbb2a115bea29aef"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.7"
|
version: "0.0.10"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -436,10 +436,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mgrs_dart:
|
mgrs_dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -464,6 +464,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.0.0"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -745,10 +761,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -797,6 +813,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
wakelock_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: wakelock_plus
|
||||||
|
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
wakelock_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: wakelock_plus_platform_interface
|
||||||
|
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -805,6 +837,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
wkt_parser:
|
wkt_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ dependencies:
|
|||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
flutter_cache_manager: ^3.4.1
|
flutter_cache_manager: ^3.4.1
|
||||||
flutter_foreground_task: ^6.1.2
|
flutter_foreground_task: ^6.1.2
|
||||||
|
wakelock_plus: ^1.2.8
|
||||||
|
characters: ^1.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user