mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-15 07:04:26 +10:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd9f14dd09 | |||
| ad911a1d80 | |||
| 361dfb7808 | |||
| ad187962c9 | |||
| b7eec5627f |
@@ -6,6 +6,18 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
|||||||
|
|
||||||
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
|
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/contacts.jpg" width="200"/><br/><p align="center"><b>Contacts</b></p></td>
|
||||||
|
<td><img src="docs/screenshots/chat1.jpg" width="200"/><br/><p align="center"><b>Chat</b></p></td>
|
||||||
|
<td><img src="docs/screenshots/chat2.jpg" width="200"/><br/><p align="center"><b>Reactions</b></p></td>
|
||||||
|
<td><img src="docs/screenshots/map.jpg" width="200"/><br/><p align="center"><b>Map</b></p></td>
|
||||||
|
<td><img src="docs/screenshots/channels.jpg" width="200"/><br/><p align="center"><b>Channels</b></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core Functionality
|
### Core Functionality
|
||||||
@@ -199,4 +211,3 @@ Your support helps maintain and improve this open-source project!
|
|||||||
|
|
||||||
- Built with [Flutter](https://flutter.dev/)
|
- Built with [Flutter](https://flutter.dev/)
|
||||||
- Map tiles from [OpenStreetMap](https://www.openstreetmap.org/)
|
- Map tiles from [OpenStreetMap](https://www.openstreetmap.org/)
|
||||||
- Voice codec support via [Codec2](https://github.com/drowe67/codec2)
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 661 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 500 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -26,8 +26,6 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
|
|||||||
flutter_ios_podfile_setup
|
flutter_ios_podfile_setup
|
||||||
|
|
||||||
target 'Runner' do
|
target 'Runner' do
|
||||||
pod 'codec2', :path => '../third_party/codec2'
|
|
||||||
|
|
||||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../models/message.dart';
|
|||||||
import '../models/path_selection.dart';
|
import '../models/path_selection.dart';
|
||||||
import '../helpers/reaction_helper.dart';
|
import '../helpers/reaction_helper.dart';
|
||||||
import '../helpers/smaz.dart';
|
import '../helpers/smaz.dart';
|
||||||
|
import '../services/app_debug_log_service.dart';
|
||||||
import '../services/ble_debug_log_service.dart';
|
import '../services/ble_debug_log_service.dart';
|
||||||
import '../services/message_retry_service.dart';
|
import '../services/message_retry_service.dart';
|
||||||
import '../services/path_history_service.dart';
|
import '../services/path_history_service.dart';
|
||||||
@@ -27,6 +28,7 @@ import '../storage/contact_settings_store.dart';
|
|||||||
import '../storage/contact_store.dart';
|
import '../storage/contact_store.dart';
|
||||||
import '../storage/message_store.dart';
|
import '../storage/message_store.dart';
|
||||||
import '../storage/unread_store.dart';
|
import '../storage/unread_store.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
import 'meshcore_protocol.dart';
|
import 'meshcore_protocol.dart';
|
||||||
|
|
||||||
class MeshCoreUuids {
|
class MeshCoreUuids {
|
||||||
@@ -106,6 +108,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
static const int _maxQueueSyncRetries = 3;
|
static const int _maxQueueSyncRetries = 3;
|
||||||
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
||||||
|
|
||||||
|
// Channel syncing state (sequential pattern)
|
||||||
|
bool _isSyncingChannels = false;
|
||||||
|
bool _channelSyncInFlight = false;
|
||||||
|
Timer? _channelSyncTimeout;
|
||||||
|
int _channelSyncRetries = 0;
|
||||||
|
int _nextChannelIndexToRequest = 0;
|
||||||
|
int _totalChannelsToRequest = 0;
|
||||||
|
List<Channel> _previousChannelsCache = [];
|
||||||
|
static const int _maxChannelSyncRetries = 3;
|
||||||
|
static const int _channelSyncTimeoutMs = 2000; // 2 second timeout per channel
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
MessageRetryService? _retryService;
|
MessageRetryService? _retryService;
|
||||||
PathHistoryService? _pathHistoryService;
|
PathHistoryService? _pathHistoryService;
|
||||||
@@ -113,6 +126,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
BackgroundService? _backgroundService;
|
BackgroundService? _backgroundService;
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
BleDebugLogService? _bleDebugLogService;
|
BleDebugLogService? _bleDebugLogService;
|
||||||
|
AppDebugLogService? _appDebugLogService;
|
||||||
final ChannelMessageStore _channelMessageStore = ChannelMessageStore();
|
final ChannelMessageStore _channelMessageStore = ChannelMessageStore();
|
||||||
final MessageStore _messageStore = MessageStore();
|
final MessageStore _messageStore = MessageStore();
|
||||||
final ChannelOrderStore _channelOrderStore = ChannelOrderStore();
|
final ChannelOrderStore _channelOrderStore = ChannelOrderStore();
|
||||||
@@ -126,6 +140,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final Set<String> _knownContactKeys = {};
|
final Set<String> _knownContactKeys = {};
|
||||||
final Map<String, int> _contactLastReadMs = {};
|
final Map<String, int> _contactLastReadMs = {};
|
||||||
final Map<int, int> _channelLastReadMs = {};
|
final Map<int, int> _channelLastReadMs = {};
|
||||||
|
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
|
||||||
String? _activeContactKey;
|
String? _activeContactKey;
|
||||||
int? _activeChannelIndex;
|
int? _activeChannelIndex;
|
||||||
List<int> _channelOrder = [];
|
List<int> _channelOrder = [];
|
||||||
@@ -178,6 +193,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
int get maxContacts => _maxContacts;
|
int get maxContacts => _maxContacts;
|
||||||
int get maxChannels => _maxChannels;
|
int get maxChannels => _maxChannels;
|
||||||
bool get isSyncingQueuedMessages => _isSyncingQueuedMessages;
|
bool get isSyncingQueuedMessages => _isSyncingQueuedMessages;
|
||||||
|
bool get isSyncingChannels => _isSyncingChannels;
|
||||||
|
int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0
|
||||||
|
? ((_nextChannelIndexToRequest / _totalChannelsToRequest) * 100).round()
|
||||||
|
: 0;
|
||||||
int? get batteryPercent => _batteryMillivolts == null
|
int? get batteryPercent => _batteryMillivolts == null
|
||||||
? null
|
? null
|
||||||
: _estimateBatteryPercent(
|
: _estimateBatteryPercent(
|
||||||
@@ -459,12 +478,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
required PathHistoryService pathHistoryService,
|
required PathHistoryService pathHistoryService,
|
||||||
AppSettingsService? appSettingsService,
|
AppSettingsService? appSettingsService,
|
||||||
BleDebugLogService? bleDebugLogService,
|
BleDebugLogService? bleDebugLogService,
|
||||||
|
AppDebugLogService? appDebugLogService,
|
||||||
BackgroundService? backgroundService,
|
BackgroundService? backgroundService,
|
||||||
}) {
|
}) {
|
||||||
_retryService = retryService;
|
_retryService = retryService;
|
||||||
_pathHistoryService = pathHistoryService;
|
_pathHistoryService = pathHistoryService;
|
||||||
_appSettingsService = appSettingsService;
|
_appSettingsService = appSettingsService;
|
||||||
_bleDebugLogService = bleDebugLogService;
|
_bleDebugLogService = bleDebugLogService;
|
||||||
|
_appDebugLogService = appDebugLogService;
|
||||||
_backgroundService = backgroundService;
|
_backgroundService = backgroundService;
|
||||||
|
|
||||||
// Initialize notification service
|
// Initialize notification service
|
||||||
@@ -480,7 +501,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
setContactPathCallback: setContactPath,
|
setContactPathCallback: setContactPath,
|
||||||
calculateTimeoutCallback: (pathLength, messageBytes) =>
|
calculateTimeoutCallback: (pathLength, messageBytes) =>
|
||||||
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
|
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
|
||||||
|
getSelfPublicKeyCallback: () => _selfPublicKey,
|
||||||
|
prepareContactOutboundTextCallback: prepareContactOutboundText,
|
||||||
appSettingsService: appSettingsService,
|
appSettingsService: appSettingsService,
|
||||||
|
debugLogService: _appDebugLogService,
|
||||||
recordPathResultCallback: _recordPathResult,
|
recordPathResultCallback: _recordPathResult,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -510,7 +534,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
int timestampSeconds,
|
int timestampSeconds,
|
||||||
) async {
|
) async {
|
||||||
if (!isConnected || text.isEmpty) return;
|
if (!isConnected || text.isEmpty) return;
|
||||||
final outboundText = _prepareContactOutboundText(contact, text);
|
final outboundText = prepareContactOutboundText(contact, text);
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
buildSendTextMsgFrame(
|
buildSendTextMsgFrame(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
@@ -811,6 +835,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_queueSyncTimeout?.cancel();
|
_queueSyncTimeout?.cancel();
|
||||||
_queueSyncTimeout = null;
|
_queueSyncTimeout = null;
|
||||||
_queueSyncRetries = 0;
|
_queueSyncRetries = 0;
|
||||||
|
_channelSyncTimeout?.cancel();
|
||||||
|
_channelSyncTimeout = null;
|
||||||
|
_channelSyncRetries = 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.
|
||||||
@@ -840,8 +867,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_queuedMessageSyncInFlight = false;
|
_queuedMessageSyncInFlight = false;
|
||||||
_didInitialQueueSync = false;
|
_didInitialQueueSync = false;
|
||||||
_pendingQueueSync = false;
|
_pendingQueueSync = false;
|
||||||
_pendingQueueSync = false;
|
_isSyncingChannels = false;
|
||||||
_didInitialQueueSync = false;
|
_channelSyncInFlight = false;
|
||||||
|
|
||||||
_setState(MeshCoreConnectionState.disconnected);
|
_setState(MeshCoreConnectionState.disconnected);
|
||||||
if (!manual) {
|
if (!manual) {
|
||||||
@@ -988,7 +1015,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
_addMessage(contact.publicKeyHex, message);
|
_addMessage(contact.publicKeyHex, message);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
final outboundText = _prepareContactOutboundText(contact, text);
|
final outboundText = prepareContactOutboundText(contact, text);
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
buildSendTextMsgFrame(
|
buildSendTextMsgFrame(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
@@ -1021,9 +1048,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
int? pathLen,
|
int? pathLen,
|
||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
}) async {
|
}) async {
|
||||||
|
appLogger.info('setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}', tag: 'Connector');
|
||||||
|
|
||||||
// Find contact in list
|
// Find contact in list
|
||||||
final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
||||||
if (index == -1) return;
|
if (index == -1) {
|
||||||
|
appLogger.warn('setPathOverride: Contact not found in list: ${contact.name}', tag: 'Connector');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.info('Found contact at index $index. Current override: ${_contacts[index].pathOverride}', tag: 'Connector');
|
||||||
|
|
||||||
// Update contact with new path override
|
// Update contact with new path override
|
||||||
_contacts[index] = _contacts[index].copyWith(
|
_contacts[index] = _contacts[index].copyWith(
|
||||||
@@ -1032,18 +1066,126 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
clearPathOverride: pathLen == null, // Clear if pathLen is null
|
clearPathOverride: pathLen == null, // Clear if pathLen is null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
appLogger.info('Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}', tag: 'Connector');
|
||||||
|
|
||||||
// Save to storage
|
// Save to storage
|
||||||
await _contactStore.saveContacts(_contacts);
|
await _contactStore.saveContacts(_contacts);
|
||||||
|
appLogger.info('Saved contacts to storage', tag: 'Connector');
|
||||||
|
|
||||||
// If setting a specific path (not flood, not auto), also sync with device
|
// If setting a specific path (not flood, not auto), also sync with device
|
||||||
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
|
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
|
||||||
|
appLogger.info('Sending path to device...', tag: 'Connector');
|
||||||
await setContactPath(contact, pathBytes, pathLen);
|
await setContactPath(contact, pathBytes, pathLen);
|
||||||
|
appLogger.info('Path sent to device', tag: 'Connector');
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}');
|
debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<PathSelection> preparePathForContactSend(Contact contact) async {
|
||||||
|
PathSelection? autoSelection;
|
||||||
|
final autoRotationEnabled =
|
||||||
|
_appSettingsService?.settings.autoRouteRotationEnabled == true;
|
||||||
|
if (autoRotationEnabled && contact.pathOverride == null) {
|
||||||
|
autoSelection = _pathHistoryService?.getNextAutoPathSelection(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
);
|
||||||
|
if (autoSelection != null) {
|
||||||
|
_pathHistoryService?.recordPathAttempt(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
autoSelection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
|
||||||
|
final pathLength = _resolveOutgoingPathLength(contact, autoSelection) ?? -1;
|
||||||
|
|
||||||
|
if (pathLength < 0) {
|
||||||
|
await clearContactPath(contact);
|
||||||
|
} else {
|
||||||
|
await setContactPath(contact, pathBytes, pathLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _selectionFromPath(pathLength, pathBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
void trackRepeaterAck({
|
||||||
|
required Contact contact,
|
||||||
|
required PathSelection selection,
|
||||||
|
required String text,
|
||||||
|
required int timestampSeconds,
|
||||||
|
int attempt = 0,
|
||||||
|
}) {
|
||||||
|
final selfKey = _selfPublicKey;
|
||||||
|
if (selfKey == null) return;
|
||||||
|
// Use transformed text to match device's ACK hash computation
|
||||||
|
final outboundText = prepareContactOutboundText(contact, text);
|
||||||
|
final ackHash = MessageRetryService.computeExpectedAckHash(
|
||||||
|
timestampSeconds,
|
||||||
|
attempt,
|
||||||
|
outboundText,
|
||||||
|
selfKey,
|
||||||
|
);
|
||||||
|
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
final messageBytes = utf8.encode(outboundText).length;
|
||||||
|
_pendingRepeaterAcks[ackHashHex]?.timeout?.cancel();
|
||||||
|
_pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext(
|
||||||
|
contactKeyHex: contact.publicKeyHex,
|
||||||
|
selection: selection,
|
||||||
|
pathLength: selection.useFlood ? -1 : selection.hopCount,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordRepeaterPathResult(
|
||||||
|
Contact contact,
|
||||||
|
PathSelection selection,
|
||||||
|
bool success,
|
||||||
|
int? tripTimeMs,
|
||||||
|
) {
|
||||||
|
_recordPathResult(contact.publicKeyHex, selection, success, tripTimeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> verifyContactPathOnDevice(
|
||||||
|
Contact contact,
|
||||||
|
Uint8List expectedPath, {
|
||||||
|
Duration timeout = const Duration(seconds: 3),
|
||||||
|
}) async {
|
||||||
|
if (!isConnected) return false;
|
||||||
|
|
||||||
|
final expectedLength = expectedPath.length;
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
|
||||||
|
void finish(bool result) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final subscription = receivedFrames.listen((frame) {
|
||||||
|
if (frame.isEmpty || frame[0] != respCodeContact) return;
|
||||||
|
final updated = Contact.fromFrame(frame);
|
||||||
|
if (updated == null) return;
|
||||||
|
if (updated.publicKeyHex != contact.publicKeyHex) return;
|
||||||
|
final matchesLength = updated.pathLength == expectedLength;
|
||||||
|
final matchesBytes = _pathsEqual(updated.path, expectedPath);
|
||||||
|
if (matchesLength && matchesBytes) {
|
||||||
|
finish(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final timer = Timer(timeout, () => finish(false));
|
||||||
|
try {
|
||||||
|
await getContactByKey(contact.publicKey);
|
||||||
|
return await completer.future;
|
||||||
|
} finally {
|
||||||
|
await subscription.cancel();
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -1246,55 +1388,125 @@ 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;
|
||||||
|
if (_isSyncingChannels) {
|
||||||
|
debugPrint('[ChannelSync] Already syncing channels, ignoring request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_isLoadingChannels = true;
|
_isLoadingChannels = true;
|
||||||
final previousChannels = List<Channel>.from(_channels);
|
_isSyncingChannels = true;
|
||||||
|
_previousChannelsCache = List<Channel>.from(_channels);
|
||||||
_channels.clear();
|
_channels.clear();
|
||||||
_expectedChannelIndices.clear();
|
_nextChannelIndexToRequest = 0;
|
||||||
|
_totalChannelsToRequest = maxChannels ?? _maxChannels;
|
||||||
|
_channelSyncRetries = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Request each channel index (send all requests in parallel)
|
debugPrint('[ChannelSync] Starting sync for $_totalChannelsToRequest channels');
|
||||||
final channelCount = maxChannels ?? _maxChannels;
|
|
||||||
for (int i = 0; i < channelCount; i++) {
|
// Start sequential sync
|
||||||
_expectedChannelIndices.add(i);
|
await _requestNextChannel();
|
||||||
sendFrame(buildGetChannelFrame(i)); // No await - send all at once
|
}
|
||||||
|
|
||||||
|
Future<void> _requestNextChannel() async {
|
||||||
|
if (!isConnected) {
|
||||||
|
_cleanupChannelSync(completed: false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for responses with timeout
|
if (_channelSyncInFlight) return;
|
||||||
final stopwatch = Stopwatch()..start();
|
|
||||||
const maxWaitTime = Duration(seconds: 5);
|
|
||||||
const checkInterval = Duration(milliseconds: 100);
|
|
||||||
|
|
||||||
while (_expectedChannelIndices.isNotEmpty && stopwatch.elapsed < maxWaitTime) {
|
// Check if we've requested all channels
|
||||||
await Future.delayed(checkInterval);
|
if (_nextChannelIndexToRequest >= _totalChannelsToRequest) {
|
||||||
|
_completeChannelSync();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stopwatch.stop();
|
_channelSyncInFlight = true;
|
||||||
|
final channelIndex = _nextChannelIndexToRequest;
|
||||||
|
|
||||||
// If timeout expired and we're still missing channels, restore them from previous load
|
// Cancel any existing timeout
|
||||||
if (_expectedChannelIndices.isNotEmpty) {
|
_channelSyncTimeout?.cancel();
|
||||||
debugPrint('Channel loading timeout - missing ${_expectedChannelIndices.length} channels, restoring from cache');
|
|
||||||
for (final prevChannel in previousChannels) {
|
// Set up timeout for this channel request
|
||||||
if (_expectedChannelIndices.contains(prevChannel.index) &&
|
_channelSyncTimeout = Timer(
|
||||||
!_channels.any((c) => c.index == prevChannel.index)) {
|
Duration(milliseconds: _channelSyncTimeoutMs),
|
||||||
_channels.add(prevChannel);
|
() => _handleChannelSyncTimeout(channelIndex),
|
||||||
debugPrint('Restored channel ${prevChannel.index} (${prevChannel.name}) from cache');
|
);
|
||||||
|
|
||||||
|
debugPrint('[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendFrame(buildGetChannelFrame(channelIndex));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ChannelSync] Error sending channel request: $e');
|
||||||
|
_channelSyncInFlight = false;
|
||||||
|
_cleanupChannelSync(completed: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleChannelSyncTimeout(int channelIndex) {
|
||||||
|
debugPrint('[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
|
||||||
|
|
||||||
|
if (_channelSyncRetries < _maxChannelSyncRetries) {
|
||||||
|
// Retry the same channel
|
||||||
|
_channelSyncRetries++;
|
||||||
|
_channelSyncInFlight = false;
|
||||||
|
unawaited(_requestNextChannel());
|
||||||
|
} else {
|
||||||
|
// Max retries reached for this channel, restore from cache and move to next
|
||||||
|
debugPrint('[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore');
|
||||||
|
|
||||||
|
// Try to restore this channel from cache
|
||||||
|
try {
|
||||||
|
final cachedChannel = _previousChannelsCache.firstWhere(
|
||||||
|
(c) => c.index == channelIndex
|
||||||
|
);
|
||||||
|
if (!cachedChannel.isEmpty) {
|
||||||
|
_channels.add(cachedChannel);
|
||||||
|
debugPrint('[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// No cached channel found, that's okay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move to next channel
|
||||||
|
_nextChannelIndexToRequest++;
|
||||||
|
_channelSyncRetries = 0;
|
||||||
|
_channelSyncInFlight = false;
|
||||||
|
unawaited(_requestNextChannel());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('Channel loading completed: received ${_channels.length}/$channelCount channels in ${stopwatch.elapsedMilliseconds}ms');
|
void _completeChannelSync() {
|
||||||
|
_channelSyncTimeout?.cancel();
|
||||||
|
|
||||||
_isLoadingChannels = false;
|
debugPrint('[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels');
|
||||||
_expectedChannelIndices.clear();
|
|
||||||
|
_cleanupChannelSync(completed: true);
|
||||||
|
|
||||||
|
// Apply ordering and notify UI
|
||||||
_applyChannelOrder();
|
_applyChannelOrder();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _cleanupChannelSync({required bool completed}) {
|
||||||
|
_isSyncingChannels = false;
|
||||||
|
_channelSyncInFlight = false;
|
||||||
|
_isLoadingChannels = false;
|
||||||
|
_channelSyncTimeout?.cancel();
|
||||||
|
_channelSyncRetries = 0;
|
||||||
|
_nextChannelIndexToRequest = 0;
|
||||||
|
_totalChannelsToRequest = 0;
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
_previousChannelsCache.clear();
|
||||||
|
}
|
||||||
|
// Keep cache on failure/disconnection for future attempts
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setChannel(int index, String name, Uint8List psk) async {
|
Future<void> setChannel(int index, String name, Uint8List psk) async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
@@ -1588,14 +1800,20 @@ 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;
|
||||||
|
|
||||||
|
appLogger.info('Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', tag: 'Connector');
|
||||||
|
|
||||||
// CRITICAL: Preserve user's path override when contact is refreshed from device
|
// 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
|
pathOverride: existing.pathOverride, // Preserve user's path choice
|
||||||
pathOverrideBytes: existing.pathOverrideBytes,
|
pathOverrideBytes: existing.pathOverrideBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
appLogger.info('After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', tag: 'Connector');
|
||||||
} else {
|
} else {
|
||||||
_contacts.add(contact);
|
_contacts.add(contact);
|
||||||
|
appLogger.info('Added new contact ${contact.name}: pathLen=${contact.pathLength}', tag: 'Connector');
|
||||||
}
|
}
|
||||||
_knownContactKeys.add(contact.publicKeyHex);
|
_knownContactKeys.add(contact.publicKeyHex);
|
||||||
_loadMessagesForContact(contact.publicKeyHex);
|
_loadMessagesForContact(contact.publicKeyHex);
|
||||||
@@ -1882,7 +2100,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prepareContactOutboundText(Contact contact, String text) {
|
/// Prepares contact outbound text by applying SMAZ encoding if enabled.
|
||||||
|
/// This should be used to transform text before computing ACK hashes.
|
||||||
|
String prepareContactOutboundText(Contact contact, String text) {
|
||||||
final trimmed = text.trim();
|
final trimmed = text.trim();
|
||||||
final isStructuredPayload =
|
final isStructuredPayload =
|
||||||
trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|');
|
trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|');
|
||||||
@@ -2029,6 +2249,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_handleRepeaterCommandSent(ackHash, timeoutMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_retryService != null) {
|
if (_retryService != null) {
|
||||||
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
|
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
|
||||||
}
|
}
|
||||||
@@ -2058,6 +2282,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
// CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages
|
// CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages
|
||||||
|
|
||||||
|
if (_handleRepeaterCommandAck(ackHash, tripTimeMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle ACK in retry service
|
// Handle ACK in retry service
|
||||||
if (_retryService != null) {
|
if (_retryService != null) {
|
||||||
_retryService!.handleAckReceived(ackHash, tripTimeMs);
|
_retryService!.handleAckReceived(ackHash, tripTimeMs);
|
||||||
@@ -2076,23 +2304,84 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) {
|
||||||
|
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
final entry = _pendingRepeaterAcks[ackHashHex];
|
||||||
|
if (entry == null) return false;
|
||||||
|
|
||||||
|
entry.timeout?.cancel();
|
||||||
|
final effectiveTimeoutMs = timeoutMs > 0
|
||||||
|
? timeoutMs
|
||||||
|
: calculateTimeout(
|
||||||
|
pathLength: entry.pathLength,
|
||||||
|
messageBytes: entry.messageBytes,
|
||||||
|
);
|
||||||
|
entry.timeout = Timer(Duration(milliseconds: effectiveTimeoutMs), () {
|
||||||
|
_recordPathResult(entry.contactKeyHex, entry.selection, false, null);
|
||||||
|
_pendingRepeaterAcks.remove(ackHashHex);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) {
|
||||||
|
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
final entry = _pendingRepeaterAcks.remove(ackHashHex);
|
||||||
|
if (entry == null) return false;
|
||||||
|
entry.timeout?.cancel();
|
||||||
|
_recordPathResult(entry.contactKeyHex, entry.selection, true, tripTimeMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void _handleChannelInfo(Uint8List frame) {
|
void _handleChannelInfo(Uint8List frame) {
|
||||||
final channel = Channel.fromFrame(frame);
|
final channel = Channel.fromFrame(frame);
|
||||||
if (channel != null) {
|
if (channel == null) return;
|
||||||
// Mark this channel index as received
|
|
||||||
_expectedChannelIndices.remove(channel.index);
|
|
||||||
|
|
||||||
// Only add non-empty channels to the list
|
debugPrint('[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}');
|
||||||
if (!channel.isEmpty) {
|
|
||||||
|
// If we're syncing and this is the channel we're waiting for
|
||||||
|
if (_isSyncingChannels && _channelSyncInFlight) {
|
||||||
|
if (channel.index == _nextChannelIndexToRequest) {
|
||||||
|
// Expected channel arrived
|
||||||
|
_channelSyncTimeout?.cancel();
|
||||||
|
_channelSyncInFlight = false;
|
||||||
|
_channelSyncRetries = 0; // Reset retry counter on success
|
||||||
|
|
||||||
|
// Only add non-empty channels
|
||||||
|
if (!channel.isEmpty) {
|
||||||
|
_channels.add(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next channel
|
||||||
|
_nextChannelIndexToRequest++;
|
||||||
|
unawaited(_requestNextChannel());
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Received a channel but not the one we're waiting for
|
||||||
|
// This can happen if device sends unsolicited updates
|
||||||
|
debugPrint('[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest');
|
||||||
|
// Add it anyway but don't advance sync
|
||||||
|
if (!channel.isEmpty && !_channels.any((c) => c.index == channel.index)) {
|
||||||
|
_channels.add(channel);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not syncing, or received unsolicited update - handle normally
|
||||||
|
if (!channel.isEmpty) {
|
||||||
|
// Update or add channel
|
||||||
|
final existingIndex = _channels.indexWhere((c) => c.index == channel.index);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
_channels[existingIndex] = channel;
|
||||||
|
} else {
|
||||||
_channels.add(channel);
|
_channels.add(channel);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only sort and notify if we're not currently loading channels
|
// Only notify if not in loading state
|
||||||
// This prevents the list from jumping around as channels arrive during refresh
|
if (!_isLoadingChannels) {
|
||||||
if (!_isLoadingChannels) {
|
_applyChannelOrder();
|
||||||
_applyChannelOrder();
|
notifyListeners();
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2370,6 +2659,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return contact.pathLength;
|
return contact.pathLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PathSelection _selectionFromPath(int pathLength, Uint8List pathBytes) {
|
||||||
|
if (pathLength < 0) {
|
||||||
|
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||||
|
}
|
||||||
|
return PathSelection(
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
hopCount: pathLength,
|
||||||
|
useFlood: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool _addChannelMessage(int channelIndex, ChannelMessage message) {
|
bool _addChannelMessage(int channelIndex, ChannelMessage message) {
|
||||||
_channelMessages.putIfAbsent(channelIndex, () => []);
|
_channelMessages.putIfAbsent(channelIndex, () => []);
|
||||||
final messages = _channelMessages[channelIndex]!;
|
final messages = _channelMessages[channelIndex]!;
|
||||||
@@ -2599,6 +2899,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
// Disable wake lock when connection is lost
|
// Disable wake lock when connection is lost
|
||||||
WakelockPlus.disable();
|
WakelockPlus.disable();
|
||||||
|
|
||||||
|
for (final entry in _pendingRepeaterAcks.values) {
|
||||||
|
entry.timeout?.cancel();
|
||||||
|
}
|
||||||
|
_pendingRepeaterAcks.clear();
|
||||||
|
|
||||||
_notifySubscription?.cancel();
|
_notifySubscription?.cancel();
|
||||||
_notifySubscription = null;
|
_notifySubscription = null;
|
||||||
_connectionSubscription?.cancel();
|
_connectionSubscription?.cancel();
|
||||||
@@ -2613,6 +2918,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_maxChannels = _defaultMaxChannels;
|
_maxChannels = _defaultMaxChannels;
|
||||||
_isSyncingQueuedMessages = false;
|
_isSyncingQueuedMessages = false;
|
||||||
_queuedMessageSyncInFlight = false;
|
_queuedMessageSyncInFlight = false;
|
||||||
|
_isSyncingChannels = false;
|
||||||
|
_channelSyncInFlight = false;
|
||||||
|
|
||||||
_setState(MeshCoreConnectionState.disconnected);
|
_setState(MeshCoreConnectionState.disconnected);
|
||||||
_scheduleReconnect();
|
_scheduleReconnect();
|
||||||
@@ -2684,4 +2991,17 @@ class _ParsedText {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RepeaterAckContext {
|
||||||
|
final String contactKeyHex;
|
||||||
|
final PathSelection selection;
|
||||||
|
final int pathLength;
|
||||||
|
final int messageBytes;
|
||||||
|
Timer? timeout;
|
||||||
|
|
||||||
|
_RepeaterAckContext({
|
||||||
|
required this.contactKeyHex,
|
||||||
|
required this.selection,
|
||||||
|
required this.pathLength,
|
||||||
|
required this.messageBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -592,16 +592,18 @@ Uint8List buildSendCliCommandFrame(
|
|||||||
Uint8List repeaterPubKey,
|
Uint8List repeaterPubKey,
|
||||||
String command, {
|
String command, {
|
||||||
int attempt = 0,
|
int attempt = 0,
|
||||||
|
int? timestampSeconds,
|
||||||
}) {
|
}) {
|
||||||
final textBytes = utf8.encode(command);
|
final textBytes = utf8.encode(command);
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||||
const prefixSize = 6;
|
const prefixSize = 6;
|
||||||
|
final safeAttempt = attempt.clamp(0, 3);
|
||||||
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
|
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
|
|
||||||
frame[offset++] = cmdSendTxtMsg;
|
frame[offset++] = cmdSendTxtMsg;
|
||||||
frame[offset++] = txtTypeCliData;
|
frame[offset++] = txtTypeCliData;
|
||||||
frame[offset++] = attempt & 0xFF;
|
frame[offset++] = safeAttempt;
|
||||||
writeUint32LE(frame, offset, timestamp);
|
writeUint32LE(frame, offset, timestamp);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import 'services/path_history_service.dart';
|
|||||||
import 'services/app_settings_service.dart';
|
import 'services/app_settings_service.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/ble_debug_log_service.dart';
|
import 'services/ble_debug_log_service.dart';
|
||||||
|
import 'services/app_debug_log_service.dart';
|
||||||
import 'services/background_service.dart';
|
import 'services/background_service.dart';
|
||||||
import 'services/map_tile_cache_service.dart';
|
import 'services/map_tile_cache_service.dart';
|
||||||
import 'storage/prefs_manager.dart';
|
import 'storage/prefs_manager.dart';
|
||||||
|
import 'utils/app_logger.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -26,12 +28,19 @@ void main() async {
|
|||||||
final retryService = MessageRetryService(storage);
|
final retryService = MessageRetryService(storage);
|
||||||
final appSettingsService = AppSettingsService();
|
final appSettingsService = AppSettingsService();
|
||||||
final bleDebugLogService = BleDebugLogService();
|
final bleDebugLogService = BleDebugLogService();
|
||||||
|
final appDebugLogService = AppDebugLogService();
|
||||||
final backgroundService = BackgroundService();
|
final backgroundService = BackgroundService();
|
||||||
final mapTileCacheService = MapTileCacheService();
|
final mapTileCacheService = MapTileCacheService();
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await appSettingsService.loadSettings();
|
await appSettingsService.loadSettings();
|
||||||
|
|
||||||
|
// Initialize app logger
|
||||||
|
appLogger.initialize(
|
||||||
|
appDebugLogService,
|
||||||
|
enabled: appSettingsService.settings.appDebugLogEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize notification service
|
// Initialize notification service
|
||||||
final notificationService = NotificationService();
|
final notificationService = NotificationService();
|
||||||
await notificationService.initialize();
|
await notificationService.initialize();
|
||||||
@@ -43,6 +52,7 @@ void main() async {
|
|||||||
pathHistoryService: pathHistoryService,
|
pathHistoryService: pathHistoryService,
|
||||||
appSettingsService: appSettingsService,
|
appSettingsService: appSettingsService,
|
||||||
bleDebugLogService: bleDebugLogService,
|
bleDebugLogService: bleDebugLogService,
|
||||||
|
appDebugLogService: appDebugLogService,
|
||||||
backgroundService: backgroundService,
|
backgroundService: backgroundService,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,6 +70,7 @@ void main() async {
|
|||||||
storage: storage,
|
storage: storage,
|
||||||
appSettingsService: appSettingsService,
|
appSettingsService: appSettingsService,
|
||||||
bleDebugLogService: bleDebugLogService,
|
bleDebugLogService: bleDebugLogService,
|
||||||
|
appDebugLogService: appDebugLogService,
|
||||||
mapTileCacheService: mapTileCacheService,
|
mapTileCacheService: mapTileCacheService,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -71,6 +82,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
final StorageService storage;
|
final StorageService storage;
|
||||||
final AppSettingsService appSettingsService;
|
final AppSettingsService appSettingsService;
|
||||||
final BleDebugLogService bleDebugLogService;
|
final BleDebugLogService bleDebugLogService;
|
||||||
|
final AppDebugLogService appDebugLogService;
|
||||||
final MapTileCacheService mapTileCacheService;
|
final MapTileCacheService mapTileCacheService;
|
||||||
|
|
||||||
const MeshCoreApp({
|
const MeshCoreApp({
|
||||||
@@ -81,6 +93,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
required this.storage,
|
required this.storage,
|
||||||
required this.appSettingsService,
|
required this.appSettingsService,
|
||||||
required this.bleDebugLogService,
|
required this.bleDebugLogService,
|
||||||
|
required this.appDebugLogService,
|
||||||
required this.mapTileCacheService,
|
required this.mapTileCacheService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +106,7 @@ class MeshCoreApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider.value(value: pathHistoryService),
|
ChangeNotifierProvider.value(value: pathHistoryService),
|
||||||
ChangeNotifierProvider.value(value: appSettingsService),
|
ChangeNotifierProvider.value(value: appSettingsService),
|
||||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||||
|
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||||
Provider.value(value: storage),
|
Provider.value(value: storage),
|
||||||
Provider.value(value: mapTileCacheService),
|
Provider.value(value: mapTileCacheService),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AppSettings {
|
|||||||
final bool notifyOnNewAdvert;
|
final bool notifyOnNewAdvert;
|
||||||
final bool autoRouteRotationEnabled;
|
final bool autoRouteRotationEnabled;
|
||||||
final String themeMode;
|
final String themeMode;
|
||||||
|
final bool appDebugLogEnabled;
|
||||||
final Map<String, String> batteryChemistryByDeviceId;
|
final Map<String, String> batteryChemistryByDeviceId;
|
||||||
|
|
||||||
AppSettings({
|
AppSettings({
|
||||||
@@ -38,6 +39,7 @@ class AppSettings {
|
|||||||
this.notifyOnNewAdvert = true,
|
this.notifyOnNewAdvert = true,
|
||||||
this.autoRouteRotationEnabled = false,
|
this.autoRouteRotationEnabled = false,
|
||||||
this.themeMode = 'system',
|
this.themeMode = 'system',
|
||||||
|
this.appDebugLogEnabled = false,
|
||||||
Map<String, String>? batteryChemistryByDeviceId,
|
Map<String, String>? batteryChemistryByDeviceId,
|
||||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ class AppSettings {
|
|||||||
'notify_on_new_advert': notifyOnNewAdvert,
|
'notify_on_new_advert': notifyOnNewAdvert,
|
||||||
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
||||||
'theme_mode': themeMode,
|
'theme_mode': themeMode,
|
||||||
|
'app_debug_log_enabled': appDebugLogEnabled,
|
||||||
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,7 @@ class AppSettings {
|
|||||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||||
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||||
|
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
||||||
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
|
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||||
) ??
|
) ??
|
||||||
@@ -111,6 +115,7 @@ class AppSettings {
|
|||||||
bool? notifyOnNewAdvert,
|
bool? notifyOnNewAdvert,
|
||||||
bool? autoRouteRotationEnabled,
|
bool? autoRouteRotationEnabled,
|
||||||
String? themeMode,
|
String? themeMode,
|
||||||
|
bool? appDebugLogEnabled,
|
||||||
Map<String, String>? batteryChemistryByDeviceId,
|
Map<String, String>? batteryChemistryByDeviceId,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
@@ -133,6 +138,7 @@ class AppSettings {
|
|||||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||||
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||||
themeMode: themeMode ?? this.themeMode,
|
themeMode: themeMode ?? this.themeMode,
|
||||||
|
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
|
||||||
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-4
@@ -46,6 +46,11 @@ class Contact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String get pathLabel {
|
String get pathLabel {
|
||||||
|
if (pathOverride != null) {
|
||||||
|
if (pathOverride! < 0) return 'Flood (forced)';
|
||||||
|
if (pathOverride == 0) return 'Direct (forced)';
|
||||||
|
return '$pathOverride hops (forced)';
|
||||||
|
}
|
||||||
if (pathLength < 0) return 'Flood';
|
if (pathLength < 0) return 'Flood';
|
||||||
if (pathLength == 0) return 'Direct';
|
if (pathLength == 0) return 'Direct';
|
||||||
return '$pathLength hops';
|
return '$pathLength hops';
|
||||||
@@ -83,12 +88,13 @@ class Contact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String get pathIdList {
|
String get pathIdList {
|
||||||
if (path.isEmpty) return '';
|
final pathBytes = _pathBytesForDisplay;
|
||||||
|
if (pathBytes.isEmpty) return '';
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
final groupSize = pathHashSize;
|
final groupSize = pathHashSize;
|
||||||
for (int i = 0; i < path.length; i += groupSize) {
|
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
||||||
final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length;
|
final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length;
|
||||||
final chunk = path.sublist(i, end);
|
final chunk = pathBytes.sublist(i, end);
|
||||||
parts.add(
|
parts.add(
|
||||||
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
|
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
|
||||||
);
|
);
|
||||||
@@ -96,6 +102,14 @@ class Contact {
|
|||||||
return parts.join(',');
|
return parts.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List get _pathBytesForDisplay {
|
||||||
|
if (pathOverride != null) {
|
||||||
|
if (pathOverride! < 0) return Uint8List(0);
|
||||||
|
return pathOverrideBytes ?? Uint8List(0);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
static Contact? fromFrame(Uint8List data) {
|
static Contact? fromFrame(Uint8List data) {
|
||||||
if (data.length < contactFrameSize) return null;
|
if (data.length < contactFrameSize) return null;
|
||||||
if (data[0] != respCodeContact) return null;
|
if (data[0] != respCodeContact) return null;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../services/app_debug_log_service.dart';
|
||||||
|
|
||||||
|
class AppDebugLogScreen extends StatelessWidget {
|
||||||
|
const AppDebugLogScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<AppDebugLogService>(
|
||||||
|
builder: (context, logService, _) {
|
||||||
|
final entries = logService.entries.reversed.toList();
|
||||||
|
final hasEntries = entries.isNotEmpty;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('App Debug Log'),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy log',
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
onPressed: hasEntries
|
||||||
|
? () async {
|
||||||
|
final text = entries
|
||||||
|
.map((entry) =>
|
||||||
|
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
|
||||||
|
.join('\n');
|
||||||
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Debug log copied')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Clear log',
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: hasEntries
|
||||||
|
? () {
|
||||||
|
logService.clear();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: hasEntries
|
||||||
|
? ListView.separated(
|
||||||
|
itemCount: entries.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = entries[index];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: _buildLevelIcon(entry.level),
|
||||||
|
title: Text(
|
||||||
|
'[${entry.tag}] ${entry.message}',
|
||||||
|
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
entry.formattedTime,
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No debug logs yet',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Enable app debug logging in settings',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLevelIcon(AppDebugLogLevel level) {
|
||||||
|
switch (level) {
|
||||||
|
case AppDebugLogLevel.info:
|
||||||
|
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
|
||||||
|
case AppDebugLogLevel.warning:
|
||||||
|
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
|
||||||
|
case AppDebugLogLevel.error:
|
||||||
|
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ class AppSettingsScreen extends StatelessWidget {
|
|||||||
_buildBatteryCard(context, settingsService, connector),
|
_buildBatteryCard(context, settingsService, connector),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildMapSettingsCard(context, settingsService),
|
_buildMapSettingsCard(context, settingsService),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDebugCard(context, settingsService),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -383,43 +385,31 @@ class AppSettingsScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Theme'),
|
title: const Text('Theme'),
|
||||||
content: Column(
|
content: RadioGroup<String>(
|
||||||
mainAxisSize: MainAxisSize.min,
|
groupValue: settingsService.settings.themeMode,
|
||||||
children: [
|
onChanged: (value) {
|
||||||
RadioListTile<String>(
|
if (value != null) {
|
||||||
title: const Text('System default'),
|
settingsService.setThemeMode(value);
|
||||||
value: 'system',
|
Navigator.pop(context);
|
||||||
groupValue: settingsService.settings.themeMode,
|
}
|
||||||
onChanged: (value) {
|
},
|
||||||
if (value != null) {
|
child: Column(
|
||||||
settingsService.setThemeMode(value);
|
mainAxisSize: MainAxisSize.min,
|
||||||
Navigator.pop(context);
|
children: [
|
||||||
}
|
RadioListTile<String>(
|
||||||
},
|
title: const Text('System default'),
|
||||||
),
|
value: 'system',
|
||||||
RadioListTile<String>(
|
),
|
||||||
title: const Text('Light'),
|
RadioListTile<String>(
|
||||||
value: 'light',
|
title: const Text('Light'),
|
||||||
groupValue: settingsService.settings.themeMode,
|
value: 'light',
|
||||||
onChanged: (value) {
|
),
|
||||||
if (value != null) {
|
RadioListTile<String>(
|
||||||
settingsService.setThemeMode(value);
|
title: const Text('Dark'),
|
||||||
Navigator.pop(context);
|
value: 'dark',
|
||||||
}
|
),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
RadioListTile<String>(
|
|
||||||
title: const Text('Dark'),
|
|
||||||
value: 'dark',
|
|
||||||
groupValue: settingsService.settings.themeMode,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsService.setThemeMode(value);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -447,77 +437,51 @@ class AppSettingsScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Map Time Filter'),
|
title: const Text('Map Time Filter'),
|
||||||
content: Column(
|
content: RadioGroup<double>(
|
||||||
mainAxisSize: MainAxisSize.min,
|
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||||
children: [
|
onChanged: (value) {
|
||||||
const Text('Show nodes discovered within:'),
|
if (value != null) {
|
||||||
const SizedBox(height: 16),
|
settingsService.setMapTimeFilterHours(value);
|
||||||
ListTile(
|
Navigator.pop(context);
|
||||||
title: const Text('All time'),
|
}
|
||||||
leading: Radio<double>(
|
},
|
||||||
value: 0,
|
child: Column(
|
||||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onChanged: (value) {
|
children: [
|
||||||
if (value != null) {
|
const Text('Show nodes discovered within:'),
|
||||||
settingsService.setMapTimeFilterHours(value);
|
const SizedBox(height: 16),
|
||||||
Navigator.pop(context);
|
ListTile(
|
||||||
}
|
title: const Text('All time'),
|
||||||
},
|
leading: Radio<double>(
|
||||||
|
value: 0,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
ListTile(
|
||||||
ListTile(
|
title: const Text('Last hour'),
|
||||||
title: const Text('Last hour'),
|
leading: Radio<double>(
|
||||||
leading: Radio<double>(
|
value: 1,
|
||||||
value: 1,
|
),
|
||||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsService.setMapTimeFilterHours(value);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
ListTile(
|
||||||
ListTile(
|
title: const Text('Last 6 hours'),
|
||||||
title: const Text('Last 6 hours'),
|
leading: Radio<double>(
|
||||||
leading: Radio<double>(
|
value: 6,
|
||||||
value: 6,
|
),
|
||||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsService.setMapTimeFilterHours(value);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
ListTile(
|
||||||
ListTile(
|
title: const Text('Last 24 hours'),
|
||||||
title: const Text('Last 24 hours'),
|
leading: Radio<double>(
|
||||||
leading: Radio<double>(
|
value: 24,
|
||||||
value: 24,
|
),
|
||||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsService.setMapTimeFilterHours(value);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
ListTile(
|
||||||
ListTile(
|
title: const Text('Last week'),
|
||||||
title: const Text('Last week'),
|
leading: Radio<double>(
|
||||||
leading: Radio<double>(
|
value: 168,
|
||||||
value: 168,
|
),
|
||||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsService.setMapTimeFilterHours(value);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -528,4 +492,39 @@ class AppSettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Debug',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.bug_report_outlined),
|
||||||
|
title: const Text('App Debug Logging'),
|
||||||
|
subtitle: const Text('Log app debug messages for troubleshooting'),
|
||||||
|
value: settingsService.settings.appDebugLogEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await settingsService.setAppDebugLogEnabled(value);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(value
|
||||||
|
? 'App debug logging enabled'
|
||||||
|
: 'App debug logging disabled'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -38,11 +39,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.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
|
// Scroll to bottom when opening channel chat - use SchedulerBinding for next frame
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||||
}
|
}
|
||||||
@@ -151,7 +152,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
builder: (context, connector, child) {
|
builder: (context, connector, child) {
|
||||||
final messages = connector.getChannelMessages(widget.channel);
|
final messages = connector.getChannelMessages(widget.channel);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import '../models/channel.dart';
|
|||||||
import '../utils/dialog_utils.dart';
|
import '../utils/dialog_utils.dart';
|
||||||
import '../utils/disconnect_navigation_mixin.dart';
|
import '../utils/disconnect_navigation_mixin.dart';
|
||||||
import '../utils/route_transitions.dart';
|
import '../utils/route_transitions.dart';
|
||||||
|
import '../widgets/battery_indicator.dart';
|
||||||
|
import '../widgets/list_filter_widget.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
import '../widgets/quick_switch_bar.dart';
|
import '../widgets/quick_switch_bar.dart';
|
||||||
import '../widgets/unread_badge.dart';
|
import '../widgets/unread_badge.dart';
|
||||||
@@ -18,6 +20,13 @@ import 'contacts_screen.dart';
|
|||||||
import 'map_screen.dart';
|
import 'map_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
enum ChannelSortOption {
|
||||||
|
manual,
|
||||||
|
name,
|
||||||
|
latestMessages,
|
||||||
|
unread,
|
||||||
|
}
|
||||||
|
|
||||||
class ChannelsScreen extends StatefulWidget {
|
class ChannelsScreen extends StatefulWidget {
|
||||||
final bool hideBackButton;
|
final bool hideBackButton;
|
||||||
|
|
||||||
@@ -32,6 +41,11 @@ class ChannelsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ChannelsScreenState extends State<ChannelsScreen>
|
class _ChannelsScreenState extends State<ChannelsScreen>
|
||||||
with DisconnectNavigationMixin {
|
with DisconnectNavigationMixin {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _searchQuery = '';
|
||||||
|
Timer? _searchDebounce;
|
||||||
|
ChannelSortOption _sortOption = ChannelSortOption.manual;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -40,6 +54,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
@@ -55,10 +76,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
canPop: allowBack,
|
canPop: allowBack,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: BatteryIndicator(connector: connector),
|
||||||
title: const Text('Channels'),
|
title: const Text('Channels'),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
automaticallyImplyLeading: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bluetooth_disabled),
|
||||||
|
tooltip: 'Disconnect',
|
||||||
|
onPressed: () => _disconnect(context),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.tune),
|
icon: const Icon(Icons.tune),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
@@ -67,57 +94,154 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.bluetooth_disabled),
|
|
||||||
tooltip: 'Disconnect',
|
|
||||||
onPressed: () => _disconnect(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: () {
|
body: RefreshIndicator(
|
||||||
if (connector.isLoadingChannels) {
|
onRefresh: () async {
|
||||||
return const Center(child: CircularProgressIndicator());
|
await context.read<MeshCoreConnector>().getChannels();
|
||||||
}
|
},
|
||||||
|
child: () {
|
||||||
|
if (connector.isLoadingChannels) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
final channels = connector.channels;
|
final channels = connector.channels;
|
||||||
|
|
||||||
if (channels.isEmpty) {
|
if (channels.isEmpty) {
|
||||||
return EmptyState(
|
return ListView(
|
||||||
icon: Icons.tag,
|
children: [
|
||||||
title: 'No channels configured',
|
SizedBox(
|
||||||
action: FilledButton.icon(
|
height: MediaQuery.of(context).size.height - 200,
|
||||||
onPressed: () => _addPublicChannel(context, connector),
|
child: EmptyState(
|
||||||
icon: const Icon(Icons.public),
|
icon: Icons.tag,
|
||||||
label: const Text('Add Public Channel'),
|
title: 'No channels configured',
|
||||||
),
|
action: FilledButton.icon(
|
||||||
);
|
onPressed: () => _addPublicChannel(context, connector),
|
||||||
}
|
icon: const Icon(Icons.public),
|
||||||
|
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) {
|
|
||||||
final channel = channels[index];
|
final filteredChannels = _filterAndSortChannels(channels, connector);
|
||||||
return _buildChannelTile(context, connector, channel, index);
|
|
||||||
},
|
return Column(
|
||||||
);
|
children: [
|
||||||
}(),
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search channels...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_searchQuery.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildFilterButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_searchDebounce?.cancel();
|
||||||
|
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value.toLowerCase();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: filteredChannels.isEmpty
|
||||||
|
? ListView(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height - 300,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No channels found',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: (_sortOption == ChannelSortOption.manual && _searchQuery.isEmpty)
|
||||||
|
? ReorderableListView.builder(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 8,
|
||||||
|
bottom: 88,
|
||||||
|
),
|
||||||
|
buildDefaultDragHandles: false,
|
||||||
|
itemCount: filteredChannels.length,
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
final reordered = List<Channel>.from(filteredChannels);
|
||||||
|
final item = reordered.removeAt(oldIndex);
|
||||||
|
reordered.insert(newIndex, item);
|
||||||
|
unawaited(
|
||||||
|
connector.setChannelOrder(
|
||||||
|
reordered.map((c) => c.index).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final channel = filteredChannels[index];
|
||||||
|
return _buildChannelTile(
|
||||||
|
context,
|
||||||
|
connector,
|
||||||
|
channel,
|
||||||
|
showDragHandle: true,
|
||||||
|
dragIndex: index,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 8,
|
||||||
|
bottom: 88,
|
||||||
|
),
|
||||||
|
itemCount: filteredChannels.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final channel = filteredChannels[index];
|
||||||
|
return _buildChannelTile(context, connector, channel);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => _showAddChannelDialog(context),
|
onPressed: () => _showAddChannelDialog(context),
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
@@ -137,7 +261,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
Channel channel,
|
Channel channel,
|
||||||
int index,
|
{
|
||||||
|
bool showDragHandle = false,
|
||||||
|
int? dragIndex,
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
final unreadCount = connector.getUnreadCountForChannel(channel);
|
final unreadCount = connector.getUnreadCountForChannel(channel);
|
||||||
return Card(
|
return Card(
|
||||||
@@ -179,13 +306,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
UnreadBadge(count: unreadCount),
|
UnreadBadge(count: unreadCount),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
ReorderableDelayedDragStartListener(
|
if (showDragHandle && dragIndex != null)
|
||||||
index: index,
|
ReorderableDelayedDragStartListener(
|
||||||
child: Icon(
|
index: dragIndex,
|
||||||
Icons.drag_handle,
|
child: Icon(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
Icons.drag_handle,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -271,6 +399,118 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
await showDisconnectDialog(context, connector);
|
await showDisconnectDialog(context, connector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterButton() {
|
||||||
|
const actionSortManual = 0;
|
||||||
|
const actionSortName = 1;
|
||||||
|
const actionSortLatest = 2;
|
||||||
|
const actionSortUnread = 3;
|
||||||
|
|
||||||
|
return SortFilterMenu(
|
||||||
|
sections: [
|
||||||
|
SortFilterMenuSection(
|
||||||
|
title: 'Sort by',
|
||||||
|
options: [
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: actionSortManual,
|
||||||
|
label: 'Manual',
|
||||||
|
checked: _sortOption == ChannelSortOption.manual,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: actionSortName,
|
||||||
|
label: 'A-Z',
|
||||||
|
checked: _sortOption == ChannelSortOption.name,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: actionSortLatest,
|
||||||
|
label: 'Latest messages',
|
||||||
|
checked: _sortOption == ChannelSortOption.latestMessages,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: actionSortUnread,
|
||||||
|
label: 'Unread',
|
||||||
|
checked: _sortOption == ChannelSortOption.unread,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (action) {
|
||||||
|
setState(() {
|
||||||
|
switch (action) {
|
||||||
|
case actionSortManual:
|
||||||
|
_sortOption = ChannelSortOption.manual;
|
||||||
|
break;
|
||||||
|
case actionSortLatest:
|
||||||
|
_sortOption = ChannelSortOption.latestMessages;
|
||||||
|
break;
|
||||||
|
case actionSortUnread:
|
||||||
|
_sortOption = ChannelSortOption.unread;
|
||||||
|
break;
|
||||||
|
case actionSortName:
|
||||||
|
default:
|
||||||
|
_sortOption = ChannelSortOption.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Channel> _filterAndSortChannels(
|
||||||
|
List<Channel> channels,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
) {
|
||||||
|
var filtered = channels.where((channel) {
|
||||||
|
if (_searchQuery.isEmpty) return true;
|
||||||
|
final label = _normalizeChannelName(channel);
|
||||||
|
return label.toLowerCase().contains(_searchQuery);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
int compareByName(Channel a, Channel b) {
|
||||||
|
final nameA = _normalizeChannelName(a);
|
||||||
|
final nameB = _normalizeChannelName(b);
|
||||||
|
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (_sortOption) {
|
||||||
|
case ChannelSortOption.manual:
|
||||||
|
break;
|
||||||
|
case ChannelSortOption.latestMessages:
|
||||||
|
filtered.sort((a, b) {
|
||||||
|
final aMessages = connector.getChannelMessages(a);
|
||||||
|
final bMessages = connector.getChannelMessages(b);
|
||||||
|
final aLast = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
|
||||||
|
final bLast = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
|
||||||
|
final timeCompare = bLast.compareTo(aLast);
|
||||||
|
if (timeCompare != 0) return timeCompare;
|
||||||
|
return compareByName(a, b);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ChannelSortOption.unread:
|
||||||
|
filtered.sort((a, b) {
|
||||||
|
final aUnread = connector.getUnreadCountForChannel(a);
|
||||||
|
final bUnread = connector.getUnreadCountForChannel(b);
|
||||||
|
final unreadCompare = bUnread.compareTo(aUnread);
|
||||||
|
if (unreadCompare != 0) return unreadCompare;
|
||||||
|
return compareByName(a, b);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ChannelSortOption.name:
|
||||||
|
filtered.sort(compareByName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeChannelName(Channel channel) {
|
||||||
|
if (channel.name.isEmpty) return 'Channel ${channel.index}';
|
||||||
|
final trimmed = channel.name.trim();
|
||||||
|
if (trimmed.startsWith('#') && trimmed.length > 1) {
|
||||||
|
return trimmed.substring(1);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
void _showAddChannelDialog(BuildContext context) {
|
void _showAddChannelDialog(BuildContext context) {
|
||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final nameController = TextEditingController();
|
final nameController = TextEditingController();
|
||||||
|
|||||||
+72
-382
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -19,6 +20,8 @@ import '../utils/emoji_utils.dart';
|
|||||||
import '../widgets/emoji_picker.dart';
|
import '../widgets/emoji_picker.dart';
|
||||||
import '../widgets/gif_message.dart';
|
import '../widgets/gif_message.dart';
|
||||||
import '../widgets/gif_picker.dart';
|
import '../widgets/gif_picker.dart';
|
||||||
|
import '../widgets/path_selection_dialog.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
@@ -36,11 +39,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.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
|
// Scroll to bottom when opening chat use SchedulerBinding for next frame
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||||
}
|
}
|
||||||
@@ -438,13 +441,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
await _notifyPathSet(
|
||||||
|
connector,
|
||||||
|
widget.contact,
|
||||||
|
pathBytes,
|
||||||
|
path.hopCount,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -590,6 +593,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return '${contact.pathLength} hops';
|
return '${contact.pathLength} hops';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyPathSet(
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
Contact contact,
|
||||||
|
Uint8List pathBytes,
|
||||||
|
int hopCount,
|
||||||
|
) async {
|
||||||
|
final verified = connector.isConnected
|
||||||
|
? await connector.verifyContactPathOnDevice(contact, pathBytes)
|
||||||
|
: false;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final status = !connector.isConnected
|
||||||
|
? 'Saved locally. Connect to sync.'
|
||||||
|
: (verified ? 'Device confirmed.' : 'Device not confirmed yet.');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Path set: $hopCount ${hopCount == 1 ? 'hop' : 'hops'} - $status',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showContactInfo(BuildContext context) {
|
void _showContactInfo(BuildContext context) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
||||||
@@ -657,7 +684,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCustomPathDialog(BuildContext context) {
|
Future<void> _showCustomPathDialog(BuildContext context) async {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
|
||||||
final currentContact = _resolveContact(connector);
|
final currentContact = _resolveContact(connector);
|
||||||
@@ -665,385 +692,48 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
connector.getContacts();
|
connector.getContacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
showDialog(
|
final pathForInput = currentContact.pathIdList;
|
||||||
context: context,
|
final currentPathLabel = _currentPathLabel(currentContact);
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.edit_road),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Set Custom Path'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: Consumer<MeshCoreConnector>(
|
|
||||||
builder: (context, connector, _) {
|
|
||||||
final contact = _resolveContact(connector);
|
|
||||||
final pathForInput = contact.pathIdList;
|
|
||||||
final currentPathLabel = _currentPathLabel(contact);
|
|
||||||
|
|
||||||
return Column(
|
// Filter out the current contact from available contacts
|
||||||
mainAxisSize: MainAxisSize.min,
|
final availableContacts = connector.contacts
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
.where((c) => c != widget.contact)
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Current path',
|
|
||||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: connector.isConnected ? connector.getContacts : null,
|
|
||||||
icon: const Icon(Icons.refresh, size: 16),
|
|
||||||
label: const Text('Reload'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
currentPathLabel,
|
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Choose how to set the message path:',
|
|
||||||
style: TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: const CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: Colors.blue,
|
|
||||||
child: Icon(Icons.text_fields, size: 16),
|
|
||||||
),
|
|
||||||
title: const Text('Enter Path Manually', style: TextStyle(fontSize: 14)),
|
|
||||||
subtitle: const Text('Type IDs like: A1B2C3D4,FFEEDDCC', style: TextStyle(fontSize: 11)),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_showManualPathInput(
|
|
||||||
context,
|
|
||||||
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: const CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
child: Icon(Icons.contacts, size: 16),
|
|
||||||
),
|
|
||||||
title: const Text('Select from Contacts', style: TextStyle(fontSize: 14)),
|
|
||||||
subtitle: const Text('Pick repeaters/rooms as hops', style: TextStyle(fontSize: 11)),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_showContactPathPicker(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showManualPathInput(BuildContext context, {String? initialPath}) {
|
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
||||||
final controller = TextEditingController(text: initialPath ?? '');
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Enter Custom Path'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Enter 2-character hex prefixes for each hop, separated by commas.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'Example: A1,F2,3C (each node uses first byte of its public key)',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Path (hex prefixes)',
|
|
||||||
hintText: 'A1,F2,3C',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
|
|
||||||
),
|
|
||||||
textCapitalization: TextCapitalization.characters,
|
|
||||||
maxLength: 191, // 64 hops * 2 chars + 63 commas
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final path = controller.text.trim().toUpperCase();
|
|
||||||
if (path.isEmpty) {
|
|
||||||
if (context.mounted) Navigator.pop(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse comma-separated hex prefixes
|
|
||||||
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
|
||||||
final pathBytesList = <int>[];
|
|
||||||
final invalidPrefixes = <String>[];
|
|
||||||
|
|
||||||
for (final id in pathIds) {
|
|
||||||
if (id.length < 2) {
|
|
||||||
invalidPrefixes.add(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final prefix = id.substring(0, 2);
|
|
||||||
try {
|
|
||||||
final byte = int.parse(prefix, radix: 16);
|
|
||||||
pathBytesList.add(byte);
|
|
||||||
} catch (e) {
|
|
||||||
invalidPrefixes.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
// Show error for invalid prefixes
|
|
||||||
if (invalidPrefixes.isNotEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check max path length (64 hops)
|
|
||||||
if (pathBytesList.length > 64) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Path too long. Maximum 64 hops allowed.'),
|
|
||||||
duration: Duration(seconds: 3),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathBytesList.isNotEmpty) {
|
|
||||||
await connector.setContactPath(
|
|
||||||
widget.contact,
|
|
||||||
Uint8List.fromList(pathBytesList),
|
|
||||||
pathBytesList.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Path set: ${pathBytesList.length} ${pathBytesList.length == 1 ? "hop" : "hops"}'),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Set Path'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showContactPathPicker(BuildContext context) {
|
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
||||||
final selectedContacts = <Contact>[];
|
|
||||||
|
|
||||||
// Filter to only repeaters and room servers
|
|
||||||
final validContacts = connector.contacts
|
|
||||||
.where((c) => (c.type == 2 || c.type == 3) && c != widget.contact)
|
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
showDialog(
|
final result = await PathSelectionDialog.show(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) => StatefulBuilder(
|
availableContacts: availableContacts,
|
||||||
builder: (context, setDialogState) => AlertDialog(
|
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
||||||
title: const Text('Build Path from Contacts'),
|
title: 'Set Custom Path',
|
||||||
content: SizedBox(
|
currentPathLabel: currentPathLabel,
|
||||||
width: double.maxFinite,
|
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (validContacts.isEmpty) ...[
|
|
||||||
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'No repeaters or room servers found.',
|
|
||||||
style: TextStyle(fontSize: 14),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'Custom paths require intermediate hops that can relay messages.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
] else if (selectedContacts.isNotEmpty) ...[
|
|
||||||
const Text(
|
|
||||||
'Selected Path:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: selectedContacts.asMap().entries.map((entry) {
|
|
||||||
final idx = entry.key;
|
|
||||||
final contact = entry.value;
|
|
||||||
return Chip(
|
|
||||||
avatar: CircleAvatar(
|
|
||||||
child: Text('${idx + 1}'),
|
|
||||||
),
|
|
||||||
label: Text(contact.name),
|
|
||||||
onDeleted: () {
|
|
||||||
setDialogState(() {
|
|
||||||
selectedContacts.removeAt(idx);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
] else
|
|
||||||
const Text(
|
|
||||||
'Tap repeaters/rooms to add them to the path:',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (validContacts.isNotEmpty)
|
|
||||||
Flexible(
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: validContacts.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final contact = validContacts[index];
|
|
||||||
final isSelected = selectedContacts.contains(contact);
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: isSelected ? Colors.green : (contact.type == 2 ? Colors.blue : Colors.purple),
|
|
||||||
child: Icon(
|
|
||||||
contact.type == 2 ? Icons.router : Icons.meeting_room,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
|
|
||||||
subtitle: Text(
|
|
||||||
'${contact.typeLabel} • ${contact.publicKeyHex.substring(0, 8)}',
|
|
||||||
style: const TextStyle(fontSize: 10),
|
|
||||||
),
|
|
||||||
trailing: isSelected
|
|
||||||
? const Icon(Icons.check_circle, color: Colors.green)
|
|
||||||
: const Icon(Icons.add_circle_outline),
|
|
||||||
onTap: () {
|
|
||||||
setDialogState(() {
|
|
||||||
if (isSelected) {
|
|
||||||
selectedContacts.remove(contact);
|
|
||||||
} else {
|
|
||||||
selectedContacts.add(contact);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
if (selectedContacts.isNotEmpty)
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setDialogState(() {
|
|
||||||
selectedContacts.clear();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Clear'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: selectedContacts.isEmpty
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
// Build path bytes from selected contacts (prefix byte of each pub key)
|
|
||||||
final pathBytesList = <int>[];
|
|
||||||
for (final contact in selectedContacts) {
|
|
||||||
if (contact.publicKeyHex.length >= 2) {
|
|
||||||
try {
|
|
||||||
pathBytesList.add(int.parse(contact.publicKeyHex.substring(0, 2), radix: 16));
|
|
||||||
} catch (e) {
|
|
||||||
// Skip invalid hex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathBytesList.isNotEmpty) {
|
|
||||||
await connector.setContactPath(
|
|
||||||
widget.contact,
|
|
||||||
Uint8List.fromList(pathBytesList),
|
|
||||||
pathBytesList.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
final pathIds = selectedContacts
|
|
||||||
.map((c) => c.publicKeyHex.substring(0, 8))
|
|
||||||
.join(',');
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Custom path set: $pathIds'),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Set Path'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
appLogger.info('PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted', tag: 'ChatScreen');
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'ChatScreen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen');
|
||||||
|
await connector.setPathOverride(
|
||||||
|
widget.contact,
|
||||||
|
pathLen: result.length,
|
||||||
|
pathBytes: result,
|
||||||
|
);
|
||||||
|
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
await _notifyPathSet(connector, widget.contact, result, result.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void _openMessagePath(Message message) {
|
void _openMessagePath(Message message) {
|
||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final senderName =
|
final senderName =
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import '../utils/dialog_utils.dart';
|
|||||||
import '../utils/disconnect_navigation_mixin.dart';
|
import '../utils/disconnect_navigation_mixin.dart';
|
||||||
import '../utils/emoji_utils.dart';
|
import '../utils/emoji_utils.dart';
|
||||||
import '../utils/route_transitions.dart';
|
import '../utils/route_transitions.dart';
|
||||||
|
import '../widgets/battery_indicator.dart';
|
||||||
|
import '../widgets/list_filter_widget.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
import '../widgets/quick_switch_bar.dart';
|
import '../widgets/quick_switch_bar.dart';
|
||||||
import '../widgets/repeater_login_dialog.dart';
|
import '../widgets/repeater_login_dialog.dart';
|
||||||
@@ -23,22 +25,6 @@ import 'map_screen.dart';
|
|||||||
import 'repeater_hub_screen.dart';
|
import 'repeater_hub_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
enum ContactSortOption {
|
|
||||||
lastSeen,
|
|
||||||
recentMessages,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum _ContactMenuAction {
|
|
||||||
sortRecentMessages,
|
|
||||||
sortName,
|
|
||||||
sortType,
|
|
||||||
toggleLastSeenFilter,
|
|
||||||
toggleUnreadOnly,
|
|
||||||
newGroup,
|
|
||||||
}
|
|
||||||
|
|
||||||
class ContactsScreen extends StatefulWidget {
|
class ContactsScreen extends StatefulWidget {
|
||||||
final bool hideBackButton;
|
final bool hideBackButton;
|
||||||
|
|
||||||
@@ -56,8 +42,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||||
bool _forceLastSeenSort = true;
|
|
||||||
bool _showUnreadOnly = false;
|
bool _showUnreadOnly = false;
|
||||||
|
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
|
||||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||||
List<ContactGroup> _groups = [];
|
List<ContactGroup> _groups = [];
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
@@ -97,41 +83,15 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final allowBack = !connector.isConnected;
|
final allowBack = !connector.isConnected;
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: allowBack,
|
canPop: allowBack,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
titleSpacing: 16,
|
leading: BatteryIndicator(connector: connector),
|
||||||
centerTitle: false,
|
title: const Text('Contacts'),
|
||||||
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
centerTitle: true,
|
||||||
title: Column(
|
automaticallyImplyLeading: false,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text('Contacts'),
|
|
||||||
Text(
|
|
||||||
'${connector.contacts.length} contacts',
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: connector.isLoadingContacts
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.refresh),
|
|
||||||
tooltip: 'Refresh',
|
|
||||||
onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.bluetooth_disabled),
|
icon: const Icon(Icons.bluetooth_disabled),
|
||||||
tooltip: 'Disconnect',
|
tooltip: 'Disconnect',
|
||||||
@@ -145,93 +105,6 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuButton<_ContactMenuAction>(
|
|
||||||
tooltip: 'Contacts options',
|
|
||||||
onSelected: (action) {
|
|
||||||
switch (action) {
|
|
||||||
case _ContactMenuAction.sortRecentMessages:
|
|
||||||
setState(() {
|
|
||||||
_sortOption = ContactSortOption.recentMessages;
|
|
||||||
_forceLastSeenSort = false;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case _ContactMenuAction.sortName:
|
|
||||||
setState(() {
|
|
||||||
_sortOption = ContactSortOption.name;
|
|
||||||
_forceLastSeenSort = false;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case _ContactMenuAction.sortType:
|
|
||||||
setState(() {
|
|
||||||
_sortOption = ContactSortOption.type;
|
|
||||||
_forceLastSeenSort = false;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case _ContactMenuAction.toggleLastSeenFilter:
|
|
||||||
setState(() {
|
|
||||||
_forceLastSeenSort = !_forceLastSeenSort;
|
|
||||||
if (_forceLastSeenSort) {
|
|
||||||
_sortOption = ContactSortOption.lastSeen;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case _ContactMenuAction.toggleUnreadOnly:
|
|
||||||
setState(() {
|
|
||||||
_showUnreadOnly = !_showUnreadOnly;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case _ContactMenuAction.newGroup:
|
|
||||||
_showGroupEditor(context, connector.contacts);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) {
|
|
||||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
PopupMenuItem<_ContactMenuAction>(
|
|
||||||
enabled: false,
|
|
||||||
child: Text('Sort by', style: labelStyle),
|
|
||||||
),
|
|
||||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
||||||
value: _ContactMenuAction.sortRecentMessages,
|
|
||||||
checked: _sortOption == ContactSortOption.recentMessages,
|
|
||||||
child: const Text('Recent messages'),
|
|
||||||
),
|
|
||||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
||||||
value: _ContactMenuAction.sortName,
|
|
||||||
checked: _sortOption == ContactSortOption.name,
|
|
||||||
child: const Text('Name'),
|
|
||||||
),
|
|
||||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
||||||
value: _ContactMenuAction.sortType,
|
|
||||||
checked: _sortOption == ContactSortOption.type,
|
|
||||||
child: const Text('Type'),
|
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
PopupMenuItem<_ContactMenuAction>(
|
|
||||||
enabled: false,
|
|
||||||
child: Text('Filters', style: labelStyle),
|
|
||||||
),
|
|
||||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
||||||
value: _ContactMenuAction.toggleLastSeenFilter,
|
|
||||||
checked: _forceLastSeenSort,
|
|
||||||
child: const Text('Last seen'),
|
|
||||||
),
|
|
||||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
||||||
value: _ContactMenuAction.toggleUnreadOnly,
|
|
||||||
checked: _showUnreadOnly,
|
|
||||||
child: const Text('Unread only'),
|
|
||||||
),
|
|
||||||
PopupMenuItem<_ContactMenuAction>(
|
|
||||||
value: _ContactMenuAction.newGroup,
|
|
||||||
child: const Text('New group'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _buildContactsBody(context, connector),
|
body: _buildContactsBody(context, connector),
|
||||||
@@ -253,6 +126,30 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
await showDisconnectDialog(context, connector);
|
await showDisconnectDialog(context, connector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
return ContactsFilterMenu(
|
||||||
|
sortOption: _sortOption,
|
||||||
|
typeFilter: _typeFilter,
|
||||||
|
showUnreadOnly: _showUnreadOnly,
|
||||||
|
onSortChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_sortOption = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTypeFilterChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_typeFilter = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUnreadOnlyChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_showUnreadOnly = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onNewGroup: () => _showGroupEditor(context, connector.contacts),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||||
final contacts = connector.contacts;
|
final contacts = connector.contacts;
|
||||||
|
|
||||||
@@ -281,8 +178,11 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search contacts...',
|
hintText: 'Search contacts...',
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
suffixIcon: _searchQuery.isNotEmpty
|
suffixIcon: Row(
|
||||||
? IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_searchQuery.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
@@ -290,8 +190,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
_searchQuery = '';
|
_searchQuery = '';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
: null,
|
_buildFilterButton(context, connector),
|
||||||
|
],
|
||||||
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -366,6 +268,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
if (contact != null && matchesContactQuery(contact, query)) return true;
|
if (contact != null && matchesContactQuery(contact, query)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
}).where((group) {
|
||||||
|
if (_typeFilter == ContactTypeFilter.all) return true;
|
||||||
|
for (final key in group.memberKeys) {
|
||||||
|
final contact = contactsByKey[key];
|
||||||
|
if (contact != null && _matchesTypeFilter(contact)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
@@ -378,14 +287,17 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
return matchesContactQuery(contact, _searchQuery);
|
return matchesContactQuery(contact, _searchQuery);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
if (_typeFilter != ContactTypeFilter.all) {
|
||||||
|
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||||
|
}
|
||||||
|
|
||||||
if (_showUnreadOnly) {
|
if (_showUnreadOnly) {
|
||||||
filtered = filtered.where((contact) {
|
filtered = filtered.where((contact) {
|
||||||
return connector.getUnreadCountForContact(contact) > 0;
|
return connector.getUnreadCountForContact(contact) > 0;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption;
|
switch (_sortOption) {
|
||||||
switch (sortOption) {
|
|
||||||
case ContactSortOption.lastSeen:
|
case ContactSortOption.lastSeen:
|
||||||
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
|
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
|
||||||
break;
|
break;
|
||||||
@@ -401,18 +313,24 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
case ContactSortOption.name:
|
case ContactSortOption.name:
|
||||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
break;
|
break;
|
||||||
case ContactSortOption.type:
|
|
||||||
filtered.sort((a, b) {
|
|
||||||
final typeCompare = a.type.compareTo(b.type);
|
|
||||||
if (typeCompare != 0) return typeCompare;
|
|
||||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _matchesTypeFilter(Contact contact) {
|
||||||
|
switch (_typeFilter) {
|
||||||
|
case ContactTypeFilter.all:
|
||||||
|
return true;
|
||||||
|
case ContactTypeFilter.users:
|
||||||
|
return contact.type == advTypeChat;
|
||||||
|
case ContactTypeFilter.repeaters:
|
||||||
|
return contact.type == advTypeRepeater;
|
||||||
|
case ContactTypeFilter.rooms:
|
||||||
|
return contact.type == advTypeRoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DateTime _resolveLastSeen(Contact contact) {
|
DateTime _resolveLastSeen(Contact contact) {
|
||||||
if (contact.type != advTypeChat) return contact.lastSeen;
|
if (contact.type != advTypeChat) return contact.lastSeen;
|
||||||
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
||||||
|
|||||||
@@ -39,10 +39,17 @@ class _DeviceScreenState extends State<DeviceScreen>
|
|||||||
canPop: false,
|
canPop: false,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: _buildBatteryIndicator(connector, context),
|
||||||
titleSpacing: 16,
|
titleSpacing: 16,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
title: _buildAppBarTitle(connector, theme),
|
title: _buildAppBarTitle(connector, theme),
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bluetooth_disabled),
|
||||||
|
tooltip: 'Disconnect',
|
||||||
|
onPressed: () => _disconnect(context, connector),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.tune),
|
icon: const Icon(Icons.tune),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
@@ -53,11 +60,6 @@ class _DeviceScreenState extends State<DeviceScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.bluetooth_disabled),
|
|
||||||
tooltip: 'Disconnect',
|
|
||||||
onPressed: () => _disconnect(context, connector),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../services/map_marker_service.dart';
|
|||||||
import '../services/map_tile_cache_service.dart';
|
import '../services/map_tile_cache_service.dart';
|
||||||
import '../utils/contact_search.dart';
|
import '../utils/contact_search.dart';
|
||||||
import '../utils/route_transitions.dart';
|
import '../utils/route_transitions.dart';
|
||||||
|
import '../widgets/battery_indicator.dart';
|
||||||
import '../widgets/quick_switch_bar.dart';
|
import '../widgets/quick_switch_bar.dart';
|
||||||
import 'channels_screen.dart';
|
import 'channels_screen.dart';
|
||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
@@ -136,10 +137,16 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
canPop: allowBack,
|
canPop: allowBack,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: BatteryIndicator(connector: connector),
|
||||||
title: const Text('Node Map'),
|
title: const Text('Node Map'),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
automaticallyImplyLeading: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bluetooth_disabled),
|
||||||
|
tooltip: 'Disconnect',
|
||||||
|
onPressed: () => _disconnect(context, connector),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.tune),
|
icon: const Icon(Icons.tune),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
@@ -148,11 +155,6 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.bluetooth_disabled),
|
|
||||||
tooltip: 'Disconnect',
|
|
||||||
onPressed: () => _disconnect(context, connector),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: !hasMapContent
|
body: !hasMapContent
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../connector/meshcore_connector.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../widgets/debug_frame_viewer.dart';
|
import '../widgets/debug_frame_viewer.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
import '../widgets/path_management_dialog.dart';
|
||||||
|
|
||||||
class RepeaterCliScreen extends StatefulWidget {
|
class RepeaterCliScreen extends StatefulWidget {
|
||||||
final Contact repeater;
|
final Contact repeater;
|
||||||
@@ -75,6 +76,13 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
|
return connector.contacts.firstWhere(
|
||||||
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
|
orElse: () => widget.repeater,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _handleTextMessageResponse(Uint8List frame) {
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
final parsed = parseContactMessageText(frame);
|
final parsed = parseContactMessageText(frame);
|
||||||
if (parsed == null) return;
|
if (parsed == null) return;
|
||||||
@@ -117,9 +125,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||||||
// Send CLI command to repeater with retry
|
// Send CLI command to repeater with retry
|
||||||
try {
|
try {
|
||||||
if (_commandService != null) {
|
if (_commandService != null) {
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
final response = await _commandService!.sendCommand(
|
final response = await _commandService!.sendCommand(
|
||||||
widget.repeater,
|
repeater,
|
||||||
command,
|
command,
|
||||||
|
retries: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -204,6 +215,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
final isFloodMode = repeater.pathOverride == -1;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Column(
|
title: Column(
|
||||||
@@ -212,13 +227,61 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Text('Repeater CLI'),
|
const Text('Repeater CLI'),
|
||||||
Text(
|
Text(
|
||||||
widget.repeater.name,
|
repeater.name,
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
|
tooltip: 'Routing mode',
|
||||||
|
onSelected: (mode) async {
|
||||||
|
if (mode == 'flood') {
|
||||||
|
await connector.setPathOverride(repeater, pathLen: -1);
|
||||||
|
} else {
|
||||||
|
await connector.setPathOverride(repeater, 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.timeline),
|
||||||
|
tooltip: 'Path management',
|
||||||
|
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.bug_report),
|
icon: const Icon(Icons.bug_report),
|
||||||
tooltip: 'Debug Next Command',
|
tooltip: 'Debug Next Command',
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Repeater info card
|
// Repeater info card
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -142,8 +140,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/contact.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
import '../widgets/path_management_dialog.dart';
|
||||||
|
|
||||||
class RepeaterSettingsScreen extends StatefulWidget {
|
class RepeaterSettingsScreen extends StatefulWidget {
|
||||||
final Contact repeater;
|
final Contact repeater;
|
||||||
@@ -121,6 +122,13 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
|
return connector.contacts.firstWhere(
|
||||||
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
|
orElse: () => widget.repeater,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||||
final target = widget.repeater.publicKey;
|
final target = widget.repeater.publicKey;
|
||||||
if (target.length < 6 || prefix.length < 6) return false;
|
if (target.length < 6 || prefix.length < 6) return false;
|
||||||
@@ -326,9 +334,15 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var successCount = 0;
|
var successCount = 0;
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
for (final command in commands) {
|
for (final command in commands) {
|
||||||
try {
|
try {
|
||||||
final response = await _commandService!.sendCommand(widget.repeater, command);
|
final response = await _commandService!.sendCommand(
|
||||||
|
repeater,
|
||||||
|
command,
|
||||||
|
retries: 1,
|
||||||
|
);
|
||||||
_applySettingResponse(command, response);
|
_applySettingResponse(command, response);
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
@@ -422,12 +436,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
|
|
||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final selection = await connector.preparePathForContactSend(repeater);
|
||||||
final commands = <String>[];
|
final commands = <String>[];
|
||||||
|
|
||||||
// Build set commands for each setting
|
// Build set commands for each setting
|
||||||
@@ -470,7 +486,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
|
|
||||||
// Send all commands
|
// Send all commands
|
||||||
for (final command in commands) {
|
for (final command in commands) {
|
||||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
connector.trackRepeaterAck(
|
||||||
|
contact: repeater,
|
||||||
|
selection: selection,
|
||||||
|
text: command,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
);
|
||||||
|
final frame = buildSendCliCommandFrame(
|
||||||
|
repeater.publicKey,
|
||||||
|
command,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
);
|
||||||
await connector.sendFrame(frame);
|
await connector.sendFrame(frame);
|
||||||
await Future.delayed(const Duration(milliseconds: 200)); // Delay between commands
|
await Future.delayed(const Duration(milliseconds: 200)); // Delay between commands
|
||||||
}
|
}
|
||||||
@@ -544,6 +571,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
final isFloodMode = repeater.pathOverride == -1;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Column(
|
title: Column(
|
||||||
@@ -552,13 +583,64 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Text('Repeater Settings'),
|
const Text('Repeater Settings'),
|
||||||
Text(
|
Text(
|
||||||
widget.repeater.name,
|
repeater.name,
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
|
tooltip: 'Routing mode',
|
||||||
|
onSelected: (mode) async {
|
||||||
|
if (mode == 'flood') {
|
||||||
|
await connector.setPathOverride(repeater, pathLen: -1);
|
||||||
|
} else {
|
||||||
|
await connector.setPathOverride(repeater, pathLen: null);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.timeline),
|
||||||
|
tooltip: 'Path management',
|
||||||
|
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||||
|
),
|
||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _isLoading ? null : _saveSettings,
|
onPressed: _isLoading ? null : _saveSettings,
|
||||||
@@ -995,6 +1077,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
|
|
||||||
Future<void> _sendDangerCommand(String command) async {
|
Future<void> _sendDangerCommand(String command) async {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
|
||||||
if (command == 'erase') {
|
if (command == 'erase') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -1006,7 +1089,19 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
final selection = await connector.preparePathForContactSend(repeater);
|
||||||
|
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
connector.trackRepeaterAck(
|
||||||
|
contact: repeater,
|
||||||
|
selection: selection,
|
||||||
|
text: command,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
);
|
||||||
|
final frame = buildSendCliCommandFrame(
|
||||||
|
repeater.publicKey,
|
||||||
|
command,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
);
|
||||||
await connector.sendFrame(frame);
|
await connector.sendFrame(frame);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
import '../widgets/path_management_dialog.dart';
|
||||||
|
|
||||||
class RepeaterStatusScreen extends StatefulWidget {
|
class RepeaterStatusScreen extends StatefulWidget {
|
||||||
final Contact repeater;
|
final Contact repeater;
|
||||||
@@ -23,6 +25,10 @@ class RepeaterStatusScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||||
|
static const int _statusPayloadOffset = 8;
|
||||||
|
static const int _statusStatsSize = 52;
|
||||||
|
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
StreamSubscription<Uint8List>? _frameSubscription;
|
StreamSubscription<Uint8List>? _frameSubscription;
|
||||||
RepeaterCommandService? _commandService;
|
RepeaterCommandService? _commandService;
|
||||||
@@ -45,6 +51,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
int? _directRx;
|
int? _directRx;
|
||||||
int? _dupFlood;
|
int? _dupFlood;
|
||||||
int? _dupDirect;
|
int? _dupDirect;
|
||||||
|
PathSelection? _pendingStatusSelection;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -80,6 +87,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
|
return connector.contacts.firstWhere(
|
||||||
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
|
orElse: () => widget.repeater,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _handleTextMessageResponse(Uint8List frame) {
|
void _handleTextMessageResponse(Uint8List frame) {
|
||||||
final parsed = parseContactMessageText(frame);
|
final parsed = parseContactMessageText(frame);
|
||||||
if (parsed == null) return;
|
if (parsed == null) return;
|
||||||
@@ -90,6 +104,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
|
|
||||||
// Parse status responses
|
// Parse status responses
|
||||||
_parseStatusResponse(parsed.text);
|
_parseStatusResponse(parsed.text);
|
||||||
|
_recordStatusResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleStatusResponse(Uint8List frame) {
|
void _handleStatusResponse(Uint8List frame) {
|
||||||
@@ -97,11 +112,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
final prefix = frame.sublist(2, 8);
|
final prefix = frame.sublist(2, 8);
|
||||||
if (!_matchesRepeaterPrefix(prefix)) return;
|
if (!_matchesRepeaterPrefix(prefix)) return;
|
||||||
|
|
||||||
const payloadOffset = 8;
|
if (frame.length < _statusResponseBytes) return;
|
||||||
const statsSize = 52;
|
|
||||||
if (frame.length < payloadOffset + statsSize) return;
|
|
||||||
|
|
||||||
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
|
final data = ByteData.sublistView(
|
||||||
|
frame,
|
||||||
|
_statusPayloadOffset,
|
||||||
|
_statusResponseBytes,
|
||||||
|
);
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
|
|
||||||
final batteryMv = data.getUint16(offset, Endian.little);
|
final batteryMv = data.getUint16(offset, Endian.little);
|
||||||
@@ -160,6 +177,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
_dupDirect = directDups;
|
_dupDirect = directDups;
|
||||||
_dupFlood = floodDups;
|
_dupFlood = floodDups;
|
||||||
});
|
});
|
||||||
|
_recordStatusResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||||
@@ -213,6 +231,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_statusRequestedAt = DateTime.now();
|
_statusRequestedAt = DateTime.now();
|
||||||
|
_pendingStatusSelection = null;
|
||||||
_batteryMv = null;
|
_batteryMv = null;
|
||||||
_uptimeSecs = null;
|
_uptimeSecs = null;
|
||||||
_queueLen = null;
|
_queueLen = null;
|
||||||
@@ -234,11 +253,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
|
final repeater = _resolveRepeater(connector);
|
||||||
|
final selection = await connector.preparePathForContactSend(repeater);
|
||||||
|
_pendingStatusSelection = selection;
|
||||||
|
final frame = buildSendStatusRequestFrame(repeater.publicKey);
|
||||||
await connector.sendFrame(frame);
|
await connector.sendFrame(frame);
|
||||||
|
|
||||||
|
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||||
|
final messageBytes = frame.length >= _statusResponseBytes
|
||||||
|
? frame.length
|
||||||
|
: _statusResponseBytes;
|
||||||
|
final timeoutMs = connector.calculateTimeout(
|
||||||
|
pathLength: pathLengthValue,
|
||||||
|
messageBytes: messageBytes,
|
||||||
|
);
|
||||||
_statusTimeout?.cancel();
|
_statusTimeout?.cancel();
|
||||||
_statusTimeout = Timer(const Duration(seconds: 12), () {
|
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -249,6 +279,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
_recordStatusResult(false);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -263,11 +294,25 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_recordStatusResult(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _recordStatusResult(bool success) {
|
||||||
|
final selection = _pendingStatusSelection;
|
||||||
|
if (selection == null) return;
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
||||||
|
_pendingStatusSelection = null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
final isFloodMode = repeater.pathOverride == -1;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Column(
|
title: Column(
|
||||||
@@ -276,13 +321,61 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Text('Repeater Status'),
|
const Text('Repeater Status'),
|
||||||
Text(
|
Text(
|
||||||
widget.repeater.name,
|
repeater.name,
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
|
tooltip: 'Routing mode',
|
||||||
|
onSelected: (mode) async {
|
||||||
|
if (mode == 'flood') {
|
||||||
|
await connector.setPathOverride(repeater, pathLen: -1);
|
||||||
|
} else {
|
||||||
|
await connector.setPathOverride(repeater, 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.timeline),
|
||||||
|
tooltip: 'Path management',
|
||||||
|
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.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 'app_settings_screen.dart';
|
import 'app_settings_screen.dart';
|
||||||
|
import 'app_debug_log_screen.dart';
|
||||||
import 'ble_debug_log_screen.dart';
|
import 'ble_debug_log_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
bool _showBatteryVoltage = false;
|
||||||
|
String _appVersion = '...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadVersionInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVersionInfo() async {
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
setState(() {
|
||||||
|
_appVersion = packageInfo.version;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -58,6 +81,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
_buildInfoRow('Name', connector.deviceDisplayName),
|
_buildInfoRow('Name', connector.deviceDisplayName),
|
||||||
_buildInfoRow('ID', connector.deviceIdLabel),
|
_buildInfoRow('ID', connector.deviceIdLabel),
|
||||||
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
|
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
|
||||||
|
_buildBatteryInfoRow(connector),
|
||||||
if (connector.selfName != null)
|
if (connector.selfName != null)
|
||||||
_buildInfoRow('Node Name', connector.selfName!),
|
_buildInfoRow('Node Name', connector.selfName!),
|
||||||
if (connector.selfPublicKey != null)
|
if (connector.selfPublicKey != null)
|
||||||
@@ -70,6 +94,53 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildBatteryInfoRow(MeshCoreConnector connector) {
|
||||||
|
final percent = connector.batteryPercent;
|
||||||
|
final millivolts = connector.batteryMillivolts;
|
||||||
|
|
||||||
|
// figure out display value
|
||||||
|
final String displayValue;
|
||||||
|
if (millivolts == null) {
|
||||||
|
displayValue = '—';
|
||||||
|
} else if (_showBatteryVoltage) {
|
||||||
|
displayValue = '${(millivolts / 1000.0).toStringAsFixed(2)} V';
|
||||||
|
} else {
|
||||||
|
displayValue = percent != null ? '$percent%' : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color? iconColor;
|
||||||
|
final Color? valueColor;
|
||||||
|
|
||||||
|
if (percent == null) {
|
||||||
|
icon = Icons.battery_unknown;
|
||||||
|
iconColor = Colors.grey;
|
||||||
|
valueColor = null;
|
||||||
|
} else if (percent <= 15) {
|
||||||
|
icon = Icons.battery_alert;
|
||||||
|
iconColor = Colors.orange;
|
||||||
|
valueColor = Colors.orange;
|
||||||
|
} else {
|
||||||
|
icon = Icons.battery_full;
|
||||||
|
iconColor = null;
|
||||||
|
valueColor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildInfoRow(
|
||||||
|
'Battery',
|
||||||
|
displayValue,
|
||||||
|
leading: Icon(icon, size: 18, color: iconColor),
|
||||||
|
valueColor: valueColor,
|
||||||
|
onTap: millivolts != null
|
||||||
|
? () {
|
||||||
|
setState(() {
|
||||||
|
_showBatteryVoltage = !_showBatteryVoltage;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildAppSettingsCard(BuildContext context) {
|
Widget _buildAppSettingsCard(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -184,7 +255,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.info_outline),
|
leading: const Icon(Icons.info_outline),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
subtitle: const Text('MeshCore Open v0.1.0'),
|
subtitle: Text('MeshCore Open v$_appVersion'),
|
||||||
onTap: () => _showAbout(context),
|
onTap: () => _showAbout(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -192,38 +263,89 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildDebugCard(BuildContext context) {
|
Widget _buildDebugCard(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: ListTile(
|
child: Column(
|
||||||
leading: const Icon(Icons.bug_report_outlined),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: const Text('BLE Debug Log'),
|
children: [
|
||||||
subtitle: const Text('Commands, responses, and status'),
|
const Padding(
|
||||||
trailing: const Icon(Icons.chevron_right),
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
onTap: () {
|
child: Text(
|
||||||
Navigator.push(
|
'Debug',
|
||||||
context,
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
|
),
|
||||||
);
|
),
|
||||||
},
|
ListTile(
|
||||||
|
leading: const Icon(Icons.bluetooth_outlined),
|
||||||
|
title: const Text('BLE Debug Log'),
|
||||||
|
subtitle: const Text('BLE commands, responses, and raw data'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.code_outlined),
|
||||||
|
title: const Text('App Debug Log'),
|
||||||
|
subtitle: const Text('Application debug messages'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const AppDebugLogScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoRow(String label, String value) {
|
Widget _buildInfoRow(
|
||||||
return Padding(
|
String label,
|
||||||
|
String value, {
|
||||||
|
Widget? leading,
|
||||||
|
Color? valueColor,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) {
|
||||||
|
final row = Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: TextStyle(color: Colors.grey[600])),
|
Row(
|
||||||
|
children: [
|
||||||
|
if (leading != null) ...[
|
||||||
|
leading,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
],
|
||||||
|
),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: valueColor,
|
||||||
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onTap != null) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
|
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
|
||||||
@@ -426,7 +548,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
showAboutDialog(
|
showAboutDialog(
|
||||||
context: context,
|
context: context,
|
||||||
applicationName: 'MeshCore Open',
|
applicationName: 'MeshCore Open',
|
||||||
applicationVersion: '0.1.0',
|
applicationVersion: _appVersion,
|
||||||
applicationLegalese: '2024 MeshCore Open Source Project',
|
applicationLegalese: '2024 MeshCore Open Source Project',
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
enum AppDebugLogLevel {
|
||||||
|
info,
|
||||||
|
warning,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppDebugLogEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final AppDebugLogLevel level;
|
||||||
|
final String tag;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
AppDebugLogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.level,
|
||||||
|
required this.tag,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get levelLabel {
|
||||||
|
switch (level) {
|
||||||
|
case AppDebugLogLevel.info:
|
||||||
|
return 'INFO';
|
||||||
|
case AppDebugLogLevel.warning:
|
||||||
|
return 'WARN';
|
||||||
|
case AppDebugLogLevel.error:
|
||||||
|
return 'ERROR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedTime {
|
||||||
|
return '${timestamp.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${timestamp.minute.toString().padLeft(2, '0')}:'
|
||||||
|
'${timestamp.second.toString().padLeft(2, '0')}.'
|
||||||
|
'${timestamp.millisecond.toString().padLeft(3, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppDebugLogService extends ChangeNotifier {
|
||||||
|
static const int maxEntries = 1000;
|
||||||
|
final List<AppDebugLogEntry> _entries = [];
|
||||||
|
bool _enabled = false;
|
||||||
|
|
||||||
|
List<AppDebugLogEntry> get entries => List.unmodifiable(_entries);
|
||||||
|
bool get enabled => _enabled;
|
||||||
|
|
||||||
|
void setEnabled(bool value) {
|
||||||
|
_enabled = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
_entries.add(
|
||||||
|
AppDebugLogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: level,
|
||||||
|
tag: tag,
|
||||||
|
message: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_entries.length > maxEntries) {
|
||||||
|
_entries.removeRange(0, _entries.length - maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Also print to console for development
|
||||||
|
debugPrint('[$tag] $message');
|
||||||
|
}
|
||||||
|
|
||||||
|
void info(String message, {String tag = 'App'}) {
|
||||||
|
log(message, tag: tag, level: AppDebugLogLevel.info);
|
||||||
|
}
|
||||||
|
|
||||||
|
void warn(String message, {String tag = 'App'}) {
|
||||||
|
log(message, tag: tag, level: AppDebugLogLevel.warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
void error(String message, {String tag = 'App'}) {
|
||||||
|
log(message, tag: tag, level: AppDebugLogLevel.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_entries.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/app_settings.dart';
|
import '../models/app_settings.dart';
|
||||||
import '../storage/prefs_manager.dart';
|
import '../storage/prefs_manager.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
|
||||||
class AppSettingsService extends ChangeNotifier {
|
class AppSettingsService extends ChangeNotifier {
|
||||||
static const String _settingsKey = 'app_settings';
|
static const String _settingsKey = 'app_settings';
|
||||||
@@ -112,6 +113,12 @@ class AppSettingsService extends ChangeNotifier {
|
|||||||
await updateSettings(_settings.copyWith(themeMode: value));
|
await updateSettings(_settings.copyWith(themeMode: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setAppDebugLogEnabled(bool value) async {
|
||||||
|
await updateSettings(_settings.copyWith(appDebugLogEnabled: value));
|
||||||
|
// Update the global logger
|
||||||
|
appLogger.setEnabled(value);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
|
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
|
||||||
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
|
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
|
||||||
updated[deviceId] = chemistry;
|
updated[deviceId] = chemistry;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/path_selection.dart';
|
import '../models/path_selection.dart';
|
||||||
import 'storage_service.dart';
|
import 'storage_service.dart';
|
||||||
import 'app_settings_service.dart';
|
import 'app_settings_service.dart';
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
|
||||||
class _AckHistoryEntry {
|
class _AckHistoryEntry {
|
||||||
final String messageId;
|
final String messageId;
|
||||||
@@ -41,7 +44,8 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex → messageId + timestamp for O(1) lookup
|
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 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
|
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||||
|
final Map<String, String> _expectedHashToMessageId = {}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||||
|
|
||||||
Function(Contact, String, int, int)? _sendMessageCallback;
|
Function(Contact, String, int, int)? _sendMessageCallback;
|
||||||
Function(String, Message)? _addMessageCallback;
|
Function(String, Message)? _addMessageCallback;
|
||||||
@@ -49,7 +53,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
Function(Contact)? _clearContactPathCallback;
|
Function(Contact)? _clearContactPathCallback;
|
||||||
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
||||||
Function(int, int)? _calculateTimeoutCallback;
|
Function(int, int)? _calculateTimeoutCallback;
|
||||||
|
Uint8List? Function()? _getSelfPublicKeyCallback;
|
||||||
|
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
||||||
AppSettingsService? _appSettingsService;
|
AppSettingsService? _appSettingsService;
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||||
|
|
||||||
MessageRetryService(this._storage);
|
MessageRetryService(this._storage);
|
||||||
@@ -61,7 +68,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
Function(Contact)? clearContactPathCallback,
|
Function(Contact)? clearContactPathCallback,
|
||||||
Function(Contact, Uint8List, int)? setContactPathCallback,
|
Function(Contact, Uint8List, int)? setContactPathCallback,
|
||||||
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
|
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
|
||||||
|
Uint8List? Function()? getSelfPublicKeyCallback,
|
||||||
|
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
||||||
AppSettingsService? appSettingsService,
|
AppSettingsService? appSettingsService,
|
||||||
|
AppDebugLogService? debugLogService,
|
||||||
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
||||||
}) {
|
}) {
|
||||||
_sendMessageCallback = sendMessageCallback;
|
_sendMessageCallback = sendMessageCallback;
|
||||||
@@ -70,10 +80,46 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_clearContactPathCallback = clearContactPathCallback;
|
_clearContactPathCallback = clearContactPathCallback;
|
||||||
_setContactPathCallback = setContactPathCallback;
|
_setContactPathCallback = setContactPathCallback;
|
||||||
_calculateTimeoutCallback = calculateTimeoutCallback;
|
_calculateTimeoutCallback = calculateTimeoutCallback;
|
||||||
|
_getSelfPublicKeyCallback = getSelfPublicKeyCallback;
|
||||||
|
_prepareContactOutboundTextCallback = prepareContactOutboundTextCallback;
|
||||||
_appSettingsService = appSettingsService;
|
_appSettingsService = appSettingsService;
|
||||||
|
_debugLogService = debugLogService;
|
||||||
_recordPathResultCallback = recordPathResultCallback;
|
_recordPathResultCallback = recordPathResultCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute expected ACK hash using same algorithm as firmware:
|
||||||
|
/// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes
|
||||||
|
static Uint8List computeExpectedAckHash(
|
||||||
|
int timestampSeconds,
|
||||||
|
int attempt,
|
||||||
|
String text,
|
||||||
|
Uint8List senderPubKey,
|
||||||
|
) {
|
||||||
|
final textBytes = utf8.encode(text);
|
||||||
|
final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length);
|
||||||
|
int offset = 0;
|
||||||
|
|
||||||
|
// timestamp (4 bytes, little-endian)
|
||||||
|
buffer[offset++] = timestampSeconds & 0xFF;
|
||||||
|
buffer[offset++] = (timestampSeconds >> 8) & 0xFF;
|
||||||
|
buffer[offset++] = (timestampSeconds >> 16) & 0xFF;
|
||||||
|
buffer[offset++] = (timestampSeconds >> 24) & 0xFF;
|
||||||
|
|
||||||
|
// attempt (1 byte)
|
||||||
|
buffer[offset++] = attempt & 0x03;
|
||||||
|
|
||||||
|
// text
|
||||||
|
buffer.setRange(offset, offset + textBytes.length, textBytes);
|
||||||
|
offset += textBytes.length;
|
||||||
|
|
||||||
|
// sender public key (32 bytes)
|
||||||
|
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
|
||||||
|
|
||||||
|
// Compute SHA256 and return first 4 bytes
|
||||||
|
final hash = sha256.convert(buffer);
|
||||||
|
return Uint8List.fromList(hash.bytes.sublist(0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendMessageWithRetry({
|
Future<void> sendMessageWithRetry({
|
||||||
required Contact contact,
|
required Contact contact,
|
||||||
required String text,
|
required String text,
|
||||||
@@ -136,14 +182,35 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final attempt = message.retryCount.clamp(0, 3);
|
final attempt = message.retryCount.clamp(0, 3);
|
||||||
|
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
// Enqueue this message to track send order for ACK hash mapping (FIFO)
|
// Compute expected ACK hash that device will return in RESP_CODE_SENT
|
||||||
|
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
|
||||||
|
final selfPubKey = _getSelfPublicKeyCallback?.call();
|
||||||
|
if (selfPubKey != null) {
|
||||||
|
final outboundText = _prepareContactOutboundTextCallback?.call(contact, message.text) ?? message.text;
|
||||||
|
final expectedHash = MessageRetryService.computeExpectedAckHash(
|
||||||
|
timestampSeconds,
|
||||||
|
attempt,
|
||||||
|
outboundText,
|
||||||
|
selfPubKey,
|
||||||
|
);
|
||||||
|
final expectedHashHex = expectedHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
_expectedHashToMessageId[expectedHashHex] = messageId;
|
||||||
|
|
||||||
|
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
|
debugPrint('Computed expected ACK hash $expectedHashHex for message $messageId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEPRECATED: Old queue-based matching (kept for fallback)
|
||||||
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
|
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
|
||||||
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
|
_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;
|
|
||||||
_sendMessageCallback!(
|
_sendMessageCallback!(
|
||||||
contact,
|
contact,
|
||||||
message.text,
|
message.text,
|
||||||
@@ -156,35 +223,68 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
||||||
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
// Dequeue the next message from the FIFO queue to match with this RESP_CODE_SENT
|
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
|
||||||
// We iterate through contacts to find which one has a pending message in their queue
|
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
|
||||||
String? messageId;
|
|
||||||
Contact? contact;
|
Contact? contact;
|
||||||
|
|
||||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
if (messageId != null) {
|
||||||
final contactKey = entry.key;
|
contact = _pendingContacts[messageId];
|
||||||
final queue = entry.value;
|
final message = _pendingMessages[messageId];
|
||||||
|
|
||||||
if (queue.isNotEmpty) {
|
if (contact != null && message != null) {
|
||||||
// Dequeue the first (oldest) message from this contact's queue
|
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||||
final candidateMessageId = queue.removeAt(0);
|
_debugLogService?.info(
|
||||||
|
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
|
debugPrint('Hash-based match: ACK hash $ackHashHex → message $messageId ✓');
|
||||||
|
|
||||||
// Verify this message is still pending
|
// Remove from old queue since we matched
|
||||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||||
messageId = candidateMessageId;
|
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||||
contact = _pendingContacts[candidateMessageId];
|
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||||
debugPrint('Dequeued message $messageId for $contactKey (remaining in queue: ${queue.length})');
|
}
|
||||||
break;
|
} else {
|
||||||
} else {
|
_debugLogService?.warn(
|
||||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending',
|
||||||
// Continue to next message in queue
|
tag: 'AckHash',
|
||||||
if (queue.isNotEmpty) {
|
);
|
||||||
final nextMessageId = queue.removeAt(0);
|
debugPrint('Hash matched $messageId but message no longer pending');
|
||||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
messageId = null;
|
||||||
messageId = nextMessageId;
|
contact = null;
|
||||||
contact = _pendingContacts[nextMessageId];
|
}
|
||||||
debugPrint('Dequeued next message $messageId for $contactKey (remaining: ${queue.length})');
|
}
|
||||||
break;
|
|
||||||
|
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||||
|
if (messageId == null) {
|
||||||
|
_debugLogService?.warn(
|
||||||
|
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
|
debugPrint('Hash-based match failed for $ackHashHex, falling back to queue-based matching');
|
||||||
|
|
||||||
|
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||||
|
final contactKey = entry.key;
|
||||||
|
final queue = entry.value;
|
||||||
|
|
||||||
|
if (queue.isNotEmpty) {
|
||||||
|
final candidateMessageId = queue.removeAt(0);
|
||||||
|
|
||||||
|
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||||
|
messageId = candidateMessageId;
|
||||||
|
contact = _pendingContacts[candidateMessageId];
|
||||||
|
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey');
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||||
|
if (queue.isNotEmpty) {
|
||||||
|
final nextMessageId = queue.removeAt(0);
|
||||||
|
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||||
|
messageId = nextMessageId;
|
||||||
|
contact = _pendingContacts[nextMessageId];
|
||||||
|
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +292,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (messageId == null || contact == null) {
|
if (messageId == null || contact == null) {
|
||||||
debugPrint('No pending message found for ACK hash: $ackHashHex (all queues empty or stale)');
|
debugPrint('No pending message found for ACK hash: $ackHashHex');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +370,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||||
|
_debugLogService?.warn(
|
||||||
|
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
|
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
|
||||||
|
|
||||||
if (message.retryCount < maxRetries - 1) {
|
if (message.retryCount < maxRetries - 1) {
|
||||||
@@ -287,8 +392,14 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_updateMessageCallback!(updatedMessage);
|
_updateMessageCallback!(updatedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_debugLogService?.info(
|
||||||
|
'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
debugPrint('Scheduling retry after ${backoffMs}ms');
|
debugPrint('Scheduling retry after ${backoffMs}ms');
|
||||||
Timer(Duration(milliseconds: backoffMs), () {
|
|
||||||
|
// Store the backoff timer so it can be canceled if new RESP_CODE_SENT arrives
|
||||||
|
_timeoutTimers[messageId] = Timer(Duration(milliseconds: backoffMs), () {
|
||||||
// Double-check message is still pending before retry
|
// Double-check message is still pending before retry
|
||||||
if (_pendingMessages.containsKey(messageId)) {
|
if (_pendingMessages.containsKey(messageId)) {
|
||||||
_attemptSend(messageId);
|
_attemptSend(messageId);
|
||||||
@@ -388,6 +499,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
matchedMessageId = mapping.messageId;
|
matchedMessageId = mapping.messageId;
|
||||||
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
|
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
|
||||||
} else {
|
} else {
|
||||||
|
_debugLogService?.warn(
|
||||||
|
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
|
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
|
||||||
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
|
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
|
||||||
for (var entry in _expectedAckHashes.entries) {
|
for (var entry in _expectedAckHashes.entries) {
|
||||||
@@ -411,6 +526,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
final contact = _pendingContacts[matchedMessageId];
|
final contact = _pendingContacts[matchedMessageId];
|
||||||
final selection = _pendingPathSelections[matchedMessageId];
|
final selection = _pendingPathSelections[matchedMessageId];
|
||||||
|
|
||||||
|
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
|
|
||||||
// Cancel any pending timeout or retry
|
// Cancel any pending timeout or retry
|
||||||
_timeoutTimers[matchedMessageId]?.cancel();
|
_timeoutTimers[matchedMessageId]?.cancel();
|
||||||
_timeoutTimers.remove(matchedMessageId);
|
_timeoutTimers.remove(matchedMessageId);
|
||||||
@@ -448,8 +569,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// Check ACK history for recently completed messages
|
// Check ACK history for recently completed messages
|
||||||
if (_checkAckHistory(ackHash)) {
|
if (_checkAckHistory(ackHash)) {
|
||||||
|
_debugLogService?.info(
|
||||||
|
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex matched a recently completed message (duplicate ACK)',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
debugPrint('ACK matched a recently completed message from history');
|
debugPrint('ACK matched a recently completed message from history');
|
||||||
} else {
|
} else {
|
||||||
|
_debugLogService?.error(
|
||||||
|
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!',
|
||||||
|
tag: 'AckHash',
|
||||||
|
);
|
||||||
debugPrint('No matching message found for ACK: $ackHashHex');
|
debugPrint('No matching message found for ACK: $ackHashHex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
|
||||||
@@ -11,7 +12,6 @@ class RepeaterCommandService {
|
|||||||
final Map<String, String> _pendingByPrefix = {};
|
final Map<String, String> _pendingByPrefix = {};
|
||||||
int _prefixCounter = 0;
|
int _prefixCounter = 0;
|
||||||
|
|
||||||
static const int timeoutSeconds = 10; // Flood mode timeout
|
|
||||||
static const int maxRetries = 5;
|
static const int maxRetries = 5;
|
||||||
|
|
||||||
RepeaterCommandService(this._connector);
|
RepeaterCommandService(this._connector);
|
||||||
@@ -23,6 +23,7 @@ class RepeaterCommandService {
|
|||||||
String command, {
|
String command, {
|
||||||
Function(String)? onResponse,
|
Function(String)? onResponse,
|
||||||
Function(int)? onAttempt,
|
Function(int)? onAttempt,
|
||||||
|
int retries = maxRetries,
|
||||||
}) async {
|
}) async {
|
||||||
final repeaterKey = repeater.publicKeyHex;
|
final repeaterKey = repeater.publicKeyHex;
|
||||||
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
|
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
|
||||||
@@ -30,43 +31,83 @@ class RepeaterCommandService {
|
|||||||
throw Exception('Another command is still awaiting a response.');
|
throw Exception('Another command is still awaiting a response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create completer for this command
|
final attemptCount = retries < 1 ? 1 : retries;
|
||||||
|
final selection = await _connector.preparePathForContactSend(repeater);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < attemptCount; attempt++) {
|
||||||
|
onAttempt?.call(attempt + 1);
|
||||||
|
try {
|
||||||
|
final response = await _sendCommandAttempt(
|
||||||
|
repeater,
|
||||||
|
command,
|
||||||
|
selection,
|
||||||
|
attempt,
|
||||||
|
);
|
||||||
|
onResponse?.call(response);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt == attemptCount - 1) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Command failed after $attemptCount attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _sendCommandAttempt(
|
||||||
|
Contact repeater,
|
||||||
|
String command,
|
||||||
|
PathSelection selection,
|
||||||
|
int attempt,
|
||||||
|
) async {
|
||||||
|
final repeaterKey = repeater.publicKeyHex;
|
||||||
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
|
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final completer = Completer<String>();
|
final completer = Completer<String>();
|
||||||
_pendingCommands[commandId] = completer;
|
_pendingCommands[commandId] = completer;
|
||||||
|
|
||||||
onAttempt?.call(0);
|
|
||||||
|
|
||||||
// Send frame once (no retries)
|
|
||||||
try {
|
try {
|
||||||
final prefix = _nextPrefixToken();
|
final prefix = _nextPrefixToken();
|
||||||
_commandPrefixes[commandId] = prefix;
|
_commandPrefixes[commandId] = prefix;
|
||||||
_pendingByPrefix[prefix] = commandId;
|
_pendingByPrefix[prefix] = commandId;
|
||||||
final framedCommand = '$prefix$command';
|
final framedCommand = '$prefix$command';
|
||||||
final frame = buildSendCliCommandFrame(repeater.publicKey, framedCommand, attempt: 0);
|
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||||
|
final timeoutMs = _connector.calculateTimeout(
|
||||||
|
pathLength: pathLengthValue,
|
||||||
|
messageBytes: framedCommand.length,
|
||||||
|
);
|
||||||
|
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||||
|
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
_connector.trackRepeaterAck(
|
||||||
|
contact: repeater,
|
||||||
|
selection: selection,
|
||||||
|
text: framedCommand,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
attempt: attempt,
|
||||||
|
);
|
||||||
|
final frame = buildSendCliCommandFrame(
|
||||||
|
repeater.publicKey,
|
||||||
|
framedCommand,
|
||||||
|
attempt: attempt,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
);
|
||||||
await _connector.sendFrame(frame);
|
await _connector.sendFrame(frame);
|
||||||
|
_commandTimeouts[commandId]?.cancel();
|
||||||
|
_commandTimeouts[commandId] = Timer(
|
||||||
|
Duration(milliseconds: timeoutMs),
|
||||||
|
() {
|
||||||
|
final completer = _pendingCommands[commandId];
|
||||||
|
if (completer != null && !completer.isCompleted) {
|
||||||
|
completer.completeError('Command timeout after $timeoutSeconds seconds');
|
||||||
|
_cleanup(commandId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_cleanup(commandId);
|
_cleanup(commandId);
|
||||||
throw Exception('Failed to send command: $e');
|
throw Exception('Failed to send command: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set timeout for this attempt
|
|
||||||
_commandTimeouts[commandId]?.cancel();
|
|
||||||
_commandTimeouts[commandId] = Timer(
|
|
||||||
Duration(seconds: timeoutSeconds),
|
|
||||||
() {
|
|
||||||
final completer = _pendingCommands[commandId];
|
|
||||||
if (completer != null && !completer.isCompleted) {
|
|
||||||
completer.completeError('Command timeout after $timeoutSeconds seconds');
|
|
||||||
_cleanup(commandId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for response or timeout
|
|
||||||
try {
|
try {
|
||||||
final response = await completer.future;
|
return await completer.future;
|
||||||
return response;
|
|
||||||
} finally {
|
} finally {
|
||||||
_cleanup(commandId);
|
_cleanup(commandId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import '../services/app_debug_log_service.dart';
|
||||||
|
|
||||||
|
/// Global app logger instance
|
||||||
|
/// Usage: appLogger.info('Message', tag: 'MyClass');
|
||||||
|
class AppLogger {
|
||||||
|
AppDebugLogService? _service;
|
||||||
|
bool _enabled = false;
|
||||||
|
|
||||||
|
/// Initialize the logger with the debug log service
|
||||||
|
void initialize(AppDebugLogService service, {bool enabled = false}) {
|
||||||
|
_service = service;
|
||||||
|
_enabled = enabled;
|
||||||
|
_service?.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update whether logging is enabled
|
||||||
|
void setEnabled(bool enabled) {
|
||||||
|
_enabled = enabled;
|
||||||
|
_service?.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if logging is currently enabled
|
||||||
|
bool get isEnabled => _enabled;
|
||||||
|
|
||||||
|
/// Log an info message
|
||||||
|
void info(String message, {String tag = 'App'}) {
|
||||||
|
if (_enabled && _service != null) {
|
||||||
|
_service!.info(message, tag: tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a warning message
|
||||||
|
void warn(String message, {String tag = 'App'}) {
|
||||||
|
if (_enabled && _service != null) {
|
||||||
|
_service!.warn(message, tag: tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log an error message
|
||||||
|
void error(String message, {String tag = 'App'}) {
|
||||||
|
if (_enabled && _service != null) {
|
||||||
|
_service!.error(message, tag: tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a message with custom level
|
||||||
|
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||||
|
if (_enabled && _service != null) {
|
||||||
|
_service!.log(message, tag: tag, level: level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global logger instance
|
||||||
|
final appLogger = AppLogger();
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
|
||||||
|
class BatteryUi {
|
||||||
|
final IconData icon;
|
||||||
|
final Color? color;
|
||||||
|
const BatteryUi(this.icon, this.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
BatteryUi batteryUiForPercent(int? percent) {
|
||||||
|
if (percent == null) {
|
||||||
|
return const BatteryUi(Icons.battery_unknown, Colors.grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
final p = percent.clamp(0, 100);
|
||||||
|
|
||||||
|
return switch (p) {
|
||||||
|
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
|
||||||
|
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
|
||||||
|
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
|
||||||
|
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
|
||||||
|
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
|
||||||
|
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
|
||||||
|
_ => const BatteryUi(Icons.battery_full, Colors.green),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class BatteryIndicator extends StatefulWidget {
|
||||||
|
final MeshCoreConnector connector;
|
||||||
|
|
||||||
|
const BatteryIndicator({
|
||||||
|
super.key,
|
||||||
|
required this.connector,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BatteryIndicator> createState() => _BatteryIndicatorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BatteryIndicatorState extends State<BatteryIndicator> {
|
||||||
|
bool _showBatteryVoltage = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final percent = widget.connector.batteryPercent;
|
||||||
|
final millivolts = widget.connector.batteryMillivolts;
|
||||||
|
|
||||||
|
if (millivolts == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final String displayText;
|
||||||
|
if (_showBatteryVoltage) {
|
||||||
|
displayText = '${(millivolts / 1000.0).toStringAsFixed(2)}V';
|
||||||
|
} else {
|
||||||
|
displayText = percent != null ? '$percent%' : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
final batteryUi = batteryUiForPercent(percent);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_showBatteryVoltage = !_showBatteryVoltage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(batteryUi.icon, size: 20, color: batteryUi.color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
displayText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: batteryUi.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum ContactSortOption {
|
||||||
|
lastSeen,
|
||||||
|
recentMessages,
|
||||||
|
name,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContactTypeFilter {
|
||||||
|
all,
|
||||||
|
users,
|
||||||
|
repeaters,
|
||||||
|
rooms,
|
||||||
|
}
|
||||||
|
|
||||||
|
class SortFilterMenuOption {
|
||||||
|
final int value;
|
||||||
|
final String label;
|
||||||
|
final bool? checked;
|
||||||
|
|
||||||
|
const SortFilterMenuOption({
|
||||||
|
required this.value,
|
||||||
|
required this.label,
|
||||||
|
this.checked,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SortFilterMenuSection {
|
||||||
|
final String title;
|
||||||
|
final List<SortFilterMenuOption> options;
|
||||||
|
|
||||||
|
const SortFilterMenuSection({
|
||||||
|
required this.title,
|
||||||
|
required this.options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SortFilterMenu extends StatelessWidget {
|
||||||
|
final List<SortFilterMenuSection> sections;
|
||||||
|
final ValueChanged<int> onSelected;
|
||||||
|
final String tooltip;
|
||||||
|
final Widget icon;
|
||||||
|
|
||||||
|
const SortFilterMenu({
|
||||||
|
super.key,
|
||||||
|
required this.sections,
|
||||||
|
required this.onSelected,
|
||||||
|
this.tooltip = 'Filter and sort',
|
||||||
|
this.icon = const Icon(Icons.filter_list_outlined),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopupMenuButton<int>(
|
||||||
|
icon: icon,
|
||||||
|
tooltip: tooltip,
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
final visibleSections = sections.where((section) => section.options.isNotEmpty).toList();
|
||||||
|
final entries = <PopupMenuEntry<int>>[];
|
||||||
|
for (int i = 0; i < visibleSections.length; i++) {
|
||||||
|
final section = visibleSections[i];
|
||||||
|
entries.add(
|
||||||
|
PopupMenuItem<int>(
|
||||||
|
enabled: false,
|
||||||
|
child: Text(section.title, style: labelStyle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (final option in section.options) {
|
||||||
|
if (option.checked == null) {
|
||||||
|
entries.add(
|
||||||
|
PopupMenuItem<int>(
|
||||||
|
value: option.value,
|
||||||
|
child: Text(option.label),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
entries.add(
|
||||||
|
CheckedPopupMenuItem<int>(
|
||||||
|
value: option.value,
|
||||||
|
checked: option.checked ?? false,
|
||||||
|
child: Text(option.label),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < visibleSections.length - 1) {
|
||||||
|
entries.add(const PopupMenuDivider());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int _actionSortRecentMessages = 1;
|
||||||
|
const int _actionSortName = 2;
|
||||||
|
const int _actionSortLastSeen = 3;
|
||||||
|
const int _actionFilterAll = 4;
|
||||||
|
const int _actionFilterUsers = 5;
|
||||||
|
const int _actionFilterRepeaters = 6;
|
||||||
|
const int _actionFilterRooms = 7;
|
||||||
|
const int _actionToggleUnreadOnly = 8;
|
||||||
|
const int _actionNewGroup = 9;
|
||||||
|
|
||||||
|
class ContactsFilterMenu extends StatelessWidget {
|
||||||
|
final ContactSortOption sortOption;
|
||||||
|
final ContactTypeFilter typeFilter;
|
||||||
|
final bool showUnreadOnly;
|
||||||
|
final ValueChanged<ContactSortOption> onSortChanged;
|
||||||
|
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
|
||||||
|
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||||
|
final VoidCallback onNewGroup;
|
||||||
|
|
||||||
|
const ContactsFilterMenu({
|
||||||
|
super.key,
|
||||||
|
required this.sortOption,
|
||||||
|
required this.typeFilter,
|
||||||
|
required this.showUnreadOnly,
|
||||||
|
required this.onSortChanged,
|
||||||
|
required this.onTypeFilterChanged,
|
||||||
|
required this.onUnreadOnlyChanged,
|
||||||
|
required this.onNewGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SortFilterMenu(
|
||||||
|
sections: [
|
||||||
|
SortFilterMenuSection(
|
||||||
|
title: 'Sort by',
|
||||||
|
options: [
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionSortRecentMessages,
|
||||||
|
label: 'Latest messages',
|
||||||
|
checked: sortOption == ContactSortOption.recentMessages,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionSortLastSeen,
|
||||||
|
label: 'Heard recently',
|
||||||
|
checked: sortOption == ContactSortOption.lastSeen,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionSortName,
|
||||||
|
label: 'A-Z',
|
||||||
|
checked: sortOption == ContactSortOption.name,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SortFilterMenuSection(
|
||||||
|
title: 'Filters',
|
||||||
|
options: [
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionFilterAll,
|
||||||
|
label: 'All',
|
||||||
|
checked: typeFilter == ContactTypeFilter.all,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionFilterUsers,
|
||||||
|
label: 'Users',
|
||||||
|
checked: typeFilter == ContactTypeFilter.users,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionFilterRepeaters,
|
||||||
|
label: 'Repeaters',
|
||||||
|
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionFilterRooms,
|
||||||
|
label: 'Room servers',
|
||||||
|
checked: typeFilter == ContactTypeFilter.rooms,
|
||||||
|
),
|
||||||
|
SortFilterMenuOption(
|
||||||
|
value: _actionToggleUnreadOnly,
|
||||||
|
label: 'Unread only',
|
||||||
|
checked: showUnreadOnly,
|
||||||
|
),
|
||||||
|
const SortFilterMenuOption(
|
||||||
|
value: _actionNewGroup,
|
||||||
|
label: 'New group',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (action) {
|
||||||
|
switch (action) {
|
||||||
|
case _actionSortRecentMessages:
|
||||||
|
onSortChanged(ContactSortOption.recentMessages);
|
||||||
|
break;
|
||||||
|
case _actionSortName:
|
||||||
|
onSortChanged(ContactSortOption.name);
|
||||||
|
break;
|
||||||
|
case _actionSortLastSeen:
|
||||||
|
onSortChanged(ContactSortOption.lastSeen);
|
||||||
|
break;
|
||||||
|
case _actionFilterAll:
|
||||||
|
onTypeFilterChanged(ContactTypeFilter.all);
|
||||||
|
break;
|
||||||
|
case _actionFilterUsers:
|
||||||
|
onTypeFilterChanged(ContactTypeFilter.users);
|
||||||
|
break;
|
||||||
|
case _actionFilterRepeaters:
|
||||||
|
onTypeFilterChanged(ContactTypeFilter.repeaters);
|
||||||
|
break;
|
||||||
|
case _actionFilterRooms:
|
||||||
|
onTypeFilterChanged(ContactTypeFilter.rooms);
|
||||||
|
break;
|
||||||
|
case _actionToggleUnreadOnly:
|
||||||
|
onUnreadOnlyChanged(!showUnreadOnly);
|
||||||
|
break;
|
||||||
|
case _actionNewGroup:
|
||||||
|
onNewGroup();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../services/path_history_service.dart';
|
||||||
|
import 'path_selection_dialog.dart';
|
||||||
|
|
||||||
|
class PathManagementDialog {
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required Contact contact,
|
||||||
|
String title = 'Path Management',
|
||||||
|
}) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _PathManagementDialog(
|
||||||
|
contact: contact,
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PathManagementDialog extends StatelessWidget {
|
||||||
|
final Contact contact;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const _PathManagementDialog({
|
||||||
|
required this.contact,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
Contact _resolveContact(MeshCoreConnector connector) {
|
||||||
|
return connector.contacts.firstWhere(
|
||||||
|
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||||
|
orElse: () => contact,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRelativeTime(DateTime time) {
|
||||||
|
final diff = DateTime.now().difference(time);
|
||||||
|
if (diff.inSeconds < 60) return 'Just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
return '${diff.inDays}d ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
|
||||||
|
if (pathBytes.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Path details not available yet. Try sending a message to refresh.'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final formattedPath = pathBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Full Path'),
|
||||||
|
content: SelectableText(formattedPath),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setCustomPath(
|
||||||
|
BuildContext context,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
Contact currentContact,
|
||||||
|
) async {
|
||||||
|
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
|
||||||
|
connector.getContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
final pathForInput = currentContact.pathIdList;
|
||||||
|
final availableContacts = connector.contacts
|
||||||
|
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final result = await PathSelectionDialog.show(
|
||||||
|
context,
|
||||||
|
availableContacts: availableContacts,
|
||||||
|
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
||||||
|
title: 'Set Custom Path',
|
||||||
|
currentPathLabel: currentContact.pathLabel,
|
||||||
|
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && context.mounted) {
|
||||||
|
await connector.setPathOverride(
|
||||||
|
currentContact,
|
||||||
|
pathLen: result.length,
|
||||||
|
pathBytes: result,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Path set: ${result.length} ${result.length == 1 ? "hop" : "hops"}'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer2<MeshCoreConnector, PathHistoryService>(
|
||||||
|
builder: (context, connector, pathService, _) {
|
||||||
|
final currentContact = _resolveContact(connector);
|
||||||
|
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Current path: ${currentContact.pathLabel}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (paths.isNotEmpty) ...[
|
||||||
|
const Text(
|
||||||
|
'Recent ACK Paths (tap to use):',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
|
if (paths.length >= 100) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amberAccent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Path history is full. Remove entries to add new ones.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...paths.map((path) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
|
||||||
|
child: Text(
|
||||||
|
'${path.hopCount}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} successes',
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
tooltip: 'Remove path',
|
||||||
|
onPressed: () async {
|
||||||
|
await pathService.removePathRecord(
|
||||||
|
currentContact.publicKeyHex,
|
||||||
|
path.pathBytes,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
path.wasFloodDiscovery
|
||||||
|
? const Icon(Icons.waves, size: 16, color: Colors.grey)
|
||||||
|
: const Icon(Icons.route, size: 16, color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
|
||||||
|
onTap: () async {
|
||||||
|
if (path.pathBytes.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Path details not available yet. Try sending a message to refresh.'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pathBytes = Uint8List.fromList(path.pathBytes);
|
||||||
|
final pathLength = path.pathBytes.length;
|
||||||
|
|
||||||
|
await connector.setPathOverride(
|
||||||
|
currentContact,
|
||||||
|
pathLen: pathLength,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const Divider(),
|
||||||
|
] else ...[
|
||||||
|
const Text('No path history yet.\nSend a message to discover paths.'),
|
||||||
|
const Divider(),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Path Actions:',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: Colors.purple,
|
||||||
|
child: Icon(Icons.edit_road, size: 16),
|
||||||
|
),
|
||||||
|
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
|
||||||
|
onTap: () async {
|
||||||
|
await _setCustomPath(context, connector, currentContact);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
child: Icon(Icons.clear_all, size: 16),
|
||||||
|
),
|
||||||
|
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
|
||||||
|
onTap: () async {
|
||||||
|
await connector.clearContactPath(currentContact);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Path cleared. Next message will rediscover route.'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
child: Icon(Icons.waves, size: 16),
|
||||||
|
),
|
||||||
|
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
|
||||||
|
onTap: () async {
|
||||||
|
await connector.setPathOverride(currentContact, pathLen: -1);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class PathSelectionDialog extends StatefulWidget {
|
||||||
|
final List<Contact> availableContacts;
|
||||||
|
final String? initialPath;
|
||||||
|
final String title;
|
||||||
|
final String? currentPathLabel;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
const PathSelectionDialog({
|
||||||
|
super.key,
|
||||||
|
required this.availableContacts,
|
||||||
|
this.initialPath,
|
||||||
|
this.title = 'Enter Custom Path',
|
||||||
|
this.currentPathLabel,
|
||||||
|
this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PathSelectionDialog> createState() => _PathSelectionDialogState();
|
||||||
|
|
||||||
|
static Future<Uint8List?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
String? initialPath,
|
||||||
|
String title = 'Enter Custom Path',
|
||||||
|
String? currentPathLabel,
|
||||||
|
VoidCallback? onRefresh,
|
||||||
|
}) {
|
||||||
|
return showDialog<Uint8List?>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => PathSelectionDialog(
|
||||||
|
availableContacts: availableContacts,
|
||||||
|
initialPath: initialPath,
|
||||||
|
title: title,
|
||||||
|
currentPathLabel: currentPathLabel,
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
||||||
|
late TextEditingController _controller;
|
||||||
|
final List<Contact> _selectedContacts = [];
|
||||||
|
List<Contact> _validContacts = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: widget.initialPath ?? '');
|
||||||
|
_filterValidContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(PathSelectionDialog oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.availableContacts != oldWidget.availableContacts) {
|
||||||
|
_filterValidContacts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterValidContacts() {
|
||||||
|
_validContacts = widget.availableContacts
|
||||||
|
.where((c) => c.type == 2 || c.type == 3)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateTextFromContacts() {
|
||||||
|
final pathParts = _selectedContacts.map((contact) {
|
||||||
|
if (contact.publicKeyHex.length >= 2) {
|
||||||
|
return contact.publicKeyHex.substring(0, 2);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).where((s) => s.isNotEmpty).toList();
|
||||||
|
|
||||||
|
_controller.text = pathParts.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleContact(Contact contact) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedContacts.contains(contact)) {
|
||||||
|
_selectedContacts.remove(contact);
|
||||||
|
} else {
|
||||||
|
_selectedContacts.add(contact);
|
||||||
|
}
|
||||||
|
_updateTextFromContacts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_selectedContacts.clear();
|
||||||
|
_controller.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _validateAndSubmit() async {
|
||||||
|
final path = _controller.text.trim().toUpperCase();
|
||||||
|
if (path.isEmpty) {
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse comma-separated hex prefixes
|
||||||
|
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||||
|
final pathBytesList = <int>[];
|
||||||
|
final invalidPrefixes = <String>[];
|
||||||
|
|
||||||
|
for (final id in pathIds) {
|
||||||
|
if (id.length < 2) {
|
||||||
|
invalidPrefixes.add(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefix = id.substring(0, 2);
|
||||||
|
try {
|
||||||
|
final byte = int.parse(prefix, radix: 16);
|
||||||
|
pathBytesList.add(byte);
|
||||||
|
} catch (e) {
|
||||||
|
invalidPrefixes.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Show error for invalid prefixes
|
||||||
|
if (invalidPrefixes.isNotEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max path length (64 hops)
|
||||||
|
if (pathBytesList.length > 64) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Path too long. Maximum 64 hops allowed.'),
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathBytesList.isNotEmpty && mounted) {
|
||||||
|
Navigator.pop(context, Uint8List.fromList(pathBytesList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.title),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.currentPathLabel != null) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Current path',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (widget.onRefresh != null)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: widget.onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh, size: 16),
|
||||||
|
label: const Text('Reload'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.currentPathLabel!,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
const Text(
|
||||||
|
'Enter 2-character hex prefixes for each hop, separated by commas.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Example: A1,F2,3C (each node uses first byte of its public key)',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Path (hex prefixes)',
|
||||||
|
hintText: 'A1,F2,3C',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
maxLength: 191, // 64 hops * 2 chars + 63 commas
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Or select from contacts:',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (_selectedContacts.isNotEmpty)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_validContacts.isEmpty) ...[
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No repeaters or room servers found.',
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Custom paths require intermediate hops that can relay messages.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: _validContacts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final contact = _validContacts[index];
|
||||||
|
final isSelected = _selectedContacts.contains(contact);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? Colors.green
|
||||||
|
: (contact.type == 2 ? Colors.blue : Colors.purple),
|
||||||
|
child: Icon(
|
||||||
|
contact.type == 2 ? Icons.router : Icons.meeting_room,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
'${contact.typeLabel} • ${contact.publicKeyHex.substring(0, 2)}',
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? const Icon(Icons.check_circle, color: Colors.green)
|
||||||
|
: const Icon(Icons.add_circle_outline),
|
||||||
|
onTap: () => _toggleContact(contact),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _validateAndSubmit,
|
||||||
|
child: const Text('Set Path'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import 'path_management_dialog.dart';
|
||||||
|
|
||||||
class RepeaterLoginDialog extends StatefulWidget {
|
class RepeaterLoginDialog extends StatefulWidget {
|
||||||
final Contact repeater;
|
final Contact repeater;
|
||||||
@@ -31,8 +32,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
late MeshCoreConnector _connector;
|
late MeshCoreConnector _connector;
|
||||||
int _currentAttempt = 0;
|
int _currentAttempt = 0;
|
||||||
final int _maxAttempts = RepeaterCommandService.maxRetries;
|
static const int _maxAttempts = 5;
|
||||||
static const int _loginTimeoutSeconds = 10;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -65,6 +65,13 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
|
|
||||||
bool _isLoggingIn = false;
|
bool _isLoggingIn = false;
|
||||||
|
|
||||||
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||||
|
return connector.contacts.firstWhere(
|
||||||
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||||
|
orElse: () => widget.repeater,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
if (_isLoggingIn) return;
|
if (_isLoggingIn) return;
|
||||||
|
|
||||||
@@ -75,6 +82,26 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final password = _passwordController.text;
|
final password = _passwordController.text;
|
||||||
|
final repeater = _resolveRepeater(_connector);
|
||||||
|
appLogger.info(
|
||||||
|
'Login started for ${repeater.name} (${repeater.publicKeyHex})',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
|
final selection = await _connector.preparePathForContactSend(repeater);
|
||||||
|
final loginFrame = buildSendLoginFrame(repeater.publicKey, password);
|
||||||
|
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||||
|
final timeoutMs = _connector.calculateTimeout(
|
||||||
|
pathLength: pathLengthValue,
|
||||||
|
messageBytes: loginFrame.length,
|
||||||
|
);
|
||||||
|
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||||
|
final timeout = Duration(milliseconds: timeoutMs);
|
||||||
|
final selectionLabel =
|
||||||
|
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
|
||||||
|
appLogger.info(
|
||||||
|
'Login routing: $selectionLabel',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
bool? loginResult;
|
bool? loginResult;
|
||||||
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
|
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -82,17 +109,46 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
_currentAttempt = attempt + 1;
|
_currentAttempt = attempt + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
appLogger.info(
|
||||||
|
'Sending login attempt ${attempt + 1}/$_maxAttempts',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
await _connector.sendFrame(
|
await _connector.sendFrame(
|
||||||
buildSendLoginFrame(widget.repeater.publicKey, password),
|
loginFrame,
|
||||||
);
|
);
|
||||||
|
|
||||||
loginResult = await _awaitLoginResponse();
|
loginResult = await _awaitLoginResponse(timeout);
|
||||||
if (loginResult == true) {
|
if (loginResult == true) {
|
||||||
|
appLogger.info(
|
||||||
|
'Login succeeded for ${repeater.name}',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (loginResult == false) {
|
if (loginResult == false) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Login failed for ${repeater.name}',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
throw Exception('Wrong password or node is unreachable');
|
throw Exception('Wrong password or node is unreachable');
|
||||||
}
|
}
|
||||||
|
appLogger.warn(
|
||||||
|
'Login attempt ${attempt + 1} timed out after ${timeoutSeconds}s',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginResult == null) {
|
||||||
|
appLogger.warn(
|
||||||
|
'Login timed out for ${repeater.name}',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginResult == true) {
|
||||||
|
_connector.recordRepeaterPathResult(repeater, selection, true, null);
|
||||||
|
} else {
|
||||||
|
_connector.recordRepeaterPathResult(repeater, selection, false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loginResult != true) {
|
if (loginResult != true) {
|
||||||
@@ -114,6 +170,11 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
Future.microtask(() => widget.onLogin(password));
|
Future.microtask(() => widget.onLogin(password));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
final repeater = _resolveRepeater(_connector);
|
||||||
|
appLogger.warn(
|
||||||
|
'Login error for ${repeater.name}: $e',
|
||||||
|
tag: 'RepeaterLogin',
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggingIn = false;
|
_isLoggingIn = false;
|
||||||
@@ -128,7 +189,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool?> _awaitLoginResponse() async {
|
Future<bool?> _awaitLoginResponse(Duration timeout) async {
|
||||||
final completer = Completer<bool?>();
|
final completer = Completer<bool?>();
|
||||||
Timer? timer;
|
Timer? timer;
|
||||||
StreamSubscription<Uint8List>? subscription;
|
StreamSubscription<Uint8List>? subscription;
|
||||||
@@ -147,7 +208,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
timer?.cancel();
|
timer?.cancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
timer = Timer(const Duration(seconds: _loginTimeoutSeconds), () {
|
timer = Timer(timeout, () {
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
completer.complete(null);
|
completer.complete(null);
|
||||||
subscription?.cancel();
|
subscription?.cancel();
|
||||||
@@ -162,6 +223,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
final isFloodMode = repeater.pathOverride == -1;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -173,7 +237,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
children: [
|
children: [
|
||||||
const Text('Repeater Login'),
|
const Text('Repeater Login'),
|
||||||
Text(
|
Text(
|
||||||
widget.repeater.name,
|
repeater.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
@@ -244,6 +308,73 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Routing',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
|
tooltip: 'Routing mode',
|
||||||
|
onSelected: (mode) async {
|
||||||
|
if (mode == 'flood') {
|
||||||
|
await connector.setPathOverride(repeater, pathLen: -1);
|
||||||
|
} else {
|
||||||
|
await connector.setPathOverride(repeater, 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
repeater.pathLabel,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||||
|
icon: const Icon(Icons.timeline, size: 18),
|
||||||
|
label: const Text('Manage Paths'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -268,7 +399,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text('Retries $_currentAttempt/$_maxAttempts'),
|
Text('Attempt $_currentAttempt/$_maxAttempts'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -28,5 +28,7 @@
|
|||||||
<string>MainMenu</string>
|
<string>MainMenu</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<string>NSApplication</string>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>MeshCore needs Bluetooth to communicate with LoRa mesh devices</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+5
-5
@@ -465,13 +465,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.0"
|
version: "8.3.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -817,10 +817,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus
|
name: wakelock_plus
|
||||||
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.3.3"
|
||||||
wakelock_plus_platform_interface:
|
wakelock_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 0.2.0+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -49,6 +49,7 @@ dependencies:
|
|||||||
flutter_foreground_task: ^6.1.2
|
flutter_foreground_task: ^6.1.2
|
||||||
wakelock_plus: ^1.2.8
|
wakelock_plus: ^1.2.8
|
||||||
characters: ^1.4.0
|
characters: ^1.4.0
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user