diff --git a/documentation/ble-protocol.md b/documentation/ble-protocol.md index 9e72b459..e5ca6972 100644 --- a/documentation/ble-protocol.md +++ b/documentation/ble-protocol.md @@ -120,6 +120,8 @@ On unexpected disconnection, auto-reconnect with exponential backoff: | 40 | CMD_GET_CUSTOM_VAR | Get custom variables | | 41 | CMD_SET_CUSTOM_VAR | Set a custom variable | | 50 | CMD_SEND_BINARY_REQ | Send binary request | +| 54 | CMD_SET_FLOOD_SCOPE | Set flood routing scope (v8+) | +| 55 | CMD_SEND_CONTROL_DATA | Send control data (e.g. zero-hop discovery, v8+) | | 56 | CMD_GET_STATS | Request companion radio stats | | 57 | CMD_SEND_ANON_REQ | Send anonymous request | | 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration | @@ -162,6 +164,7 @@ On unexpected disconnection, auto-reconnect with exponential backoff: | 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered | | 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data | | 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response | +| 0x8E | PUSH_CODE_CONTROL_DATA | Control data push (e.g. zero-hop discovery response) | ## Data Models diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b929c09d..7dd550c4 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:math' as math; import 'package:crypto/crypto.dart' as crypto; +import 'package:meshcore_open/storage/region_store.dart'; import 'package:pointycastle/export.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -35,6 +36,7 @@ import 'meshcore_connector_tcp.dart'; import '../storage/channel_message_store.dart'; import '../storage/channel_order_store.dart'; import '../storage/channel_settings_store.dart'; +import '../storage/channel_region_store.dart'; import '../storage/channel_store.dart'; import '../storage/contact_discovery_store.dart'; import '../storage/contact_settings_store.dart'; @@ -266,6 +268,10 @@ class MeshCoreConnector extends ChangeNotifier { // Serializes path operations (setContactPath/clearContactPath) to prevent // interleaved async calls from leaving in-memory state inconsistent with device. Future _pathOpLock = Future.value(); + // Flood scope is a global firmware setting, so scoped channel sends must not + // overlap or a message may inherit another channel's region. + Future _channelScopedSendLock = Future.value(); + static const Duration _commandAckTimeout = Duration(seconds: 5); Map? _currentCustomVars; /// Maps repeater pubkey-prefix hex (12 hex chars = first 6 bytes) → the @@ -297,6 +303,7 @@ class MeshCoreConnector extends ChangeNotifier { final MessageStore _messageStore = MessageStore(); final ChannelOrderStore _channelOrderStore = ChannelOrderStore(); final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore(); + final ChannelRegionStore _channelRegionStore = ChannelRegionStore(); final ContactSettingsStore _contactSettingsStore = ContactSettingsStore(); final ContactStore _contactStore = ContactStore(); final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore(); @@ -306,6 +313,7 @@ class MeshCoreConnector extends ChangeNotifier { final Map _channelSmazEnabled = {}; final Map _channelCyr2LatEnabled = {}; final Map _channelCyr2LatProfileId = {}; + final Map _channelRegions = {}; bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command final Map _contactSmazEnabled = {}; @@ -704,6 +712,14 @@ class MeshCoreConnector extends ChangeNotifier { return _contactSmazEnabled[contactKeyHex] ?? false; } + bool hasChannelRegion(int channelIndex) { + return (_channelRegions[channelIndex] ?? '').isNotEmpty; + } + + Region getChannelRegion(int channelIndex) { + return _channelRegions[channelIndex] ?? ''; + } + void ensureContactSmazSettingLoaded(String contactKeyHex) { _ensureContactSmazSettingLoaded(contactKeyHex); } @@ -857,6 +873,14 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } + Future setChannelRegion(int channelIndex, String region) async { + // Update in-memory state and notify synchronously so the UI reflects the + // change immediately; persistence happens in the background. + _channelRegions[channelIndex] = region; + notifyListeners(); + await _channelRegionStore.saveRegion(channelIndex, region); + } + Future _loadChannelOrder() async { _channelOrder = await _channelOrderStore.loadChannelOrder(); _applyChannelOrder(); @@ -1015,11 +1039,13 @@ class MeshCoreConnector extends ChangeNotifier { Future loadChannelSettings({int? maxChannels}) async { _channelSmazEnabled.clear(); _channelCyr2LatEnabled.clear(); + _channelRegions.clear(); final channelCount = maxChannels ?? _maxChannels; for (int i = 0; i < channelCount; i++) { _channelSmazEnabled[i] = await _channelSettingsStore.loadSmazEnabled(i); _channelCyr2LatEnabled[i] = await _channelSettingsStore .loadCyr2LatEnabled(i); + _channelRegions[i] = await _channelRegionStore.loadRegion(i); } } @@ -3383,12 +3409,15 @@ class MeshCoreConnector extends ChangeNotifier { // Send the reaction to the device (don't add as a visible message) final reactionQueueId = _nextReactionSendQueueId(); _pendingChannelSentQueue.add(reactionQueueId); - await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( - buildSendChannelTextMsgFrame(channel.index, text), - channelSendQueueId: reactionQueueId, - expectsGenericAck: true, - ); + await _runScopedChannelSend(() async { + await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); + await _sendFrameAndWaitForCommandAck( + buildSendChannelTextMsgFrame(channel.index, text), + channelSendQueueId: reactionQueueId, + expectsGenericAck: true, + successCode: respCodeSent, + ); + }, region: getChannelRegion(channel.index)); return; } @@ -3405,12 +3434,97 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); final outboundText = prepareChannelOutboundText(channel.index, text); - await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( - buildSendChannelTextMsgFrame(channel.index, outboundText), - channelSendQueueId: message.messageId, - expectsGenericAck: true, - ); + await _runScopedChannelSend(() async { + await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); + await _sendFrameAndWaitForCommandAck( + buildSendChannelTextMsgFrame(channel.index, outboundText), + channelSendQueueId: message.messageId, + expectsGenericAck: true, + successCode: respCodeSent, + ); + }, region: getChannelRegion(channel.index)); + } + + Future _runScopedChannelSend( + Future Function() action, { + required String region, + }) async { + final prev = _channelScopedSendLock; + final completer = Completer(); + _channelScopedSendLock = completer.future; + await prev; + + try { + // Only touch the global flood scope for region-scoped channels. Plain + // channels send exactly as before, which also stays compatible with + // firmware that predates CMD_SET_FLOOD_SCOPE. The lock is still held so an + // unscoped send can't interleave with (and inherit the scope of) a + // concurrent scoped send. + if (region.isEmpty) { + await action(); + return; + } + await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame(region)); + try { + await action(); + } finally { + if (isConnected) { + await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame('')); + } + } + } finally { + completer.complete(); + } + } + + // Sends [data] and resolves once the device replies. [successCode] is the + // response code that signals success for this frame: SET_FLOOD_SCOPE replies + // with RESP_CODE_OK, whereas a channel text send replies with RESP_CODE_SENT. + // Waiting for the text send's RESP_CODE_SENT before the scope is reset + // guarantees the firmware has already built the packet with the active scope. + Future _sendFrameAndWaitForCommandAck( + Uint8List data, { + String? channelSendQueueId, + bool expectsGenericAck = false, + int successCode = respCodeOk, + }) async { + final completer = Completer(); + late final StreamSubscription subscription; + late final Timer timeout; + + void complete() { + if (!completer.isCompleted) completer.complete(); + } + + void completeError(Object error) { + if (!completer.isCompleted) completer.completeError(error); + } + + subscription = receivedFrames.listen((frame) { + if (frame.isEmpty) return; + if (frame[0] == successCode) { + complete(); + } else if (frame[0] == respCodeErr) { + final errCode = frame.length > 1 ? frame[1] : -1; + completeError(Exception('Command failed with error code $errCode')); + } + }); + + timeout = Timer(_commandAckTimeout, () { + completeError(TimeoutException('Command ACK timed out')); + }); + + try { + await sendFrame( + data, + channelSendQueueId: channelSendQueueId, + expectsGenericAck: expectsGenericAck, + ); + await completer.future; + } finally { + timeout.cancel(); + await subscription.cancel(); + } } Future removeContact(Contact contact) async { @@ -4017,6 +4131,9 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodePathUpdated: _handlePathUpdated(frame); break; + case pushCodeControlData: + // Optional feature-specific services listen to receivedFrames directly. + break; case pushCodeLoginSuccess: _handleLoginSuccess(frame); break; @@ -4163,6 +4280,7 @@ class MeshCoreConnector extends ChangeNotifier { _messageStore.setPublicKeyHex = selfPublicKeyHex; _channelOrderStore.setPublicKeyHex = selfPublicKeyHex; _channelSettingsStore.setPublicKeyHex = selfPublicKeyHex; + _channelRegionStore.setPublicKeyHex = selfPublicKeyHex; _contactSettingsStore.setPublicKeyHex = selfPublicKeyHex; _contactStore.setPublicKeyHex = selfPublicKeyHex; _channelStore.setPublicKeyHex = selfPublicKeyHex; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 7b594894..45e157e5 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:crypto/crypto.dart' as crypto; import 'package:flutter/widgets.dart'; // Buffer Reader - sequential binary data reader with pointer tracking @@ -206,6 +207,8 @@ const int cmdSendTelemetryReq = 39; const int cmdGetCustomVar = 40; const int cmdSetCustomVar = 41; const int cmdSendBinaryReq = 50; +const int cmdSetFloodScope = 54; +const int cmdSendControlData = 55; const int cmdGetStats = 56; const int cmdSendAnonReq = 57; const int cmdSetAutoAddConfig = 58; @@ -230,6 +233,12 @@ Uint8List buildTelemetryBinaryPayload() { return Uint8List.fromList([reqTypeGetTelemetry, 0x00, 0x00, 0x00, 0x00]); } +const int anonReqTypeRegions = 0x01; + +// Control data sub-types used by MeshCore discovery packets. +const int controlSubtypeDiscoverReq = 0x08; +const int controlSubtypeDiscoverResp = 0x09; + // Repeater response codes const int respServerLoginOk = 0; @@ -272,6 +281,7 @@ const int pushCodeTraceData = 0x89; const int pushCodeNewAdvert = 0x8A; const int pushCodeTelemetryResponse = 0x8B; const int pushCodeBinaryResponse = 0x8C; +const int pushCodeControlData = 0x8E; // Contact/advertisement types const int advTypeChat = 1; @@ -866,6 +876,67 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) { return writer.toBytes(); } +Uint8List buildSendControlDataFrame(Uint8List payload) { + final writer = BufferWriter(); + writer.writeByte(cmdSendControlData); + writer.writeBytes(payload); + return writer.toBytes(); +} + +Uint8List buildDiscoveryRequestPayload( + int tag, { + bool prefixOnly = false, + int typeMask = 1 << advTypeRepeater, +}) { + final writer = BufferWriter(); + // The high bit must be set for CMD_SEND_CONTROL_DATA; DISCOVER_REQ uses + // subtype 0x8, with the low bit selecting short/full public keys in replies. + writer.writeByte( + (controlSubtypeDiscoverReq << 4) | (prefixOnly ? 0x01 : 0x00), + ); + writer.writeByte(typeMask); + writer.writeUInt32LE(tag); + writer.writeUInt32LE(0); // since=0 asks nearby nodes for any recent advert. + return writer.toBytes(); +} + +Uint8List _reversePathByHop(Uint8List path, int pathHashWidth) { + if (path.isEmpty) return Uint8List(0); + final width = pathHashWidth.clamp(1, 4).toInt(); + if (path.length % width != 0) { + return Uint8List.fromList(path.reversed.toList()); + } + + final reversed = Uint8List(path.length); + final hops = path.length ~/ width; + for (var i = 0; i < hops; i++) { + final from = (hops - 1 - i) * width; + reversed.setRange(i * width, (i + 1) * width, path, from); + } + return reversed; +} + +// Build CMD_SEND_ANON_REQ frame. +// Payload format for regions: [anon_req_type][reply_path_len][reply_path...]. +Uint8List buildSendAnonReqFrame( + Uint8List repeaterPubKey, { + required int requestType, + Uint8List? replyPath, + int replyHopCount = 0, + int pathHashWidth = pathHashSize, +}) { + final width = pathHashWidth.clamp(1, 4).toInt(); + final path = replyPath ?? Uint8List(0); + final encodedPathLen = ((width - 1) << 6) | (replyHopCount & 0x3F); + final writer = BufferWriter(); + writer.writeByte(cmdSendAnonReq); + writer.writeBytes(repeaterPubKey); + writer.writeByte(requestType); + writer.writeByte(encodedPathLen); + writer.writeBytes(_reversePathByHop(path, width)); + return writer.toBytes(); +} + //Build a trace request frame //[cmd][tag x4][auth x4][flag][payload] Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) { @@ -960,3 +1031,18 @@ Uint8List buildSendTelemetryReq(Uint8List? pubKey) { } return writer.toBytes(); } + +//Build CMD_SET_FLOOD_SCOPE +// Format: [cmd][scope] +Uint8List buildSetFloodScopeFrame(String region) { + if (region == '') { + // reset scope + return Uint8List.fromList([cmdSetFloodScope, 0]); + } + + final name = region.startsWith('#') ? region : '#$region'; + final hash = crypto.sha256.convert(utf8.encode(name)).bytes; + final scope = Uint8List.fromList(hash.sublist(0, 16)); + + return Uint8List.fromList([cmdSetFloodScope, 0, ...scope]); +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f6bccbde..dc4c9dab 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2488,6 +2488,13 @@ "settings_companionDebugLog": "Debug-Protokoll für die Begleitsoftware", "settings_companionDebugLogSubtitle": "BLE/TCP/USB-Befehle, Antworten und Rohdaten", "repeater_chanUtil": "Nutzung des Kanals", + "@settings_deleteRegionConfirm": { + "placeholders": { + "region": { + "type": "String" + } + } + }, "@routing_lastWorked": { "placeholders": { "when": { @@ -2495,6 +2502,27 @@ } } }, + "@channels_regionSetTo": { + "placeholders": { + "region": { + "type": "String", + "example": "de-mitte" + } + } + }, + "settings_regionSettings": "Regionen", + "settings_regionSettingsSubtitle": "Gespeicherte Regionen verwalten", + "settings_regionManagement_screenTitle": "Regions-Verwaltung", + "settings_regionNameHint": "Regions-Namen eingeben", + "settings_regionAddRegion": "Region hinzufügen", + "settings_regionDeleted": "Region entfernt", + "settings_regionName": "Regions-Name", + "settings_deleteRegion": "Region entfernen", + "settings_deleteRegionConfirm": "Region \"{region}\" aus der Liste entfernen?", + "channels_regionNotSet": "Region: keine", + "channels_regionSetTo": "Region: {region}", + "channels_regionSelect_Title": "Region auswählen", + "channels_clearRegion": "Region zurücksetzen", "@routing_deliveryCounts": { "placeholders": { "successes": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 705caecb..bcbe3005 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -158,6 +158,25 @@ "settings_radioSettings": "Radio Settings", "settings_radioSettingsSubtitle": "Frequency, power, spreading factor", "settings_radioSettingsUpdated": "Radio settings updated", + "settings_regionSettings": "Regions", + "settings_regionSettingsSubtitle": "Manage stored regions", + "settings_regionManagement_screenTitle": "Region Management", + "settings_regionNameHint": "Enter region name", + "settings_regionAddRegion": "Add region", + "settings_regionFetchRegions": "Fetch regions from repeaters", + "settings_regionFetchRegionsFail": "No regions were found", + "settings_regionFetchRegionsAlreadyExists": "This region has already been added", + "settings_regionName": "Region Name", + "settings_regionDeleted": "Region deleted", + "settings_deleteRegion": "Delete Region", + "settings_deleteRegionConfirm": "Remove \"{region}\" from region list?", + "@settings_deleteRegionConfirm": { + "placeholders": { + "region": { + "type": "String" + } + } + }, "settings_location": "Location", "settings_locationSubtitle": "GPS coordinates", "settings_locationUpdated": "Location and GPS settings updated", @@ -635,6 +654,18 @@ "channels_scanQrCodeComingSoon": "Coming soon", "channels_enterHashtag": "Enter hashtag", "channels_hashtagHint": "e.g. #team", + "channels_regionSetTo": "Region: {region}", + "@channels_regionSetTo": { + "placeholders": { + "region": { + "type": "String", + "example": "de-mitte" + } + } + }, + "channels_regionNotSet": "Region: none", + "channels_regionSelect_Title": "Select a region", + "channels_clearRegion": "Clear region", "chat_noMessages": "No messages yet", "chat_sendMessage": "Send message", "chat_sendMessageTo": "Send a message to {contactName}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cd19b2a7..08d7a0b6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -784,6 +784,78 @@ abstract class AppLocalizations { /// **'Radio settings updated'** String get settings_radioSettingsUpdated; + /// No description provided for @settings_regionSettings. + /// + /// In en, this message translates to: + /// **'Regions'** + String get settings_regionSettings; + + /// No description provided for @settings_regionSettingsSubtitle. + /// + /// In en, this message translates to: + /// **'Manage stored regions'** + String get settings_regionSettingsSubtitle; + + /// No description provided for @settings_regionManagement_screenTitle. + /// + /// In en, this message translates to: + /// **'Region Management'** + String get settings_regionManagement_screenTitle; + + /// No description provided for @settings_regionNameHint. + /// + /// In en, this message translates to: + /// **'Enter region name'** + String get settings_regionNameHint; + + /// No description provided for @settings_regionAddRegion. + /// + /// In en, this message translates to: + /// **'Add region'** + String get settings_regionAddRegion; + + /// No description provided for @settings_regionFetchRegions. + /// + /// In en, this message translates to: + /// **'Fetch regions from repeaters'** + String get settings_regionFetchRegions; + + /// No description provided for @settings_regionFetchRegionsFail. + /// + /// In en, this message translates to: + /// **'No regions were found'** + String get settings_regionFetchRegionsFail; + + /// No description provided for @settings_regionFetchRegionsAlreadyExists. + /// + /// In en, this message translates to: + /// **'This region has already been added'** + String get settings_regionFetchRegionsAlreadyExists; + + /// No description provided for @settings_regionName. + /// + /// In en, this message translates to: + /// **'Region Name'** + String get settings_regionName; + + /// No description provided for @settings_regionDeleted. + /// + /// In en, this message translates to: + /// **'Region deleted'** + String get settings_regionDeleted; + + /// No description provided for @settings_deleteRegion. + /// + /// In en, this message translates to: + /// **'Delete Region'** + String get settings_deleteRegion; + + /// No description provided for @settings_deleteRegionConfirm. + /// + /// In en, this message translates to: + /// **'Remove \"{region}\" from region list?'** + String settings_deleteRegionConfirm(String region); + /// No description provided for @settings_location. /// /// In en, this message translates to: @@ -2458,6 +2530,30 @@ abstract class AppLocalizations { /// **'e.g. #team'** String get channels_hashtagHint; + /// No description provided for @channels_regionSetTo. + /// + /// In en, this message translates to: + /// **'Region: {region}'** + String channels_regionSetTo(String region); + + /// No description provided for @channels_regionNotSet. + /// + /// In en, this message translates to: + /// **'Region: none'** + String get channels_regionNotSet; + + /// No description provided for @channels_regionSelect_Title. + /// + /// In en, this message translates to: + /// **'Select a region'** + String get channels_regionSelect_Title; + + /// No description provided for @channels_clearRegion. + /// + /// In en, this message translates to: + /// **'Clear region'** + String get channels_clearRegion; + /// No description provided for @chat_noMessages. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index e0621f98..1404277c 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -367,6 +367,45 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_radioSettingsUpdated => 'Радио настройките са актуализирани'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Местоположение'; @@ -1330,6 +1369,20 @@ class AppLocalizationsBg extends AppLocalizations { @override String get channels_hashtagHint => 'напр. #отбор'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Няма съобщения.'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4efda5d8..9e061241 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -369,6 +369,46 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Funkparameter aktualisiert'; + @override + String get settings_regionSettings => 'Regionen'; + + @override + String get settings_regionSettingsSubtitle => + 'Gespeicherte Regionen verwalten'; + + @override + String get settings_regionManagement_screenTitle => 'Regions-Verwaltung'; + + @override + String get settings_regionNameHint => 'Regions-Namen eingeben'; + + @override + String get settings_regionAddRegion => 'Region hinzufügen'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Regions-Name'; + + @override + String get settings_regionDeleted => 'Region entfernt'; + + @override + String get settings_deleteRegion => 'Region entfernen'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Region \"$region\" aus der Liste entfernen?'; + } + @override String get settings_location => 'Ort'; @@ -1329,6 +1369,20 @@ class AppLocalizationsDe extends AppLocalizations { @override String get channels_hashtagHint => 'z.B. #team'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: keine'; + + @override + String get channels_regionSelect_Title => 'Region auswählen'; + + @override + String get channels_clearRegion => 'Region zurücksetzen'; + @override String get chat_noMessages => 'Noch keine Nachrichten.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 39cf3727..08f45442 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -363,6 +363,45 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Radio settings updated'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Location'; @@ -1300,6 +1339,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get channels_hashtagHint => 'e.g. #team'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'No messages yet'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index db655fa7..da9decb1 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -367,6 +367,45 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Ajustes de radio actualizados'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Ubicación'; @@ -1327,6 +1366,20 @@ class AppLocalizationsEs extends AppLocalizations { @override String get channels_hashtagHint => 'ej. #equipo'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Aún no hay mensajes'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5dc20ea9..2f9205cf 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -369,6 +369,45 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Paramètres radio mis à jour'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Emplacement'; @@ -1329,6 +1368,20 @@ class AppLocalizationsFr extends AppLocalizations { @override String get channels_hashtagHint => 'ex. #equipe'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Aucun message pour le moment'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 5d1a9a1f..d64268ed 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -365,6 +365,45 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'A rádió beállításai frissítve'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'hely'; @@ -1322,6 +1361,20 @@ class AppLocalizationsHu extends AppLocalizations { @override String get channels_hashtagHint => 'például #csapat'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Még nincsenek üzenetek'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index fc18841a..350e7b78 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -369,6 +369,45 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Impostazioni radio aggiornate'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Posizione'; @@ -1328,6 +1367,20 @@ class AppLocalizationsIt extends AppLocalizations { @override String get channels_hashtagHint => 'es. #team'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Nessun messaggio ancora'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index dddb89e9..53851ba3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -353,6 +353,45 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'ラジオの設定が更新されました'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => '場所'; @@ -1259,6 +1298,20 @@ class AppLocalizationsJa extends AppLocalizations { @override String get channels_hashtagHint => '例:#チーム'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'まだメッセージは届いていません'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f17ed6ee..3ddff59a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -353,6 +353,45 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_radioSettingsUpdated => '라디오 설정이 업데이트되었습니다.'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => '위치'; @@ -1261,6 +1300,20 @@ class AppLocalizationsKo extends AppLocalizations { @override String get channels_hashtagHint => '예: #팀'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => '아직 메시지가 없습니다.'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index bf0a47eb..ade730a2 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -365,6 +365,45 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Radio instellingen bijgewerkt'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Locatie'; @@ -1316,6 +1355,20 @@ class AppLocalizationsNl extends AppLocalizations { @override String get channels_hashtagHint => 'bijv. #team'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Nog geen berichten.'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 50b40989..627b332b 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -371,6 +371,45 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_radioSettingsUpdated => 'Ustawienia radia zostały zaktualizowane'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Lokalizacja'; @@ -1338,6 +1377,20 @@ class AppLocalizationsPl extends AppLocalizations { @override String get channels_hashtagHint => 'np. #zespół'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Brak jeszcze wiadomości'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4e5aa75f..0c9f67e2 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -369,6 +369,45 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_radioSettingsUpdated => 'Configurações de rádio atualizadas'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Localização'; @@ -1326,6 +1365,20 @@ class AppLocalizationsPt extends AppLocalizations { @override String get channels_hashtagHint => 'ex. #equipe'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Ainda não existem mensagens.'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f5e6fd8e..1ddc9c79 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -368,6 +368,45 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Настройки радио обновлены'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Позиция'; @@ -1327,6 +1366,20 @@ class AppLocalizationsRu extends AppLocalizations { @override String get channels_hashtagHint => 'например, #команда'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Сообщений пока нет'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 789d3586..bd16dc0f 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -367,6 +367,45 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Nastavenia rádia aktualizované'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Lokalita'; @@ -1316,6 +1355,20 @@ class AppLocalizationsSk extends AppLocalizations { @override String get channels_hashtagHint => 'napr. #tím'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Zatiaľ žiadne správy.'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 46718ece..064391b7 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -366,6 +366,45 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Radio nastavitve posodobljene'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Lokacija'; @@ -1314,6 +1353,20 @@ class AppLocalizationsSl extends AppLocalizations { @override String get channels_hashtagHint => 'npr. #ekipa'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Še ni sporočil.'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 56ef80d5..1d1ec527 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -365,6 +365,45 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_radioSettingsUpdated => 'Radioinställningarna har uppdaterats'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Plats'; @@ -1306,6 +1345,20 @@ class AppLocalizationsSv extends AppLocalizations { @override String get channels_hashtagHint => 't.ex. #team'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Inga meddelanden ännu'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 961ca982..ab02382b 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -367,6 +367,45 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'Налаштування радіо оновлено'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => 'Геопозиція'; @@ -1320,6 +1359,20 @@ class AppLocalizationsUk extends AppLocalizations { @override String get channels_hashtagHint => 'напр. #команда'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => 'Поки немає повідомлень.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9d496d9a..6e0704bb 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -350,6 +350,45 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_radioSettingsUpdated => '无线电设置已更新'; + @override + String get settings_regionSettings => 'Regions'; + + @override + String get settings_regionSettingsSubtitle => 'Manage stored regions'; + + @override + String get settings_regionManagement_screenTitle => 'Region Management'; + + @override + String get settings_regionNameHint => 'Enter region name'; + + @override + String get settings_regionAddRegion => 'Add region'; + + @override + String get settings_regionFetchRegions => 'Fetch regions from repeaters'; + + @override + String get settings_regionFetchRegionsFail => 'No regions were found'; + + @override + String get settings_regionFetchRegionsAlreadyExists => + 'This region has already been added'; + + @override + String get settings_regionName => 'Region Name'; + + @override + String get settings_regionDeleted => 'Region deleted'; + + @override + String get settings_deleteRegion => 'Delete Region'; + + @override + String settings_deleteRegionConfirm(String region) { + return 'Remove \"$region\" from region list?'; + } + @override String get settings_location => '位置'; @@ -1245,6 +1284,20 @@ class AppLocalizationsZh extends AppLocalizations { @override String get channels_hashtagHint => '例如:#团队'; + @override + String channels_regionSetTo(String region) { + return 'Region: $region'; + } + + @override + String get channels_regionNotSet => 'Region: none'; + + @override + String get channels_regionSelect_Title => 'Select a region'; + + @override + String get channels_clearRegion => 'Clear region'; + @override String get chat_noMessages => '暂无消息'; diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 9baf6302..324157ef 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -27,6 +27,10 @@ class Channel { bool get isPublicChannel => pskHex == publicChannelPsk; + bool get isHashtagChannel => name.startsWith('#'); + + bool get isPrivateChannel => !isPublicChannel && !isHashtagChannel; + static Channel? fromFrame(Uint8List frame) { // CHANNEL_INFO format: // [0] = RESP_CODE_CHANNEL_INFO (18) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 2af7e73f..bbbb270b 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -42,6 +42,8 @@ import '../theme/mesh_theme.dart'; import '../widgets/mesh_ui.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; +import 'region_management_screen.dart'; +import '../storage/region_store.dart'; class ChannelChatScreen extends StatefulWidget { final Channel channel; @@ -273,46 +275,63 @@ class _ChannelChatScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Row( - children: [ - _channelIcon(widget.channel), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.channel.name.isEmpty - ? context.l10n.channels_channelIndex( - widget.channel.index, - ) - : widget.channel.name, - style: const TextStyle(fontSize: 16), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Consumer( - builder: (context, connector, _) { - final unreadCount = connector - .getUnreadCountForChannelIndex(widget.channel.index); - final privacy = widget.channel.isPublicChannel - ? context.l10n.channels_public - : context.l10n.channels_private; - return Text( - '$privacy • ${context.l10n.chat_unread(unreadCount)}', - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ); - }, - ), - ], + title: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => openRegionSelectDialog(widget.channel), + child: Row( + children: [ + _channelIcon(widget.channel), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.channel.name.isEmpty + ? context.l10n.channels_channelIndex( + widget.channel.index, + ) + : widget.channel.name, + style: const TextStyle(fontSize: 16), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Consumer( + builder: (context, connector, _) { + final unreadCount = connector + .getUnreadCountForChannelIndex( + widget.channel.index, + ); + final privacy = widget.channel.isPublicChannel + ? context.l10n.channels_public + : context.l10n.channels_private; + final region = connector.getChannelRegion( + widget.channel.index, + ); + final regionText = region.isNotEmpty + ? ' • ${context.l10n.channels_regionSetTo(region)}' + : ''; + return Text( + '$privacy • ${context.l10n.chat_unread(unreadCount)}$regionText', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ); + }, + ), + ], + ), ), - ), - ], + ], + ), ), centerTitle: false, bottom: const SyncProgressAppBarBottom(), actions: [ + IconButton( + tooltip: context.l10n.channels_regionSelect_Title, + icon: const Icon(Icons.landscape), + onPressed: () => openRegionSelectDialog(widget.channel), + ), const RadioStatsIconButton(), PopupMenuButton( icon: const Icon(Icons.more_vert), @@ -1578,6 +1597,118 @@ class _ChannelChatScreenState extends State { } return hues[h % hues.length]; } + + Future openRegionSelectDialog(Channel channel) async { + // The AppBar subtitle reads the region from the connector inside a + // Consumer, so setChannelRegion's notifyListeners refreshes it directly — + // no post-dialog setState needed. + await showDialog( + context: context, + builder: (BuildContext context) => _RegionSelectDialog(channel: channel), + ); + } +} + +class _RegionSelectDialog extends StatefulWidget { + final Channel channel; + + const _RegionSelectDialog({required this.channel}); + + @override + State<_RegionSelectDialog> createState() => _RegionSelectDialogState(); +} + +class _RegionSelectDialogState extends State<_RegionSelectDialog> { + final RegionStore regionStore = RegionStore(); + + List regions = []; + int selectedIndex = -1; + + @override + void initState() { + super.initState(); + loadRegions(); + } + + void loadRegions() { + setState(() { + regions = regionStore.loadRegions(); + final channelRegion = context.read().getChannelRegion( + widget.channel.index, + ); + selectedIndex = regions.indexOf(channelRegion); + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppBar( + backgroundColor: Colors.transparent, + title: Text(context.l10n.channels_regionSelect_Title), + centerTitle: true, + actions: [ + IconButton( + tooltip: context.l10n.channels_clearRegion, + icon: const Icon(Icons.backspace_outlined), + onPressed: () { + context.read().setChannelRegion( + widget.channel.index, + '', + ); + Navigator.pop(context); + }, + ), + IconButton( + tooltip: context.l10n.settings_regionSettingsSubtitle, + icon: const Icon(Icons.settings), + onPressed: () async { + await pushRegionManagementScreen(context); + if (!mounted) return; + loadRegions(); + }, + ), + ], + ), + const SizedBox(height: 15), + Expanded( + child: ListView.builder( + itemCount: regions.length, + itemBuilder: (context, index) { + final selected = selectedIndex == index; + return ListTile( + leading: Icon( + Icons.landscape, + color: selected ? MeshPalette.blue : null, + ), + title: Text(regions[index]), + trailing: selected + ? const Icon(Icons.check, color: MeshPalette.blue) + : null, + tileColor: selected ? MeshPalette.blueBg : null, + onTap: () { + // Tapping the already-selected region clears it. + context.read().setChannelRegion( + widget.channel.index, + selected ? '' : regions[index], + ); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } } class _SwipeReplyBubble extends StatefulWidget { diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 745445ee..2de232c6 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -374,13 +374,30 @@ class _ChannelsScreenState extends State _communityIndex, ); final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + final community = isCommunityChannel + ? _communityIndex.getCommunityForChannel(channel) + : null; + // Only flood-routed channels carry a region; show it when one is set. + String subtitle = connector.hasChannelRegion(channel.index) + ? context.l10n.channels_regionSetTo( + connector.getChannelRegion(channel.index), + ) + : ''; switch (channelType) { case ChannelType.communityPublic: icon = Icons.groups; iconColor = MeshPalette.magenta; + if (community != null) { + subtitle = + '${context.l10n.community_publicChannel} • ${community.name}'; + } case ChannelType.communityHashtag: icon = Icons.groups; iconColor = MeshPalette.magenta; + if (community != null) { + subtitle = + '${context.l10n.community_hashtagChannel} • ${community.name}'; + } case ChannelType.public: icon = Icons.public; iconColor = MeshPalette.signal; @@ -503,6 +520,17 @@ class _ChannelsScreenState extends State ), ], ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], if (lastPreview.isNotEmpty) ...[ const SizedBox(height: 2), Text( diff --git a/lib/screens/region_management_screen.dart b/lib/screens/region_management_screen.dart new file mode 100644 index 00000000..df6c477c --- /dev/null +++ b/lib/screens/region_management_screen.dart @@ -0,0 +1,527 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/l10n/l10n.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/storage/region_store.dart'; +import 'package:meshcore_open/theme/mesh_theme.dart'; +import 'package:meshcore_open/widgets/mesh_ui.dart'; +import 'package:provider/provider.dart'; + +Future pushRegionManagementScreen(BuildContext context) { + return Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const RegionManagementScreen(), + ), + ); +} + +class RegionManagementScreen extends StatefulWidget { + const RegionManagementScreen({super.key}); + + @override + State createState() => _RegionManagementScreenState(); +} + +class _RegionManagementScreenState extends State { + static final RegExp _validFetchedRegion = RegExp(r'^[a-z0-9-]{1,30}$'); + + final RegionStore _regionStore = RegionStore(); + List _regions = []; + bool _isFetchingRegions = false; + + @override + void initState() { + super.initState(); + final connector = context.read(); + _regionStore.setPublicKeyHex = connector.selfPublicKeyHex; + _loadRegions(); + } + + void _loadRegions() { + final regions = _regionStore.loadRegions(); + if (mounted) { + setState(() { + _regions = regions; + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.settings_regionManagement_screenTitle), + centerTitle: true, + actions: [ + IconButton( + tooltip: l10n.settings_regionAddRegion, + icon: const Icon(Icons.add), + onPressed: () => _showAddRegionDialog(context), + ), + IconButton( + tooltip: l10n.settings_regionFetchRegions, + icon: _isFetchingRegions + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.travel_explore), + onPressed: _isFetchingRegions ? null : _showFetchRegionsDialog, + ), + ], + ), + body: ListView.builder( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 88), + itemCount: _regions.length, + itemBuilder: (context, index) { + final region = _regions[index]; + return _buildRegionTile(context, region); + }, + ), + ); + } + + void _showAddRegionDialog(BuildContext context) { + final l10n = context.l10n; + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.settings_regionName), + content: TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _handleAddRegion(controller.text, context), + decoration: InputDecoration( + hintText: l10n.settings_regionNameHint, + border: const OutlineInputBorder(), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp("[a-z0-9-]")), + ], + maxLength: 30, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.common_cancel), + ), + TextButton( + onPressed: () => _handleAddRegion(controller.text, context), + child: Text(l10n.common_add), + ), + ], + ), + ); + } + + Future _showFetchRegionsDialog() async { + if (_isFetchingRegions) return; + + setState(() { + _isFetchingRegions = true; + }); + + Set fetchedRegions = {}; + try { + fetchedRegions = await _fetchRegionsFromRepeaters(); + } finally { + if (mounted) { + setState(() { + _isFetchingRegions = false; + }); + } + } + + if (!mounted) return; + final l10n = context.l10n; + final sortedRegions = fetchedRegions.toList()..sort(); + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.settings_regionFetchRegions), + content: sortedRegions.isEmpty + ? Text(l10n.settings_regionFetchRegionsFail) + : StatefulBuilder( + builder: (context, setDialogState) { + return SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: sortedRegions.length, + itemBuilder: (context, index) { + final fetchedRegion = sortedRegions[index]; + final alreadyExists = _regions.contains(fetchedRegion); + return MeshCard( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(left: 14, right: 4), + child: Row( + children: [ + const Icon( + Icons.landscape, + color: MeshPalette.blue, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + fetchedRegion, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + style: alreadyExists + ? TextButton.styleFrom( + foregroundColor: Theme.of( + context, + ).disabledColor, + ) + : null, + onPressed: () { + if (alreadyExists) { + _showDialogSnackBar( + context, + l10n.settings_regionFetchRegionsAlreadyExists, + ); + return; + } + + _regionStore.addRegion(fetchedRegion); + _loadRegions(); + setDialogState(() {}); + }, + child: Text(l10n.common_add), + ), + ], + ), + ); + }, + ), + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.common_close), + ), + ], + ), + ); + } + + void _showDialogSnackBar(BuildContext context, String message) { + final overlay = Overlay.maybeOf(context); + if (overlay == null) return; + + final theme = Theme.of(context); + final entry = OverlayEntry( + builder: (context) => Positioned( + left: 16, + right: 16, + bottom: 32, + child: SafeArea( + child: Material( + color: theme.colorScheme.inverseSurface, + elevation: 6, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onInverseSurface, + ), + ), + ), + ), + ), + ), + ); + + overlay.insert(entry); + Timer(const Duration(seconds: 3), entry.remove); + } + + Future> _fetchRegionsFromRepeaters() async { + final connector = context.read(); + final repeaters = await _discoverNearbyRepeaters(connector); + final regions = {}; + + for (final repeater in repeaters) { + if (!mounted || !connector.isConnected) break; + regions.addAll(await _requestRegionsFromRepeater(connector, repeater)); + } + + return regions; + } + + Future> _discoverNearbyRepeaters( + MeshCoreConnector connector, + ) async { + final repeaters = connector.contacts + .where((contact) => contact.type == advTypeRepeater) + .toList(); + if (repeaters.isEmpty || !connector.isConnected) return []; + + StreamSubscription? subscription; + Timer? timeout; + final completer = Completer>(); + final respondingPrefixes = {}; + final tag = DateTime.now().microsecondsSinceEpoch & 0xFFFFFFFF; + + void complete() { + if (completer.isCompleted) return; + timeout?.cancel(); + subscription?.cancel(); + completer.complete(respondingPrefixes); + } + + subscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty || completer.isCompleted) return; + + final reader = BufferReader(frame); + try { + if (reader.readByte() != pushCodeControlData) return; + if (reader.remaining < 9) return; + reader.skipBytes(3); // SNR, RSSI, path_len from companion firmware. + + final payloadType = reader.readByte(); + if (((payloadType >> 4) & 0x0F) != controlSubtypeDiscoverResp || + (payloadType & 0x0F) != advTypeRepeater) { + return; + } + + reader.skipBytes(1); // Inbound SNR reported by the responding repeater. + if (reader.readUInt32LE() != tag) return; + + final publicKeyPrefix = reader.readRemainingBytes(); + if (publicKeyPrefix.isEmpty) return; + respondingPrefixes.add(pubKeyToHex(publicKeyPrefix)); + } catch (_) { + // Ignore malformed discovery frames; another response may still arrive. + } + }); + + try { + final payload = buildDiscoveryRequestPayload(tag, prefixOnly: true); + await connector.sendFrame(buildSendControlDataFrame(payload)); + timeout = Timer(const Duration(seconds: 10), complete); + final prefixes = await completer.future; + return repeaters.where((contact) { + final contactKey = contact.publicKeyHex.toLowerCase(); + return prefixes.any((prefix) => contactKey.startsWith(prefix)); + }).toList(); + } catch (_) { + timeout?.cancel(); + await subscription.cancel(); + return []; + } + } + + Future> _requestRegionsFromRepeater( + MeshCoreConnector connector, + Contact repeater, + ) async { + StreamSubscription? subscription; + Timer? timeout; + final completer = Completer>(); + int? expectedTag; + final originalPath = Uint8List.fromList(repeater.path); + final originalPathLength = repeater.pathLength; + var pathChangedForRequest = false; + + void complete(Set regions) { + if (completer.isCompleted) return; + timeout?.cancel(); + subscription?.cancel(); + completer.complete(regions); + } + + void restartTimeout(Duration duration) { + timeout?.cancel(); + timeout = Timer(duration, () => complete({})); + } + + try { + final replyPath = Uint8List(0); + const replyHopCount = 0; + await connector.setContactPath(repeater, replyPath, replyHopCount); + pathChangedForRequest = true; + + subscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty || completer.isCompleted) return; + + final reader = BufferReader(frame); + try { + final cmd = reader.readByte(); + if (cmd == respCodeSent) { + reader.skipBytes(1); + expectedTag = reader.readUInt32LE(); + final estimatedTimeoutMs = reader.readUInt32LE(); + restartTimeout( + Duration( + milliseconds: estimatedTimeoutMs > 0 + ? estimatedTimeoutMs + 2000 + : 10000, + ), + ); + return; + } + + if (cmd == respCodeErr) { + complete({}); + return; + } + + if (cmd != pushCodeBinaryResponse || expectedTag == null) return; + + reader.skipBytes(1); + final tag = reader.readUInt32LE(); + if (tag != expectedTag) return; + + complete(_parseRegionsResponse(reader.readRemainingBytes())); + } catch (_) { + complete({}); + } + }); + + restartTimeout(const Duration(seconds: 10)); + final frame = buildSendAnonReqFrame( + repeater.publicKey, + requestType: anonReqTypeRegions, + replyPath: replyPath, + replyHopCount: replyHopCount, + pathHashWidth: connector.pathHashByteWidth, + ); + await connector.sendFrame(frame); + final regions = await completer.future; + if (pathChangedForRequest && connector.isConnected) { + await _restoreRepeaterPath( + connector, + repeater, + originalPathLength, + originalPath, + ); + } + return regions; + } catch (_) { + timeout?.cancel(); + subscription?.cancel(); + if (pathChangedForRequest && connector.isConnected) { + await _restoreRepeaterPath( + connector, + repeater, + originalPathLength, + originalPath, + ); + } + return {}; + } + } + + Future _restoreRepeaterPath( + MeshCoreConnector connector, + Contact repeater, + int originalPathLength, + Uint8List originalPath, + ) async { + if (originalPathLength < 0) { + await connector.clearContactPath(repeater); + return; + } + await connector.setContactPath(repeater, originalPath, originalPathLength); + } + + Set _parseRegionsResponse(Uint8List frame) { + if (frame.length <= 4) return {}; + final names = utf8 + .decode(frame.sublist(4), allowMalformed: true) + .replaceAll('\x00', '') + .split(','); + return names + .map((name) => name.trim()) + .where((name) => _validFetchedRegion.hasMatch(name)) + .toSet(); + } + + void _handleAddRegion(Region region, BuildContext context) { + Navigator.pop(context); + _regionStore.addRegion(region); + _loadRegions(); + } + + Widget _buildRegionTile(BuildContext context, Region region) { + final scheme = Theme.of(context).colorScheme; + return MeshCard( + key: ValueKey(region), + padding: const EdgeInsets.only(left: 14, right: 4), + child: Row( + children: [ + const Icon(Icons.landscape, color: MeshPalette.blue), + const SizedBox(width: 12), + Expanded( + child: Text( + region, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: context.l10n.settings_deleteRegion, + icon: Icon(Icons.delete_outline, color: scheme.error), + onPressed: () => _confirmDelete(context, region), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context, Region region) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.settings_deleteRegion), + content: Text(context.l10n.settings_deleteRegionConfirm(region)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () async { + final connector = context.read(); + Navigator.pop(dialogContext); + await _regionStore.removeRegion(region); + // Deleting a region clears it from any channels that used it; + // refresh the connector's in-memory channel regions to match. + await connector.loadChannelSettings(); + _loadRegions(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.settings_regionDeleted)), + ); + }, + child: Text( + context.l10n.common_delete, + style: TextStyle( + color: Theme.of(dialogContext).colorScheme.error, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ef7ae08e..f8ff3692 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -19,6 +19,7 @@ import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import '../widgets/radio_stats_entry.dart'; import '../widgets/sync_progress_overlay.dart'; +import 'region_management_screen.dart'; /// Convert device coding-rate value (1-4 on some firmware, 5-8 on others) /// to the UI enum range (always 5-8). @@ -449,6 +450,14 @@ class _SettingsScreenState extends State { onTap: () => _showRadioSettings(context, connector), ), const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.landscape, + title: l10n.settings_regionSettings, + subtitle: l10n.settings_regionSettingsSubtitle, + onTap: () => pushRegionManagementScreen(context), + ), + const Divider(height: 1, indent: 16), _tappableTile( context, icon: Icons.sensors_outlined, diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index df2822bd..d415b5c2 100644 --- a/lib/services/ble_debug_log_service.dart +++ b/lib/services/ble_debug_log_service.dart @@ -176,6 +176,8 @@ class BleDebugLogService extends ChangeNotifier { return 'CMD_SET_CUSTOM_VAR'; case cmdSendTracePath: return 'CMD_SEND_TRACE_PATH'; + case cmdSetFloodScope: + return 'CMD_SET_FLOOD_SCOPE'; default: return null; } diff --git a/lib/storage/channel_region_store.dart b/lib/storage/channel_region_store.dart new file mode 100644 index 00000000..e28551a7 --- /dev/null +++ b/lib/storage/channel_region_store.dart @@ -0,0 +1,39 @@ +import '../utils/app_logger.dart'; +import 'prefs_manager.dart'; + +class ChannelRegionStore { + static const String _keyPrefix = 'channel_region_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; + + Future loadRegion(int channelIndex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot load channel settings.', + ); + return ''; + } + final prefs = PrefsManager.instance; + final key = '$keyFor$channelIndex'; + String? region = prefs.getString(key); + return region ?? ''; + } + + Future saveRegion(int channelIndex, String region) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot save channel settings.', + ); + return ''; + } + + final prefs = PrefsManager.instance; + final key = '$keyFor$channelIndex'; + await prefs.setString(key, region); + return region; + } +} diff --git a/lib/storage/region_store.dart b/lib/storage/region_store.dart new file mode 100644 index 00000000..af532da9 --- /dev/null +++ b/lib/storage/region_store.dart @@ -0,0 +1,53 @@ +import 'package:meshcore_open/storage/channel_region_store.dart'; +import 'package:meshcore_open/storage/channel_store.dart'; + +import 'prefs_manager.dart'; + +typedef Region = String; + +class RegionStore { + static const String key = 'regions'; + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; + + List loadRegions() { + final prefs = PrefsManager.instance; + List? region = prefs.getStringList(key); + return region ?? []; + } + + void saveRegions(List regions) { + final prefs = PrefsManager.instance; + + var distinctRegions = [ + ...{...regions}, + ]; + + distinctRegions.sort(); + prefs.setStringList(key, distinctRegions); + } + + void addRegion(Region region) { + final regions = loadRegions(); + regions.add(region); + saveRegions(regions); + } + + Future removeRegion(Region region) async { + final regions = loadRegions(); + final channelStore = ChannelStore(); + final channelRegionStore = ChannelRegionStore(); + channelStore.setPublicKeyHex = publicKeyHex; + channelRegionStore.setPublicKeyHex = publicKeyHex; + + for (var channel in await channelStore.loadChannels()) { + var channelRegion = await channelRegionStore.loadRegion(channel.index); + if (channelRegion == region) { + await channelRegionStore.saveRegion(channel.index, ''); + } + } + regions.remove(region); + saveRegions(regions); + } +} diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93e46829..379e36fa 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/untranslated.json b/untranslated.json index 9e26dfee..f42fd2c3 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,311 @@ -{} \ No newline at end of file +{ + "bg": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "de": [ + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists" + ], + + "es": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "fr": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "hu": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "it": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "ja": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "ko": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "nl": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "pl": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "pt": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "ru": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "sk": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "sl": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "sv": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "uk": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ], + + "zh": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" + ] +} diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 533a1712..f02857f4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial flutter_local_notifications_windows - jni ) set(PLUGIN_BUNDLED_LIBRARIES)