diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index c756dfa..60880df 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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 _scanResults = []; final List _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 _channelSmazEnabled = {}; + final Map _contactSmazEnabled = {}; final Set _knownContactKeys = {}; + final Map _contactLastReadMs = {}; + final Map _channelLastReadMs = {}; + String? _activeContactKey; + int? _activeChannelIndex; List _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 get scanResults => List.unmodifiable(_scanResults); List get contacts => List.unmodifiable(_contacts); List get channels => List.unmodifiable(_channels); @@ -162,6 +188,16 @@ class MeshCoreConnector extends ChangeNotifier { return _conversations[contact.publicKeyHex] ?? []; } + Future 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 _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 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 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 setChannelSmazEnabled(int channelIndex, bool enabled) async { _channelSmazEnabled[channelIndex] = enabled; await _channelSettingsStore.saveSmazEnabled(channelIndex, enabled); notifyListeners(); } + Future setContactSmazEnabled(String contactKeyHex, bool enabled) async { + _contactSmazEnabled[contactKeyHex] = enabled; + await _contactSettingsStore.saveSmazEnabled(contactKeyHex, enabled); + notifyListeners(); + } + Future _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 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 connect(BluetoothDevice device) async { + Future 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 _waitForSelfInfo({required Duration timeout}) async { + if (_selfPublicKey != null) return true; + if (!isConnected) return false; + + final completer = Completer(); + 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 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 _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 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.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.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.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().firstWhere( + (c) => c?.publicKeyHex == contactKeyHex, + orElse: () => null, + ); + if (contact == null) return true; + return contact.type != advTypeRepeater; + } + + int _calculateReadTimestampMs(Iterable? 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.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.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; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index b46d372..6f09ac1 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -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; // ": " + 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; diff --git a/lib/helpers/utf8_length_limiter.dart b/lib/helpers/utf8_length_limiter.dart new file mode 100644 index 0000000..843389e --- /dev/null +++ b/lib/helpers/utf8_length_limiter.dart @@ -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(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 2909bf9..ea0e144 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ void main() async { // Load persisted channel messages await connector.loadAllChannelMessages(); + await connector.loadUnreadState(); runApp(MeshCoreApp( connector: connector, diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 40a3ebb..79df62d 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -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'; } diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 2592a97..b6ec3a1 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -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( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index e9b7476..ab16b91 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -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 { final TextEditingController _textController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().setActiveChannel(widget.channel.index); + }); + } + @override void dispose() { + context.read().setActiveChannel(null); _textController.dispose(); _scrollController.dispose(); super.dispose(); @@ -66,9 +81,17 @@ class _ChannelChatScreenState extends State { : widget.channel.name, style: const TextStyle(fontSize: 16), ), - Text( - widget.channel.isPublicChannel ? 'Public' : 'Private', - style: const TextStyle(fontSize: 12), + Consumer( + 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 { ], 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 { } Widget _buildMessageComposer() { + final connector = context.watch(); + final maxBytes = maxChannelMessageBytes(connector.selfName); return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -432,6 +458,9 @@ class _ChannelChatScreenState extends State { return TextField( controller: _textController, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter(maxBytes), + ], decoration: InputDecoration( hintText: 'Type a message...', border: OutlineInputBorder( @@ -464,7 +493,16 @@ class _ChannelChatScreenState extends State { final text = _textController.text.trim(); if (text.isEmpty) return; - context.read().sendChannelMessage(widget.channel, text); + final connector = context.read(); + 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 { } 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 _deleteMessage(ChannelMessage message) async { + await context.read().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(','); } } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart new file mode 100644 index 0000000..bbd7b97 --- /dev/null +++ b/lib/screens/channel_message_path_screen.dart @@ -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( + 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 _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( + 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, + ), + ] + : []; + + 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 _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 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 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; +} diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index fe5e061..145a639 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -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 { 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 { 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 { ), ], ), - 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { } 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')), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 8dfddb8..2509eb3 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -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 { final _scrollController = ScrollController(); bool _forceFlood = false; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().setActiveContact(widget.contact.publicKeyHex); + }); + } + @override void dispose() { + context.read().setActiveContact(null); _textController.dispose(); _scrollController.dispose(); super.dispose(); @@ -43,6 +59,8 @@ class _ChatScreenState extends State { 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 { 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 { 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 { return TextField( controller: _textController, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter(maxBytes), + ], decoration: const InputDecoration( hintText: 'Type a message...', border: OutlineInputBorder(), @@ -275,6 +299,14 @@ class _ChatScreenState extends State { 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 { } void _showContactInfo(BuildContext context) { + final connector = Provider.of(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( + 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 { ); } - void _showMessageRetry(BuildContext context, Message message) { + void _openMessagePath(Message message) { + final connector = context.read(); + 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 _deleteMessage(Message message) async { + await context.read().deleteMessage(message); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message deleted')), + ); + } + void _retryMessage(Message message) { final connector = Provider.of(context, listen: false); connector.sendMessage( @@ -977,11 +1091,13 @@ class _ChatScreenState extends State { 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); diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 26e2209..8425725 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -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 { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; ContactSortOption _sortOption = ContactSortOption.lastSeen; + bool _showUnreadOnly = false; final ContactGroupStore _groupStore = ContactGroupStore(); List _groups = []; @@ -166,6 +168,18 @@ class _ContactsScreenState extends State { ), ], ), + 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 { } final filteredAndSorted = _filterAndSortContacts(contacts, connector); - final filteredGroups = _filterAndSortGroups(_groups, contacts); + final filteredGroups = + _showUnreadOnly ? const [] : _filterAndSortGroups(_groups, contacts); return Column( children: [ @@ -265,7 +280,9 @@ class _ContactsScreenState extends State { 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 { 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 { 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 { if (contact.type == advTypeRepeater) { _showRepeaterLogin(context, contact); } else { + context.read().markContactRead(contact.publicKeyHex); Navigator.push( context, MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), @@ -702,11 +728,13 @@ class _ContactsScreenState extends State { 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]), diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart index e464141..26d694e 100644 --- a/lib/screens/device_screen.dart +++ b/lib/screens/device_screen.dart @@ -35,7 +35,7 @@ class _DeviceScreenState extends State { 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 { 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 { ), const SizedBox(height: 4), Text( - connector.device?.remoteId.toString() ?? '', + connector.deviceIdLabel, style: TextStyle( fontSize: 12, color: Colors.grey[600], diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index eb15eb3..de1a200 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -852,7 +852,7 @@ class _MapScreenState extends State { } bool _isPublicChannel(Channel channel) { - return channel.pskBase64 == Channel.publicChannelPsk; + return channel.isPublicChannel; } Future _confirmPublicShare(BuildContext context, String channelLabel) async { diff --git a/lib/screens/repeater_cli_screen.dart b/lib/screens/repeater_cli_screen.dart index d91a4f6..4f3025e 100644 --- a/lib/screens/repeater_cli_screen.dart +++ b/lib/screens/repeater_cli_screen.dart @@ -24,6 +24,7 @@ class RepeaterCliScreen extends StatefulWidget { class _RepeaterCliScreenState extends State { final TextEditingController _commandController = TextEditingController(); + final FocusNode _commandFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); final List> _commandHistory = []; int _historyIndex = -1; @@ -54,6 +55,7 @@ class _RepeaterCliScreenState extends State { _frameSubscription?.cancel(); _commandService?.dispose(); _commandController.dispose(); + _commandFocusNode.dispose(); _scrollController.dispose(); super.dispose(); } @@ -377,6 +379,7 @@ class _RepeaterCliScreenState extends State { Expanded( child: TextField( controller: _commandController, + focusNode: _commandFocusNode, decoration: const InputDecoration( hintText: 'Enter command...', border: OutlineInputBorder(), @@ -399,7 +402,284 @@ class _RepeaterCliScreenState extends State { ); } + 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 { 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 { ); } - Widget _buildHelpSection(String title, List 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 { 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, + }); } diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index 494022d..03e02c7 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -445,10 +445,10 @@ class _RepeaterSettingsScreenState extends State { 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 { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _bandwidth, + initialValue: _bandwidth, decoration: const InputDecoration( labelText: 'Bandwidth', border: OutlineInputBorder(), @@ -683,7 +683,7 @@ class _RepeaterSettingsScreenState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _spreadingFactor, + initialValue: _spreadingFactor, decoration: const InputDecoration( labelText: 'Spreading Factor', border: OutlineInputBorder(), @@ -705,7 +705,7 @@ class _RepeaterSettingsScreenState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _codingRate, + initialValue: _codingRate, decoration: const InputDecoration( labelText: 'Coding Rate', border: OutlineInputBorder(), @@ -841,7 +841,7 @@ class _RepeaterSettingsScreenState extends State { 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 { 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 { const SizedBox(height: 16), ListTile( title: const Text('Encrypted Advertisement Interval'), - subtitle: Text('${_privAdvertInterval} minutes'), + subtitle: Text('$_privAdvertInterval minutes'), trailing: Text('${_privAdvertInterval}m'), ), Slider( diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index db3f83c..b558ed8 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -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( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index cee2237..586a501 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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!), diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index b52aebb..6313971 100644 --- a/lib/services/ble_debug_log_service.dart +++ b/lib/services/ble_debug_log_service.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import '../connector/meshcore_protocol.dart'; diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 3443363..0a5b29b 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -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'; diff --git a/lib/services/repeater_command_service.dart b/lib/services/repeater_command_service.dart index f1fd157..9024dcc 100644 --- a/lib/services/repeater_command_service.dart +++ b/lib/services/repeater_command_service.dart @@ -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'; diff --git a/lib/storage/contact_settings_store.dart b/lib/storage/contact_settings_store.dart new file mode 100644 index 0000000..c91c24f --- /dev/null +++ b/lib/storage/contact_settings_store.dart @@ -0,0 +1,17 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class ContactSettingsStore { + static const String _smazKeyPrefix = 'contact_smaz_'; + + Future loadSmazEnabled(String contactKeyHex) async { + final prefs = await SharedPreferences.getInstance(); + final key = '$_smazKeyPrefix$contactKeyHex'; + return prefs.getBool(key) ?? false; + } + + Future saveSmazEnabled(String contactKeyHex, bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + final key = '$_smazKeyPrefix$contactKeyHex'; + await prefs.setBool(key, enabled); + } +} diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 9bc1cde..ecb8299 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -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 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, diff --git a/lib/storage/unread_store.dart b/lib/storage/unread_store.dart new file mode 100644 index 0000000..c66d7c1 --- /dev/null +++ b/lib/storage/unread_store.dart @@ -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> loadContactLastRead() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString(_contactLastReadKey); + if (jsonStr == null) return {}; + + try { + final json = jsonDecode(jsonStr) as Map; + return json.map((key, value) => MapEntry(key, value as int)); + } catch (_) { + return {}; + } + } + + Future saveContactLastRead(Map lastReadMs) async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = jsonEncode(lastReadMs); + await prefs.setString(_contactLastReadKey, jsonStr); + } + + Future> loadChannelLastRead() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString(_channelLastReadKey); + if (jsonStr == null) return {}; + + try { + final json = jsonDecode(jsonStr) as Map; + return json.map((key, value) => MapEntry(int.parse(key), value as int)); + } catch (_) { + return {}; + } + } + + Future saveChannelLastRead(Map 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); + } +} diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index 8dab160..157d1e7 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -156,8 +156,8 @@ class _RepeaterLoginDialogState extends State { }); final result = await completer.future; - timer?.cancel(); - await subscription?.cancel(); + timer.cancel(); + await subscription.cancel(); return result; } diff --git a/lib/widgets/unread_badge.dart b/lib/widgets/unread_badge.dart new file mode 100644 index 0000000..92b7c37 --- /dev/null +++ b/lib/widgets/unread_badge.dart @@ -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, + ), + ), + ); + } +}