🔄 Changes

Core Features
Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges
Message Deletion: Users can now long-press to delete individual messages in chats and channels
SMAZ Compression: Added per-contact compression settings (previously only channels)
UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly
Channel Message Paths: New screen to visualize packet routing through repeater network with map view
Protocol Updates
Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation
Changed channel PSK format from Base64 to Hexadecimal (breaking change)
Added app version field to connection handshake frame
UI Improvements
Unread badges on all contact and channel list items
Enhanced message bubbles with path visualization for channel messages
Character count displays in message input fields
Improved repeater CLI screen functionality
New Files
lib/storage/unread_store.dart - Unread tracking persistence
lib/storage/contact_settings_store.dart - Per-contact SMAZ settings
lib/widgets/unread_badge.dart - Unread count indicator
lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter
lib/screens/channel_message_path_screen.dart - Packet path visualization
This commit is contained in:
zach 2025-12-26 13:33:03 -07:00
parent e7a5b9e209
commit 02ca7801ea
25 changed files with 1656 additions and 259 deletions

View file

@ -21,8 +21,10 @@ import '../services/notification_service.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart';
import '../storage/message_store.dart';
import '../storage/unread_store.dart';
import 'meshcore_protocol.dart';
class MeshCoreUuids {
@ -44,6 +46,8 @@ class MeshCoreConnector extends ChangeNotifier {
BluetoothDevice? _device;
BluetoothCharacteristic? _rxCharacteristic;
BluetoothCharacteristic? _txCharacteristic;
String? _deviceDisplayName;
String? _deviceId;
final List<ScanResult> _scanResults = [];
final List<Contact> _contacts = [];
@ -95,14 +99,36 @@ class MeshCoreConnector extends ChangeNotifier {
final MessageStore _messageStore = MessageStore();
final ChannelOrderStore _channelOrderStore = ChannelOrderStore();
final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore();
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
final ContactStore _contactStore = ContactStore();
final UnreadStore _unreadStore = UnreadStore();
final Map<int, bool> _channelSmazEnabled = {};
final Map<String, bool> _contactSmazEnabled = {};
final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {};
final Map<int, int> _channelLastReadMs = {};
String? _activeContactKey;
int? _activeChannelIndex;
List<int> _channelOrder = [];
// Getters
MeshCoreConnectionState get state => _state;
BluetoothDevice? get device => _device;
String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown';
String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) {
return _selfName!;
}
final platformName = _device?.platformName;
if (platformName != null && platformName.isNotEmpty) {
return platformName;
}
if (_deviceDisplayName != null && _deviceDisplayName!.isNotEmpty) {
return _deviceDisplayName!;
}
return 'Unknown Device';
}
List<ScanResult> get scanResults => List.unmodifiable(_scanResults);
List<Contact> get contacts => List.unmodifiable(_contacts);
List<Channel> get channels => List.unmodifiable(_channels);
@ -162,6 +188,16 @@ class MeshCoreConnector extends ChangeNotifier {
return _conversations[contact.publicKeyHex] ?? [];
}
Future<void> deleteMessage(Message message) async {
final contactKeyHex = message.senderKeyHex;
final messages = _conversations[contactKeyHex];
if (messages == null) return;
final removed = messages.remove(message);
if (!removed) return;
await _messageStore.saveMessages(contactKeyHex, messages);
notifyListeners();
}
Future<void> _loadMessagesForContact(String contactKeyHex) async {
if (_loadedConversationKeys.contains(contactKeyHex)) return;
_loadedConversationKeys.add(contactKeyHex);
@ -177,16 +213,122 @@ class MeshCoreConnector extends ChangeNotifier {
return _channelMessages[channel.index] ?? [];
}
Future<void> deleteChannelMessage(ChannelMessage message) async {
final channelIndex = message.channelIndex;
if (channelIndex == null) return;
final messages = _channelMessages[channelIndex];
if (messages == null) return;
final removed = messages.remove(message);
if (!removed) return;
await _channelMessageStore.saveChannelMessages(channelIndex, messages);
notifyListeners();
}
int getUnreadCountForContact(Contact contact) {
if (contact.type == advTypeRepeater) return 0;
return getUnreadCountForContactKey(contact.publicKeyHex);
}
int getUnreadCountForContactKey(String contactKeyHex) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0;
final messages = _conversations[contactKeyHex];
if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _contactLastReadMs[contactKeyHex] ?? 0;
var count = 0;
for (final message in messages) {
if (message.isOutgoing || message.isCli) continue;
if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
count++;
}
}
return count;
}
int getUnreadCountForChannel(Channel channel) {
return getUnreadCountForChannelIndex(channel.index);
}
int getUnreadCountForChannelIndex(int channelIndex) {
final messages = _channelMessages[channelIndex];
if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _channelLastReadMs[channelIndex] ?? 0;
var count = 0;
for (final message in messages) {
if (message.isOutgoing) continue;
if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
count++;
}
}
return count;
}
bool isChannelSmazEnabled(int channelIndex) {
return _channelSmazEnabled[channelIndex] ?? false;
}
bool isContactSmazEnabled(String contactKeyHex) {
return _contactSmazEnabled[contactKeyHex] ?? false;
}
void ensureContactSmazSettingLoaded(String contactKeyHex) {
_ensureContactSmazSettingLoaded(contactKeyHex);
}
Future<void> loadUnreadState() async {
_contactLastReadMs
..clear()
..addAll(await _unreadStore.loadContactLastRead());
_channelLastReadMs
..clear()
..addAll(await _unreadStore.loadChannelLastRead());
notifyListeners();
}
void setActiveContact(String? contactKeyHex) {
if (contactKeyHex != null && !_shouldTrackUnreadForContactKey(contactKeyHex)) {
_activeContactKey = null;
return;
}
_activeContactKey = contactKeyHex;
if (contactKeyHex != null) {
markContactRead(contactKeyHex);
}
}
void setActiveChannel(int? channelIndex) {
_activeChannelIndex = channelIndex;
if (channelIndex != null) {
markChannelRead(channelIndex);
}
}
void markContactRead(String contactKeyHex) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
final markMs = _calculateReadTimestampMs(
_conversations[contactKeyHex]?.map((m) => m.timestamp),
);
_setContactLastReadMs(contactKeyHex, markMs);
}
void markChannelRead(int channelIndex) {
final markMs = _calculateReadTimestampMs(
_channelMessages[channelIndex]?.map((m) => m.timestamp),
);
_setChannelLastReadMs(channelIndex, markMs);
}
Future<void> setChannelSmazEnabled(int channelIndex, bool enabled) async {
_channelSmazEnabled[channelIndex] = enabled;
await _channelSettingsStore.saveSmazEnabled(channelIndex, enabled);
notifyListeners();
}
Future<void> setContactSmazEnabled(String contactKeyHex, bool enabled) async {
_contactSmazEnabled[contactKeyHex] = enabled;
await _contactSettingsStore.saveSmazEnabled(contactKeyHex, enabled);
notifyListeners();
}
Future<void> _loadChannelOrder() async {
_channelOrder = await _channelOrderStore.loadChannelOrder();
_applyChannelOrder();
@ -244,6 +386,9 @@ class MeshCoreConnector extends ChangeNotifier {
_knownContactKeys
..clear()
..addAll(cached.map((c) => c.publicKeyHex));
for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
}
}
Future<void> loadChannelSettings({int? maxChannels}) async {
@ -262,10 +407,11 @@ class MeshCoreConnector extends ChangeNotifier {
int timestampSeconds,
) async {
if (!isConnected || text.isEmpty) return;
final outboundText = _prepareContactOutboundText(contact, text);
await sendFrame(
buildSendTextMsgFrame(
contact.publicKey,
text,
outboundText,
forceFlood: forceFlood,
attempt: attempt,
timestampSeconds: timestampSeconds,
@ -354,7 +500,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<void> connect(BluetoothDevice device) async {
Future<void> connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
return;
@ -363,6 +509,13 @@ class MeshCoreConnector extends ChangeNotifier {
await stopScan();
_setState(MeshCoreConnectionState.connecting);
_device = device;
_deviceId = device.remoteId.toString();
if (displayName != null && displayName.trim().isNotEmpty) {
_deviceDisplayName = displayName.trim();
} else if (device.platformName.isNotEmpty) {
_deviceDisplayName = device.platformName;
}
notifyListeners();
try {
_connectionSubscription = device.connectionState.listen((state) {
@ -418,6 +571,13 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.connected);
await _requestDeviceInfo();
final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
if (!gotSelfInfo) {
await refreshDeviceInfo();
await _waitForSelfInfo(timeout: const Duration(seconds: 3));
}
// Keep device clock aligned on every connection.
await syncTime();
@ -428,6 +588,37 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<bool> _waitForSelfInfo({required Duration timeout}) async {
if (_selfPublicKey != null) return true;
if (!isConnected) return false;
final completer = Completer<bool>();
late final VoidCallback listener;
listener = () {
if (_selfPublicKey != null) {
if (!completer.isCompleted) {
completer.complete(true);
}
} else if (!isConnected) {
if (!completer.isCompleted) {
completer.complete(false);
}
}
};
addListener(listener);
final timer = Timer(timeout, () {
if (!completer.isCompleted) {
completer.complete(false);
}
});
final result = await completer.future;
timer.cancel();
removeListener(listener);
return result;
}
Future<void> disconnect() async {
if (_state == MeshCoreConnectionState.disconnecting) return;
@ -450,6 +641,8 @@ class MeshCoreConnector extends ChangeNotifier {
_device = null;
_rxCharacteristic = null;
_txCharacteristic = null;
_deviceDisplayName = null;
_deviceId = null;
_contacts.clear();
_conversations.clear();
_loadedConversationKeys.clear();
@ -507,6 +700,7 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildAppStartFrame());
await requestBatteryStatus(force: true);
await sendFrame(buildGetRadioSettingsFrame());
_scheduleSelfInfoRetry();
}
Future<void> _requestDeviceInfo() async {
@ -515,11 +709,25 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildAppStartFrame());
await requestBatteryStatus();
_scheduleSelfInfoRetry();
}
void _scheduleSelfInfoRetry() {
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = Timer(const Duration(milliseconds: 3500), () {
if (!isConnected || !_awaitingSelfInfo) return;
sendFrame(buildAppStartFrame());
});
_selfInfoRetryTimer = Timer.periodic(
const Duration(milliseconds: 3500),
(timer) {
if (!isConnected) {
timer.cancel();
return;
}
if (!_awaitingSelfInfo) {
timer.cancel();
return;
}
unawaited(sendFrame(buildAppStartFrame()));
},
);
}
Future<void> getContacts({int? since, bool preserveExisting = false}) async {
@ -606,7 +814,14 @@ class MeshCoreConnector extends ChangeNotifier {
);
_addMessage(contact.publicKeyHex, message);
notifyListeners();
await sendFrame(buildSendTextMsgFrame(contact.publicKey, text, forceFlood: forceFlood));
final outboundText = _prepareContactOutboundText(contact, text);
await sendFrame(
buildSendTextMsgFrame(
contact.publicKey,
outboundText,
forceFlood: forceFlood,
),
);
}
}
@ -646,6 +861,10 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex);
_contactLastReadMs.remove(contact.publicKeyHex);
unawaited(_unreadStore.saveContactLastRead(
Map<String, int>.from(_contactLastReadMs),
));
_messageStore.clearMessages(contact.publicKeyHex);
notifyListeners();
}
@ -747,6 +966,10 @@ class MeshCoreConnector extends ChangeNotifier {
// Delete by setting empty name and zero PSK
await sendFrame(buildSetChannelFrame(index, '', Uint8List(16)));
_channelLastReadMs.remove(index);
unawaited(_unreadStore.saveChannelLastRead(
Map<int, int>.from(_channelLastReadMs),
));
// Refresh channels after deleting
await getChannels();
}
@ -892,6 +1115,8 @@ class MeshCoreConnector extends ChangeNotifier {
_selfName = readCString(frame, 58, frame.length - 58);
}
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null;
notifyListeners();
// Auto-fetch contacts after getting self info
@ -995,6 +1220,12 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleContact(Uint8List frame) {
final contact = Contact.fromFrame(frame);
if (contact != null) {
if (contact.type == advTypeRepeater) {
_contactLastReadMs.remove(contact.publicKeyHex);
unawaited(_unreadStore.saveContactLastRead(
Map<String, int>.from(_contactLastReadMs),
));
}
// Check if this is a new contact
final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex);
final existingIndex = _contacts.indexWhere(
@ -1075,6 +1306,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
_addMessage(message.senderKeyHex, message);
_maybeMarkActiveContactRead(message);
notifyListeners();
// Show notification for new incoming message
@ -1130,6 +1362,7 @@ class MeshCoreConnector extends ChangeNotifier {
text = readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4));
}
if (text.isEmpty) return null;
final decodedText = isCli ? text : (Smaz.tryDecodePrefixed(text) ?? text);
final timestampRaw = readUint32LE(frame, timestampOffset);
final pathLenByte = frame[pathLenOffset];
@ -1142,7 +1375,7 @@ class MeshCoreConnector extends ChangeNotifier {
return Message(
senderKey: contact.publicKey,
text: text,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: isCli,
@ -1160,6 +1393,24 @@ class MeshCoreConnector extends ChangeNotifier {
return true;
}
void _ensureContactSmazSettingLoaded(String contactKeyHex) {
if (_contactSmazEnabled.containsKey(contactKeyHex)) return;
_contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) {
if (_contactSmazEnabled[contactKeyHex] == enabled) return;
_contactSmazEnabled[contactKeyHex] = enabled;
notifyListeners();
});
}
String _prepareContactOutboundText(Contact contact, String text) {
final trimmed = text.trim();
final isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:');
if (!isStructuredPayload && isContactSmazEnabled(contact.publicKeyHex)) {
return Smaz.encodeIfSmaller(text);
}
return text;
}
void _handleIncomingChannelMessage(Uint8List frame) {
final message = ChannelMessage.fromFrame(frame);
if (message != null && message.channelIndex != null) {
@ -1167,6 +1418,7 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
_addChannelMessage(message.channelIndex!, message);
_maybeMarkActiveChannelRead(message);
notifyListeners();
_handleQueuedMessageReceived();
} else if (_isSyncingQueuedMessages) {
@ -1219,6 +1471,7 @@ class MeshCoreConnector extends ChangeNotifier {
);
_addChannelMessage(channel.index, message);
_maybeMarkActiveChannelRead(message);
notifyListeners();
return;
}
@ -1315,6 +1568,75 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
}
bool _shouldTrackUnreadForContactKey(String contactKeyHex) {
final contact = _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == contactKeyHex,
orElse: () => null,
);
if (contact == null) return true;
return contact.type != advTypeRepeater;
}
int _calculateReadTimestampMs(Iterable<DateTime>? timestamps) {
var latestMs = 0;
if (timestamps != null) {
for (final timestamp in timestamps) {
final ms = timestamp.millisecondsSinceEpoch;
if (ms > latestMs) {
latestMs = ms;
}
}
}
return latestMs;
}
void _setContactLastReadMs(String contactKeyHex, int timestampMs, {bool notify = true}) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
final existing = _contactLastReadMs[contactKeyHex] ?? 0;
if (timestampMs <= existing) return;
_contactLastReadMs[contactKeyHex] = timestampMs;
unawaited(_unreadStore.saveContactLastRead(
Map<String, int>.from(_contactLastReadMs),
));
if (notify) {
notifyListeners();
}
}
void _setChannelLastReadMs(int channelIndex, int timestampMs, {bool notify = true}) {
final existing = _channelLastReadMs[channelIndex] ?? 0;
if (timestampMs <= existing) return;
_channelLastReadMs[channelIndex] = timestampMs;
unawaited(_unreadStore.saveChannelLastRead(
Map<int, int>.from(_channelLastReadMs),
));
if (notify) {
notifyListeners();
}
}
void _maybeMarkActiveContactRead(Message message) {
if (message.isOutgoing || message.isCli) return;
if (_activeContactKey != message.senderKeyHex) return;
if (!_shouldTrackUnreadForContactKey(message.senderKeyHex)) return;
_setContactLastReadMs(
message.senderKeyHex,
message.timestamp.millisecondsSinceEpoch,
notify: false,
);
}
void _maybeMarkActiveChannelRead(ChannelMessage message) {
if (message.isOutgoing) return;
final channelIndex = message.channelIndex;
if (channelIndex == null || _activeChannelIndex != channelIndex) return;
_setChannelLastReadMs(
channelIndex,
message.timestamp.millisecondsSinceEpoch,
notify: false,
);
}
void _addMessage(String pubKeyHex, Message message) {
_conversations.putIfAbsent(pubKeyHex, () => []);
_conversations[pubKeyHex]!.add(message);
@ -1525,6 +1847,8 @@ class MeshCoreConnector extends ChangeNotifier {
_device = null;
_rxCharacteristic = null;
_txCharacteristic = null;
_deviceDisplayName = null;
_deviceId = null;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;

View file

@ -86,6 +86,35 @@ const int pathHashSize = 1;
const int maxNameSize = 32;
const int maxFrameSize = 172;
const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160;
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1;
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1;
int maxContactMessageBytes() {
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
return _minPositive(byFrame, maxTextPayloadBytes);
}
int maxChannelMessageBytes(String? senderName) {
final nameLength = _senderNameBytes(senderName);
final prefixBytes = nameLength + 2; // "<name>: "
final byPayload = maxTextPayloadBytes - prefixBytes;
final byFrame = maxFrameSize - _sendChannelTextMsgOverheadBytes;
return _minPositive(byPayload, byFrame);
}
int _senderNameBytes(String? senderName) {
if (senderName == null || senderName.isEmpty) return maxNameSize - 1;
final bytes = utf8.encode(senderName);
final maxBytes = maxNameSize - 1;
return bytes.length > maxBytes ? maxBytes : bytes.length;
}
int _minPositive(int a, int b) {
final minValue = a < b ? a : b;
return minValue < 0 ? 0 : minValue;
}
// Contact frame offsets
const int contactPubKeyOffset = 1;
@ -295,12 +324,16 @@ Uint8List buildRemoveContactFrame(Uint8List pubKey) {
}
// Build CMD_APP_START frame
// Format: [cmd][reserved x7][app_name...]
Uint8List buildAppStartFrame({String appName = 'MeshCoreOpen'}) {
// Format: [cmd][app_ver][reserved x6][app_name...]
Uint8List buildAppStartFrame({
String appName = 'MeshCoreOpen',
int appVersion = 1,
}) {
final nameBytes = utf8.encode(appName);
final frame = Uint8List(8 + nameBytes.length + 1);
frame[0] = cmdAppStart;
// bytes 1-7 are reserved (zeros)
frame[1] = appVersion;
// bytes 2-7 are reserved (zeros)
frame.setRange(8, 8 + nameBytes.length, nameBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;

View file

@ -0,0 +1,36 @@
import 'dart:convert';
import 'package:flutter/services.dart';
class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
final int maxBytes;
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
if (maxBytes <= 0) return oldValue;
final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue;
final truncated = _truncateToMaxBytes(newValue.text, maxBytes);
return TextEditingValue(
text: truncated,
selection: TextSelection.collapsed(offset: truncated.length),
composing: TextRange.empty,
);
}
String _truncateToMaxBytes(String text, int limit) {
final buffer = StringBuffer();
var used = 0;
for (final rune in text.runes) {
final char = String.fromCharCode(rune);
final charBytes = utf8.encode(char).length;
if (used + charBytes > limit) break;
buffer.write(char);
used += charBytes;
}
return buffer.toString();
}
}

View file

@ -41,6 +41,7 @@ void main() async {
// Load persisted channel messages
await connector.loadAllChannelMessages();
await connector.loadUnreadState();
runApp(MeshCoreApp(
connector: connector,

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
@ -14,11 +13,11 @@ class Channel {
required this.psk,
});
String get pskBase64 => base64Encode(psk);
String get pskHex => _bytesToHex(psk);
bool get isEmpty => name.isEmpty && psk.every((b) => b == 0);
bool get isPublicChannel => pskBase64 == publicChannelPsk;
bool get isPublicChannel => pskHex == publicChannelPsk;
static Channel? fromFrame(Uint8List data) {
// CHANNEL_INFO format:
@ -44,14 +43,31 @@ class Channel {
);
}
static Channel fromPsk(int index, String name, String pskBase64) {
final pskBytes = base64Decode(pskBase64);
final psk = Uint8List(16);
for (int i = 0; i < pskBytes.length && i < 16; i++) {
psk[i] = pskBytes[i];
}
static Channel fromHex(int index, String name, String pskHex) {
final psk = parsePskHex(pskHex);
return Channel(index: index, name: name, psk: psk);
}
static const String publicChannelPsk = 'izOH6cXN6mrJ5e26oRXNcg==';
static Uint8List parsePskHex(String hex) {
final cleaned = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
if (cleaned.length != 32) {
throw const FormatException('PSK must be 32 hex characters');
}
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
final start = i * 2;
bytes[i] = int.parse(cleaned.substring(start, start + 2), radix: 16);
}
return bytes;
}
static String formatPskHex(Uint8List psk) {
return _bytesToHex(psk);
}
static String _bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72';
}

View file

@ -277,7 +277,7 @@ class AppSettingsScreen extends StatelessWidget {
AppSettingsService settingsService,
MeshCoreConnector connector,
) {
final deviceId = connector.device?.remoteId.toString();
final deviceId = connector.deviceId;
final isConnected = connector.isConnected && deviceId != null;
final selection =
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
@ -298,7 +298,7 @@ class AppSettingsScreen extends StatelessWidget {
title: const Text('Battery Chemistry'),
subtitle: Text(
isConnected
? 'Set per device (${connector.device!.platformName})'
? 'Set per device (${connector.deviceDisplayName})'
: 'Connect to a device to choose',
),
trailing: DropdownButton<String>(

View file

@ -1,15 +1,20 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../utils/emoji_utils.dart';
import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget {
@ -28,8 +33,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
});
}
@override
void dispose() {
context.read<MeshCoreConnector>().setActiveChannel(null);
_textController.dispose();
_scrollController.dispose();
super.dispose();
@ -66,9 +81,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
Text(
widget.channel.isPublicChannel ? 'Public' : 'Private',
style: const TextStyle(fontSize: 12),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final unreadCount =
connector.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel ? 'Public' : 'Private';
return Text(
'$privacy • Unread: $unreadCount',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
);
},
),
],
),
@ -158,7 +181,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
Flexible(
child: GestureDetector(
onLongPress: () => _showMessagePathInfo(message),
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
@ -383,6 +407,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildMessageComposer() {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@ -432,6 +458,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return TextField(
controller: _textController,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
@ -464,7 +493,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final text = _textController.text.trim();
if (text.isEmpty) return;
context.read<MeshCoreConnector>().sendChannelMessage(widget.channel, text);
final connector = context.read<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(text).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
);
return;
}
connector.sendChannelMessage(widget.channel, text);
_textController.clear();
}
@ -480,65 +518,67 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
void _showMessagePathInfo(ChannelMessage message) {
final pathPrefixes =
message.pathBytes.isNotEmpty ? _formatPathPrefixes(message.pathBytes) : null;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Packet Path'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('Sender', message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)),
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
if (pathPrefixes != null) _buildDetailRow('Prefixes', pathPrefixes),
if (pathPrefixes == null) ...[
const SizedBox(height: 8),
const Text(
'Hop details are not provided for this packet.',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: message),
),
);
}
String _formatPathLabel(int? pathLength) {
if (pathLength == null) return 'Unknown';
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
void _showMessageActions(ChannelMessage message) {
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy'),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
},
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
onTap: () => Navigator.pop(sheetContext),
),
],
),
),
);
}
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')),
);
}
Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message deleted')),
);
}
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 70,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
),
Expanded(child: Text(value)),
],
),
);
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
}

View file

@ -0,0 +1,420 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message;
const ChannelMessagePathScreen({
super.key,
required this.message,
});
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final hops = _buildPathHops(message.pathBytes, connector.contacts);
final hasHopDetails = message.pathBytes.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('Packet Path'),
actions: [
IconButton(
icon: const Icon(Icons.map_outlined),
tooltip: 'View map',
onPressed: hasHopDetails
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
ChannelMessagePathMapScreen(message: message),
),
);
}
: null,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSummaryCard(context),
const SizedBox(height: 16),
Text(
'Repeater Hops',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (!hasHopDetails)
const Text(
'Hop details are not provided for this packet.',
style: TextStyle(color: Colors.grey),
)
else
..._buildHopTiles(hops),
],
),
);
},
);
}
Widget _buildSummaryCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Message Details',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildDetailRow('Sender', message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)),
if (message.repeatCount > 0)
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
],
),
),
);
}
List<Widget> _buildHopTiles(List<_PathHop> hops) {
return [
for (final hop in hops)
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
child: Text(
hop.index.toString(),
style: const TextStyle(fontSize: 12),
),
),
title: Text(hop.displayLabel),
subtitle: Text(
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
),
),
),
];
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} '
'${time.hour}:${time.minute.toString().padLeft(2, '0')}';
}
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
}
String _formatPathLabel(int? pathLength) {
if (pathLength == null) return 'Unknown';
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 70,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
),
Expanded(child: Text(value)),
],
),
);
}
}
class ChannelMessagePathMapScreen extends StatelessWidget {
final ChannelMessage message;
const ChannelMessagePathMapScreen({
super.key,
required this.message,
});
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final hops = _buildPathHops(message.pathBytes, connector.contacts);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
.toList();
final polylines = points.length > 1
? [
Polyline(
points: points,
strokeWidth: 4,
color: Colors.blueAccent,
),
]
: <Polyline>[];
final initialCenter =
points.isNotEmpty ? points.first : const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
return Scaffold(
appBar: AppBar(
title: const Text('Path Map'),
),
body: Stack(
children: [
FlutterMap(
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
initialCameraFit: bounds == null
? null
: CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
minZoom: 2.0,
maxZoom: 18.0,
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.open',
maxZoom: 19,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
MarkerLayer(
markers: _buildHopMarkers(hops),
),
],
),
if (points.isEmpty)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: const Padding(
padding: EdgeInsets.all(12),
child: Text('No repeater locations available for this path.'),
),
),
),
_buildLegendCard(context, hops),
],
),
);
},
);
}
List<Marker> _buildHopMarkers(List<_PathHop> hops) {
return [
for (final hop in hops)
if (hop.hasLocation)
Marker(
point: hop.position!,
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
];
}
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (hops.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
return Positioned(
left: 16,
right: 16,
bottom: 16,
child: SizedBox(
height: cardHeight,
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Repeater Hops',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
const Divider(height: 1),
Expanded(
child: hops.isEmpty
? const Center(
child: Text('No hop details available for this packet.'),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: hops.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final hop = hops[index];
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
child: Text(
hop.index.toString(),
style: const TextStyle(fontSize: 12),
),
),
title: Text(hop.displayLabel),
subtitle: Text(
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
),
);
},
),
),
],
),
),
),
);
}
}
class _PathHop {
final int index;
final int prefix;
final Contact? contact;
final LatLng? position;
const _PathHop({
required this.index,
required this.prefix,
required this.contact,
required this.position,
});
bool get hasLocation => position != null;
String get displayLabel {
final prefixLabel = _formatPrefix(prefix);
return '($prefixLabel) ${_resolveName(contact)}';
}
}
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final prefix = pathBytes[i];
final contact = _matchContactForPrefix(contacts, prefix);
hops.add(
_PathHop(
index: i + 1,
prefix: prefix,
contact: contact,
position: _resolvePosition(contact),
),
);
}
return hops;
}
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts
.where((contact) => contact.publicKey.isNotEmpty && contact.publicKey[0] == prefix)
.toList();
if (matches.isEmpty) return null;
Contact? pickWhere(bool Function(Contact) predicate) {
for (final contact in matches) {
if (predicate(contact)) return contact;
}
return null;
}
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
pickWhere((c) => c.type == advTypeRepeater) ??
pickWhere(_hasValidLocation) ??
matches.first;
}
LatLng? _resolvePosition(Contact? contact) {
if (contact == null) return null;
if (!_hasValidLocation(contact)) return null;
return LatLng(contact.latitude!, contact.longitude!);
}
bool _hasValidLocation(Contact contact) {
final lat = contact.latitude;
final lon = contact.longitude;
if (lat == null || lon == null) return false;
if (lat == 0 && lon == 0) return false;
return true;
}
String _formatPrefix(int prefix) {
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
}
String _resolveName(Contact? contact) {
if (contact == null) return 'Unknown Repeater';
final name = contact.name.trim();
if (name.isEmpty || name.toLowerCase() == 'unknown') {
return 'Unknown Repeater';
}
return name;
}

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
@ -7,6 +6,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
import '../widgets/unread_badge.dart';
import 'channel_chat_screen.dart';
class ChannelsScreen extends StatefulWidget {
@ -99,6 +99,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
MeshCoreConnector connector,
Channel channel,
) {
final unreadCount = connector.getUnreadCountForChannel(channel);
return Card(
key: ValueKey('channel_${channel.index}'),
child: ListTile(
@ -129,6 +130,10 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _showEditChannelDialog(context, connector, channel),
@ -148,12 +153,15 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
),
onTap: () {
connector.markChannelRead(channel.index);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
);
},
),
);
}
@ -225,7 +233,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Base64)',
labelText: 'PSK (Hex)',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
@ -236,7 +244,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
pskController.text = base64Encode(bytes);
pskController.text = Channel.formatPskHex(bytes);
},
),
),
@ -253,7 +261,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskBase64 = usePublicPsk
final pskHex = usePublicPsk
? Channel.publicChannelPsk
: pskController.text.trim();
@ -266,14 +274,10 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
Uint8List psk;
try {
final decoded = base64Decode(pskBase64);
psk = Uint8List(16);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
} catch (e) {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid PSK format')),
const SnackBar(content: Text('PSK must be 32 hex characters')),
);
return;
}
@ -298,7 +302,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
Channel channel,
) {
final nameController = TextEditingController(text: channel.name);
final pskController = TextEditingController(text: channel.pskBase64);
final pskController = TextEditingController(text: channel.pskHex);
bool smazEnabled = connector.isChannelSmazEnabled(channel.index);
showDialog(
@ -322,7 +326,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Base64)',
labelText: 'PSK (Hex)',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
@ -333,7 +337,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
pskController.text = base64Encode(bytes);
pskController.text = Channel.formatPskHex(bytes);
},
),
),
@ -356,18 +360,14 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskBase64 = pskController.text.trim();
final pskHex = pskController.text.trim();
Uint8List psk;
try {
final decoded = base64Decode(pskBase64);
psk = Uint8List(16);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
} catch (e) {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid PSK format')),
const SnackBar(content: Text('PSK must be 32 hex characters')),
);
return;
}
@ -418,11 +418,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
}
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
final psk = Uint8List(16);
final decoded = base64Decode(Channel.publicChannelPsk);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
connector.setChannel(0, 'Public', psk);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Public channel added')),

View file

@ -1,13 +1,19 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../services/path_history_service.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../utils/emoji_utils.dart';
import '../widgets/gif_message.dart';
@ -27,8 +33,18 @@ class _ChatScreenState extends State<ChatScreen> {
final _scrollController = ScrollController();
bool _forceFlood = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
});
}
@override
void dispose() {
context.read<MeshCoreConnector>().setActiveContact(null);
_textController.dispose();
_scrollController.dispose();
super.dispose();
@ -43,6 +59,8 @@ class _ChatScreenState extends State<ChatScreen> {
final paths = pathService.getRecentPaths(widget.contact.publicKeyHex);
final contact = _resolveContact(connector);
final showRecentPath = paths.isNotEmpty && contact.pathLength >= 0;
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@ -53,19 +71,22 @@ class _ChatScreenState extends State<ChatScreen> {
behavior: HitTestBehavior.opaque,
onLongPress: () => _showFullPathDialog(context, paths.first.pathBytes),
child: Text(
paths.first.displayText,
'${paths.first.displayText}$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
)
else if (contact.pathLength >= 0)
Text(
'${contact.pathLength} ${contact.pathLength == 1 ? 'hop' : 'hops'}',
'${contact.pathLength} ${contact.pathLength == 1 ? 'hop' : 'hops'}$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
)
else
const Text(
'No path',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
Text(
'No path • $unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
],
);
@ -177,15 +198,15 @@ class _ChatScreenState extends State<ChatScreen> {
return _MessageBubble(
message: message,
senderName: widget.contact.name,
onLongPress: message.isOutgoing && message.status == MessageStatus.failed
? () => _showMessageRetry(context, message)
: null,
onTap: () => _openMessagePath(message),
onLongPress: () => _showMessageActions(message),
);
},
);
}
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@ -231,6 +252,9 @@ class _ChatScreenState extends State<ChatScreen> {
return TextField(
controller: _textController,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
decoration: const InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(),
@ -275,6 +299,14 @@ class _ChatScreenState extends State<ChatScreen> {
final text = _textController.text.trim();
if (text.isEmpty) return;
final maxBytes = maxContactMessageBytes();
if (utf8.encode(text).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
);
return;
}
connector.sendMessage(
widget.contact,
text,
@ -543,30 +575,52 @@ class _ChatScreenState extends State<ChatScreen> {
}
void _showContactInfo(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(widget.contact.name),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('Type', widget.contact.typeLabel),
_buildInfoRow('Path', widget.contact.pathLabel),
if (widget.contact.hasLocation)
_buildInfoRow(
'Location',
'${widget.contact.latitude?.toStringAsFixed(4)}, ${widget.contact.longitude?.toStringAsFixed(4)}',
builder: (context) => Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
return AlertDialog(
title: Text(contact.name),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('Type', contact.typeLabel),
_buildInfoRow('Path', contact.pathLabel),
if (contact.hasLocation)
_buildInfoRow(
'Location',
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow('Public Key', contact.publicKeyHex.substring(0, 16) + '...'),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'),
subtitle: const Text('Compress outgoing messages'),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(contact.publicKeyHex, value);
},
),
],
),
_buildInfoRow('Public Key', widget.contact.publicKeyHex.substring(0, 16) + '...'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
},
),
);
}
@ -940,27 +994,87 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
void _showMessageRetry(BuildContext context, Message message) {
void _openMessagePath(Message message) {
final connector = context.read<MeshCoreConnector>();
final senderName =
message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.name;
final pathMessage = ChannelMessage(
senderKey: null,
senderName: senderName,
text: message.text,
timestamp: message.timestamp,
isOutgoing: message.isOutgoing,
status: ChannelMessageStatus.sent,
repeatCount: 0,
pathLength: message.pathLength,
pathBytes: message.pathBytes,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: pathMessage),
),
);
}
void _showMessageActions(Message message) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
leading: const Icon(Icons.copy),
title: const Text('Copy'),
onTap: () {
Navigator.pop(context);
_retryMessage(message);
Navigator.pop(sheetContext);
_copyMessageText(message.text);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
},
),
if (message.isOutgoing && message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
onTap: () {
Navigator.pop(sheetContext);
_retryMessage(message);
},
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
onTap: () => Navigator.pop(sheetContext),
),
],
),
),
);
}
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')),
);
}
Future<void> _deleteMessage(Message message) async {
await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message deleted')),
);
}
void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.sendMessage(
@ -977,11 +1091,13 @@ class _ChatScreenState extends State<ChatScreen> {
class _MessageBubble extends StatelessWidget {
final Message message;
final String senderName;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const _MessageBubble({
required this.message,
required this.senderName,
this.onTap,
this.onLongPress,
});
@ -1004,6 +1120,7 @@ class _MessageBubble extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
@ -1176,10 +1293,6 @@ class _MessageBubble extends StatelessWidget {
);
}
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
}
Widget _buildAvatar(String senderName, ColorScheme colorScheme) {
final initial = _getFirstCharacterOrEmoji(senderName);
final color = _getColorForName(senderName);

View file

@ -7,6 +7,7 @@ import '../models/contact.dart';
import '../models/contact_group.dart';
import '../storage/contact_group_store.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/unread_badge.dart';
import '../utils/emoji_utils.dart';
import 'chat_screen.dart';
import 'repeater_hub_screen.dart';
@ -29,6 +30,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
bool _showUnreadOnly = false;
final ContactGroupStore _groupStore = ContactGroupStore();
List<ContactGroup> _groups = [];
@ -166,6 +168,18 @@ class _ContactsScreenState extends State<ContactsScreen> {
),
],
),
IconButton(
icon: Icon(
Icons.mark_chat_unread_outlined,
color: _showUnreadOnly ? Theme.of(context).primaryColor : null,
),
tooltip: _showUnreadOnly ? 'Showing unread only' : 'Show unread only',
onPressed: () {
setState(() {
_showUnreadOnly = !_showUnreadOnly;
});
},
),
IconButton(
icon: const Icon(Icons.group_add),
tooltip: 'New group',
@ -222,7 +236,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
}
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
final filteredGroups = _filterAndSortGroups(_groups, contacts);
final filteredGroups =
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts);
return Column(
children: [
@ -265,7 +280,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No contacts or groups found',
_showUnreadOnly
? 'No unread contacts'
: 'No contacts or groups found',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
@ -281,8 +298,10 @@ class _ContactsScreenState extends State<ContactsScreen> {
return _buildGroupTile(context, group, contacts);
}
final contact = filteredAndSorted[index - filteredGroups.length];
final unreadCount = connector.getUnreadCountForContact(contact);
return _ContactTile(
contact: contact,
unreadCount: unreadCount,
onTap: () => _openChat(context, contact),
onLongPress: () => _showContactOptions(context, connector, contact),
);
@ -324,6 +343,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
return contact.name.toLowerCase().contains(_searchQuery);
}).toList();
if (_showUnreadOnly) {
filtered = filtered.where((contact) {
return connector.getUnreadCountForContact(contact) > 0;
}).toList();
}
switch (_sortOption) {
case ContactSortOption.lastSeen:
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
@ -399,6 +424,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
if (contact.type == advTypeRepeater) {
_showRepeaterLogin(context, contact);
} else {
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
@ -702,11 +728,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
class _ContactTile extends StatelessWidget {
final Contact contact;
final int unreadCount;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTile({
required this.contact,
required this.unreadCount,
required this.onTap,
required this.onLongPress,
});
@ -724,6 +752,10 @@ class _ContactTile extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(contact.lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),

View file

@ -35,7 +35,7 @@ class _DeviceScreenState extends State<DeviceScreen> {
canPop: false,
child: Scaffold(
appBar: AppBar(
title: Text(connector.device?.platformName ?? 'MeshCore Device'),
title: Text(connector.deviceDisplayName),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
@ -82,7 +82,7 @@ class _DeviceScreenState extends State<DeviceScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connector.device?.platformName ?? 'Unknown Device',
connector.deviceDisplayName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -90,7 +90,7 @@ class _DeviceScreenState extends State<DeviceScreen> {
),
const SizedBox(height: 4),
Text(
connector.device?.remoteId.toString() ?? '',
connector.deviceIdLabel,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],

View file

@ -852,7 +852,7 @@ class _MapScreenState extends State<MapScreen> {
}
bool _isPublicChannel(Channel channel) {
return channel.pskBase64 == Channel.publicChannelPsk;
return channel.isPublicChannel;
}
Future<bool> _confirmPublicShare(BuildContext context, String channelLabel) async {

View file

@ -24,6 +24,7 @@ class RepeaterCliScreen extends StatefulWidget {
class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
final TextEditingController _commandController = TextEditingController();
final FocusNode _commandFocusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
final List<Map<String, String>> _commandHistory = [];
int _historyIndex = -1;
@ -54,6 +55,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
_frameSubscription?.cancel();
_commandService?.dispose();
_commandController.dispose();
_commandFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@ -377,6 +379,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Expanded(
child: TextField(
controller: _commandController,
focusNode: _commandFocusNode,
decoration: const InputDecoration(
hintText: 'Enter command...',
border: OutlineInputBorder(),
@ -399,7 +402,284 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
);
}
void _applyHelpCommand(String command) {
_commandController.text = command;
_commandController.selection = TextSelection.fromPosition(
TextPosition(offset: command.length),
);
Navigator.pop(context);
Future.microtask(() {
if (mounted) {
_commandFocusNode.requestFocus();
}
});
}
void _showCommandHelp(BuildContext context) {
final generalCommands = [
const _CommandHelpEntry(
command: 'advert',
description: 'Sends an advertisement packet',
),
const _CommandHelpEntry(
command: 'reboot',
description:
"Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
),
const _CommandHelpEntry(
command: 'clock',
description: "Displays current time per device's clock.",
),
const _CommandHelpEntry(
command: 'password {new-password}',
description: 'Sets a new admin password for the device.',
),
const _CommandHelpEntry(
command: 'ver',
description: 'Shows the device version and firmware build date.',
),
const _CommandHelpEntry(
command: 'clear stats',
description: 'Resets various stats counters to zero.',
),
];
final settingsCommands = [
const _CommandHelpEntry(
command: 'set af {air-time-factor}',
description: 'Sets the air-time-factor.',
),
const _CommandHelpEntry(
command: 'set tx {tx-power-dbm}',
description: 'Sets LoRa transmit power in dBm. (reboot to apply)',
),
const _CommandHelpEntry(
command: 'set repeat {on|off}',
description: 'Enables or disables the repeater role for this node.',
),
const _CommandHelpEntry(
command: 'set allow.read.only {on|off}',
description:
"(Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
),
const _CommandHelpEntry(
command: 'set flood.max {max-hops}',
description:
'Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
),
const _CommandHelpEntry(
command: 'set int.thresh {db}',
description:
'Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
),
const _CommandHelpEntry(
command: 'set agc.reset.interval {seconds}',
description:
'Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
),
const _CommandHelpEntry(
command: 'set multi.acks {0|1}',
description: "Enables or disables the 'double ACKs' feature.",
),
const _CommandHelpEntry(
command: 'set advert.interval {minutes}',
description:
'Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
),
const _CommandHelpEntry(
command: 'set flood.advert.interval {hours}',
description:
'Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
),
const _CommandHelpEntry(
command: 'set guest.password {guess-password}',
description:
'Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
),
const _CommandHelpEntry(
command: 'set name {name}',
description: 'Sets the advertisement name.',
),
const _CommandHelpEntry(
command: 'set lat {latitude}',
description: 'Sets the advertisement map latitude. (decimal degrees)',
),
const _CommandHelpEntry(
command: 'set lon {longitude}',
description: 'Sets the advertisement map longitude. (decimal degrees)',
),
const _CommandHelpEntry(
command: 'set radio {freq},{bw},{sf},{cr}',
description:
'Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
),
const _CommandHelpEntry(
command: 'set rxdelay {base}',
description:
'Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
),
const _CommandHelpEntry(
command: 'set txdelay {factor}',
description:
'Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
),
const _CommandHelpEntry(
command: 'set direct.txdelay {factor}',
description:
'Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
),
const _CommandHelpEntry(
command: 'set bridge.enabled {on|off}',
description: 'Enable/Disable bridge.',
),
const _CommandHelpEntry(
command: 'set bridge.delay {0-10000}',
description: 'Set delay before retransmitting packets.',
),
const _CommandHelpEntry(
command: 'set bridge.source {rx|tx}',
description:
'Choose wether the bridge will retransmit received packets or transmitted packets.',
),
const _CommandHelpEntry(
command: 'set bridge.baud {speed}',
description: 'Set serial link baudrate for rs232 bridges.',
),
const _CommandHelpEntry(
command: 'set bridge.secret {shared-secret}',
description: 'Set bridge secret for espnow bridges.',
),
const _CommandHelpEntry(
command: 'set adc.multiplier {factor}',
description:
'Sets custom factor to adjust reported battery voltage (only supported on select boards).',
),
const _CommandHelpEntry(
command: 'tempradio {freq},{bw},{sf},{cr},{minutes}',
description:
'Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
),
const _CommandHelpEntry(
command: 'setperm {pubkey-hex} {permissions}',
description:
'Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
),
];
final bridgeCommands = [
const _CommandHelpEntry(
command: 'get bridge.type',
description: 'Gets bridge type none, rs232, espnow',
),
];
final loggingCommands = [
const _CommandHelpEntry(
command: 'log start',
description: 'Starts packet logging to file system.',
),
const _CommandHelpEntry(
command: 'log stop',
description: 'Stops packet logging to file system.',
),
const _CommandHelpEntry(
command: 'log erase',
description: 'Erases the packet logs from file system.',
),
];
final neighborCommands = [
const _CommandHelpEntry(
command: 'neighbors',
description:
'Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
),
const _CommandHelpEntry(
command: 'neighbor.remove {pubkey-prefix}',
description:
'Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
),
];
final regionCommands = [
const _CommandHelpEntry(
command: 'region',
description:
'(serial only) Lists all defined regions and current flood permissions.',
),
const _CommandHelpEntry(
command: 'region load',
description:
'NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
),
const _CommandHelpEntry(
command: 'region get {* | name-prefix}',
description:
'Searches for region with given name prefix (or "*" for the global scope). Replies with "-> {region-name} ({parent-name}) {\'F\'}"',
),
const _CommandHelpEntry(
command: 'region put {name} {* | parent-name-prefix}',
description: 'Adds or updates a region definition with given name.',
),
const _CommandHelpEntry(
command: 'region remove {name}',
description:
'Removes a region definition with given name. (must match exactly, and have no child regions)',
),
const _CommandHelpEntry(
command: 'region allowf {* | name-prefix}',
description:
"Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
),
const _CommandHelpEntry(
command: 'region denyf {* | name-prefix}',
description:
"Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
),
const _CommandHelpEntry(
command: 'region home',
description:
"Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
),
const _CommandHelpEntry(
command: 'region home {* | name-prefix}',
description: "Sets the 'home' region.",
),
const _CommandHelpEntry(
command: 'region save',
description: 'Persists the region list/map to storage.',
),
];
final gpsCommands = [
const _CommandHelpEntry(
command: 'gps',
description:
'Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
),
const _CommandHelpEntry(
command: 'gps {on|off}',
description: 'Toggles gps power state.',
),
const _CommandHelpEntry(
command: 'gps sync',
description: 'Syncs node time with gps clock.',
),
const _CommandHelpEntry(
command: 'gps setloc',
description: "Sets node's position to gps coordinates and save preferences.",
),
const _CommandHelpEntry(
command: 'gps advert',
description:
"Gives location advert configuration of the node:\n- none: don't include location in adverts\n- share: share gps location (from SensorManager)\n- prefs: advert the location stored in preferences",
),
const _CommandHelpEntry(
command: 'gps advert {none|share|prefs}',
description: 'Sets location advert configuration.',
),
];
showDialog(
context: context,
builder: (context) => AlertDialog(
@ -414,85 +694,35 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
_buildHelpSection('General', [
'advert - Sends an advertisement packet',
"reboot - Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
"clock - Displays current time per device's clock.",
'password {new-password} - Sets a new admin password for the device.',
'ver - Shows the device version and firmware build date.',
'clear stats - Resets various stats counters to zero.',
]),
_buildHelpSection(context, 'General', generalCommands),
const SizedBox(height: 16),
_buildHelpSection('Settings', [
'set af {air-time-factor} - Sets the air-time-factor.',
'set tx {tx-power-dbm} - Sets LoRa transmit power in dBm. (reboot to apply)',
'set repeat {on|off} - Enables or disables the repeater role for this node.',
"set allow.read.only {on|off} - (Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
'set flood.max {max-hops} - Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
'set int.thresh {db} - Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
'set agc.reset.interval {seconds} - Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
"set multi.acks {0|1} - Enables or disables the 'double ACKs' feature.",
'set advert.interval {minutes} - Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
'set flood.advert.interval {hours} - Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
'set guest.password {guess-password} - Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
'set name {name} - Sets the advertisement name.',
'set lat {latitude} - Sets the advertisement map latitude. (decimal degrees)',
'set lon {longitude} - Sets the advertisement map longitude. (decimal degrees)',
'set radio {freq},{bw},{sf},{cr} - Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
'set rxdelay {base} - Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
'set txdelay {factor} - Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
'set direct.txdelay {factor} - Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
'set bridge.enabled {on|off} - Enable/Disable bridge.',
'set bridge.delay {0-10000} - Set delay before retransmitting packets.',
'set bridge.source {rx|tx} - Choose wether the bridge will retransmit received packets or transmitted packets.',
'set bridge.baud {speed} - Set serial link baudrate for rs232 bridges.',
'set bridge.secret {shared-secret} - Set bridge secret for espnow bridges.',
'set adc.multiplier {factor} - Sets custom factor to adjust reported battery voltage (only supported on select boards).',
'tempradio {freq},{bw},{sf},{cr},{minutes} - Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
'setperm {pubkey-hex} {permissions} - Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
]),
_buildHelpSection(context, 'Settings', settingsCommands),
const SizedBox(height: 16),
_buildHelpSection('Bridge', [
'get bridge.type - Gets bridge type none, rs232, espnow',
]),
_buildHelpSection(context, 'Bridge', bridgeCommands),
const SizedBox(height: 16),
_buildHelpSection('Logging', [
'log start - Starts packet logging to file system.',
'log stop - Stops packet logging to file system.',
'log erase - Erases the packet logs from file system.',
]),
_buildHelpSection(context, 'Logging', loggingCommands),
const SizedBox(height: 16),
_buildHelpSection('Neighbors (Repeater only)', [
'neighbors - Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
'neighbor.remove {pubkey-prefix} - Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
]),
_buildHelpSection(
context,
'Neighbors (Repeater only)',
neighborCommands,
),
const SizedBox(height: 16),
_buildHelpSection('Region Management (Repeater only)', [
'region commands have been introduced to manage region definitions and permissions.',
'region - (serial only) Lists all defined regions and current flood permissions.',
'region load - NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
"region get {* | name-prefix} - Searches for region with given name prefix (or '*' for the global scope). Replies with \"-> {region-name} ({parent-name}) {'F'}\"",
'region put {name} {* | parent-name-prefix} - Adds or updates a region definition with given name.',
'region remove {name} - Removes a region definition with given name. (must match exactly, and have no child regions)',
"region allowf {* | name-prefix} - Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
"region denyf {* | name-prefix} - Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
"region home - Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
"region home {* | name-prefix} - Sets the 'home' region.",
'region save - Persists the region list/map to storage.',
]),
_buildHelpSection(
context,
'Region Management (Repeater only)',
regionCommands,
note:
'Region commands have been introduced to manage region definitions and permissions.',
),
const SizedBox(height: 16),
_buildHelpSection('GPS Management', [
'gps command has been introduced to manage location related topics.',
'gps - Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
'gps {on|off} - Toggles gps power state.',
'gps sync - Syncs node time with gps clock.',
"gps setloc - Sets node's position to gps coordinates and save preferences.",
'gps advert - Gives location advert configuration of the node:',
"none: don't include location in adverts",
'share: share gps location (from SensorManager)',
'prefs: advert the location stored in preferences',
'gps advert {none|share|prefs} - Sets location advert configuration.',
]),
_buildHelpSection(
context,
'GPS Management',
gpsCommands,
note:
'gps command has been introduced to manage location related topics.',
),
],
),
),
@ -506,7 +736,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
);
}
Widget _buildHelpSection(String title, List<String> commands) {
Widget _buildHelpSection(
BuildContext context,
String title,
List<_CommandHelpEntry> commands, {
String? note,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -514,15 +749,68 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
if (note != null) ...[
const SizedBox(height: 6),
Text(
note,
style: const TextStyle(fontSize: 12),
),
],
const SizedBox(height: 8),
...commands.map((cmd) => Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Text(
'$cmd',
style: const TextStyle(fontSize: 13, fontFamily: 'monospace'),
),
)),
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
],
);
}
Widget _buildHelpCommandCard(BuildContext context, _CommandHelpEntry entry) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 8),
color: colorScheme.surfaceVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _applyHelpCommand(entry.command),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.command,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
fontWeight: FontWeight.bold,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Text(
entry.description,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _CommandHelpEntry {
final String command;
final String description;
const _CommandHelpEntry({
required this.command,
required this.description,
});
}

View file

@ -445,10 +445,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
commands.add('set privacy ${_privacyMode ? "on" : "off"}');
// Advertisement intervals
commands.add('set advert.interval ${_advertInterval}');
commands.add('set flood.advert.interval ${_floodAdvertInterval}');
commands.add('set advert.interval $_advertInterval');
commands.add('set flood.advert.interval $_floodAdvertInterval');
if (_privacyMode) {
commands.add('set priv.advert.interval ${_privAdvertInterval}');
commands.add('set priv.advert.interval $_privAdvertInterval');
}
// Send all commands
@ -661,7 +661,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: _bandwidth,
initialValue: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
@ -683,7 +683,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: _spreadingFactor,
initialValue: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
@ -705,7 +705,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: _codingRate,
initialValue: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),
@ -841,7 +841,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const Divider(),
ListTile(
title: const Text('Local Advertisement Interval'),
subtitle: Text('${_advertInterval} minutes'),
subtitle: Text('$_advertInterval minutes'),
trailing: Text('${_advertInterval}m'),
),
Slider(
@ -860,7 +860,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
ListTile(
title: const Text('Flood Advertisement Interval'),
subtitle: Text('${_floodAdvertInterval} hours'),
subtitle: Text('$_floodAdvertInterval hours'),
trailing: Text('${_floodAdvertInterval}h'),
),
Slider(
@ -880,7 +880,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
ListTile(
title: const Text('Encrypted Advertisement Interval'),
subtitle: Text('${_privAdvertInterval} minutes'),
subtitle: Text('$_privAdvertInterval minutes'),
trailing: Text('${_privAdvertInterval}m'),
),
Slider(

View file

@ -76,7 +76,7 @@ class ScannerScreen extends StatelessWidget {
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.connected:
statusText = 'Connected to ${connector.device?.platformName}';
statusText = 'Connected to ${connector.deviceDisplayName}';
statusColor = Colors.green;
break;
case MeshCoreConnectionState.disconnecting:
@ -152,7 +152,10 @@ class ScannerScreen extends StatelessWidget {
ScanResult result,
) async {
try {
await connector.connect(result.device);
final name = result.device.platformName.isNotEmpty
? result.device.platformName
: result.advertisementData.advName;
await connector.connect(result.device, displayName: name);
if (context.mounted && connector.isConnected) {
Navigator.push(

View file

@ -53,8 +53,8 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildInfoRow('Name', connector.device?.platformName ?? 'Unknown'),
_buildInfoRow('ID', connector.device?.remoteId.toString() ?? 'Unknown'),
_buildInfoRow('Name', connector.deviceDisplayName),
_buildInfoRow('ID', connector.deviceIdLabel),
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
if (connector.selfName != null)
_buildInfoRow('Node Name', connector.selfName!),

View file

@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import '../connector/meshcore_protocol.dart';

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/contact.dart';

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';

View file

@ -0,0 +1,17 @@
import 'package:shared_preferences/shared_preferences.dart';
class ContactSettingsStore {
static const String _smazKeyPrefix = 'contact_smaz_';
Future<bool> loadSmazEnabled(String contactKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_smazKeyPrefix$contactKeyHex';
return prefs.getBool(key) ?? false;
}
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_smazKeyPrefix$contactKeyHex';
await prefs.setBool(key, enabled);
}
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
import '../helpers/smaz.dart';
class MessageStore {
static const String _keyPrefix = 'messages_';
@ -55,12 +56,15 @@ class MessageStore {
}
Message _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String;
final isCli = json['isCli'] as bool? ?? false;
final decodedText = isCli ? rawText : (Smaz.tryDecodePrefixed(rawText) ?? rawText);
return Message(
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
text: json['text'] as String,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
isOutgoing: json['isOutgoing'] as bool,
isCli: json['isCli'] as bool? ?? false,
isCli: isCli,
status: MessageStatus.values[json['status'] as int],
messageId: json['messageId'] as String?,
retryCount: json['retryCount'] as int? ?? 0,

View file

@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class UnreadStore {
static const String _contactLastReadKey = 'contact_last_read';
static const String _channelLastReadKey = 'channel_last_read';
Future<Map<String, int>> loadContactLastRead() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_contactLastReadKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int));
} catch (_) {
return {};
}
}
Future<void> saveContactLastRead(Map<String, int> lastReadMs) async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(lastReadMs);
await prefs.setString(_contactLastReadKey, jsonStr);
}
Future<Map<int, int>> loadChannelLastRead() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_channelLastReadKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(int.parse(key), value as int));
} catch (_) {
return {};
}
}
Future<void> saveChannelLastRead(Map<int, int> lastReadMs) async {
final prefs = await SharedPreferences.getInstance();
final asString = lastReadMs.map((key, value) => MapEntry(key.toString(), value));
final jsonStr = jsonEncode(asString);
await prefs.setString(_channelLastReadKey, jsonStr);
}
}

View file

@ -156,8 +156,8 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
});
final result = await completer.future;
timer?.cancel();
await subscription?.cancel();
timer.cancel();
await subscription.cancel();
return result;
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class UnreadBadge extends StatelessWidget {
final int count;
const UnreadBadge({
super.key,
required this.count,
});
@override
Widget build(BuildContext context) {
final display = count > 99 ? '99+' : count.toString();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.redAccent,
borderRadius: BorderRadius.circular(10),
),
child: Text(
display,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
}