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 a9cb2901..8fcee71d 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'; @@ -301,6 +303,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 @@ -332,6 +338,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(); @@ -341,6 +348,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 = {}; @@ -739,6 +747,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); } @@ -892,6 +908,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(); @@ -1050,11 +1074,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); } } @@ -3464,12 +3490,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; } @@ -3486,12 +3515,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 { @@ -4103,6 +4217,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; @@ -4249,6 +4366,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 fc46d362..82c9473d 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/helpers/link_handler.dart b/lib/helpers/link_handler.dart index c2eae294..980369da 100644 --- a/lib/helpers/link_handler.dart +++ b/lib/helpers/link_handler.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -20,6 +21,7 @@ class LinkHandler { required String text, required TextStyle style, TextStyle? linkStyle, + VoidCallback? onSecondaryTap, }) { final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style); const options = LinkifyOptions(humanize: false, defaultToHttps: false); @@ -27,7 +29,7 @@ class LinkHandler { void onOpen(LinkableElement link) => handleLinkTap(context, link.url); if (PlatformInfo.isDesktop) { - return SelectableLinkify( + final linkify = SelectableLinkify( text: text, style: style, linkStyle: effectiveLinkStyle, @@ -35,6 +37,14 @@ class LinkHandler { linkifiers: linkifiers, onOpen: onOpen, ); + if (onSecondaryTap == null) return linkify; + return Listener( + onPointerDown: (event) { + if (event.buttons & kSecondaryMouseButton != 0) onSecondaryTap(); + }, + behavior: HitTestBehavior.translucent, + child: linkify, + ); } return Linkify( text: text, 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 9af260e4..85f3f49d 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", @@ -639,6 +658,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 2c30a511..87ad67a4 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: @@ -2482,6 +2554,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 780cb3c4..f06e5055 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 => 'Местоположение'; @@ -1342,6 +1381,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 c44e3d01..0a59487e 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'; @@ -1341,6 +1381,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 99d8c4ff..4fa895e7 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'; @@ -1312,6 +1351,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 0882003a..0897a684 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'; @@ -1339,6 +1378,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 a5ae5046..eb960ea4 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'; @@ -1341,6 +1380,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 6bc7a536..c1188965 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'; @@ -1334,6 +1373,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 b043947b..30d47357 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'; @@ -1340,6 +1379,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 a617465f..4cc75df7 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 => '場所'; @@ -1271,6 +1310,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 2bdfaad5..434b3968 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 => '위치'; @@ -1273,6 +1312,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 3973340e..e84ed4ed 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'; @@ -1328,6 +1367,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 9805e217..d2cf3986 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'; @@ -1350,6 +1389,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 088fa3a1..de14aa42 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'; @@ -1338,6 +1377,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 58960c56..95f99a91 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 => 'Позиция'; @@ -1339,6 +1378,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 433464f5..941d6078 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'; @@ -1328,6 +1367,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 89340357..f55a98e9 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'; @@ -1326,6 +1365,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 9f6cb8f2..62806e0b 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'; @@ -1318,6 +1357,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 95946bc2..d1bae5b7 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 => 'Геопозиція'; @@ -1332,6 +1371,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 bb4c23d4..aff4d5e8 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 => '位置'; @@ -1257,6 +1296,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/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 78053aa9..7eb4aa5f 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -117,20 +117,32 @@ class _BleDebugLogScreenState extends State { final entry = entries[index]; final time = '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; - return GestureDetector( - onLongPress: () async { - await Clipboard.setData( - ClipboardData( - text: entry.payload - .map( - (b) => b - .toRadixString(16) - .padLeft(2, '0'), - ) - .join(''), + Future copyHex() async { + await Clipboard.setData( + ClipboardData( + text: entry.payload + .map( + (b) => b + .toRadixString(16) + .padLeft(2, '0'), + ) + .join(''), + ), + ); + if (context.mounted) { + showDismissibleSnackBar( + context, + content: Text( + context.l10n.debugLog_bleCopied, ), ); - }, + } + } + + return GestureDetector( + onTap: copyHex, + onLongPress: copyHex, + onSecondaryTap: copyHex, child: Container( color: MeshPalette.bg, padding: const EdgeInsets.symmetric( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 25fb265c..a1397f52 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -43,6 +43,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; @@ -274,46 +276,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), @@ -655,6 +674,9 @@ class _ChannelChatScreenState extends State { fontStyle: FontStyle.italic, color: textColor.withValues(alpha: 0.72), ), + onSecondaryTap: PlatformInfo.isDesktop + ? () => _showMessageActions(message) + : null, ), ), ], @@ -667,22 +689,25 @@ class _ChannelChatScreenState extends State { : EdgeInsets.zero, child: Row( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ RouteChip( isDirect: (message.pathLength ?? -1) >= 0, hops: displayHopCount, ), const SizedBox(width: 4), - Text( - context.l10n.channels_via( - _formatPathPrefixes( + Flexible( + child: Text( + context.l10n.channels_via( + _formatPathPrefixes( displayPath, displayPathHashWidth, ), - ), - style: MeshTheme.mono( - fontSize: 9.5 * textScale, - color: metaColor, + ), + style: MeshTheme.mono( + fontSize: 9.5 * textScale, + color: metaColor, + ), ), ), ], @@ -1593,6 +1618,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 dce3dfc3..d9846db3 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -372,13 +372,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; @@ -427,142 +444,151 @@ class _ChannelsScreenState extends State channelMessageStore, channel, ), - child: GestureDetector( - onSecondaryTapUp: PlatformInfo.isDesktop - ? (_) => _showChannelActions( - this.context, - connector, - channelMessageStore, - channel, - ) - : null, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Leading avatar with optional community badge - Stack( - clipBehavior: Clip.none, - children: [ - AvatarCircle( - name: channelLabel, - size: 42, - color: iconColor, - icon: icon, - ), - if (isCommunityChannel) - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: MeshPalette.magenta, - shape: BoxShape.circle, - border: Border.all( - color: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - width: 2, - ), - ), - child: const Icon( - Icons.people, - size: 8, - color: Colors.white, - ), - ), - ), - ], - ), - const SizedBox(width: 12), - // Title + subtitle + ch chip - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - channelLabel, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 6), - StatusChip( - label: 'CH ${channel.index}', - color: MeshPalette.blue, - fontSize: 10, - ), - ], - ), - if (lastPreview.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - lastPreview, - style: MeshTheme.mono( - fontSize: 11.5, - color: scheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], + onSecondaryTap: PlatformInfo.isDesktop + ? () => _showChannelActions( + this.context, + connector, + channelMessageStore, + channel, + ) + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Leading avatar with optional community badge + Stack( + clipBehavior: Clip.none, + children: [ + AvatarCircle( + name: channelLabel, + size: 42, + color: iconColor, + icon: icon, ), - ), - const SizedBox(width: 8), - // Right side: time + unread badge + muted + drag handle - Column( - crossAxisAlignment: CrossAxisAlignment.end, + if (isCommunityChannel) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: MeshPalette.magenta, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of( + context, + ).colorScheme.surfaceContainerLow, + width: 2, + ), + ), + child: const Icon( + Icons.people, + size: 8, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(width: 12), + // Title + subtitle + ch chip + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (lastTime != null) - Text( - _relativeTime(lastTime), - style: MeshTheme.mono( - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), Row( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (isMuted) ...[ - Icon( - Icons.notifications_off, - size: 14, - color: scheme.onSurfaceVariant, + Expanded( + child: Text( + channelLabel, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 4), - ], - if (unreadCount > 0) UnreadBadge(count: unreadCount), + ), + const SizedBox(width: 6), + StatusChip( + label: 'CH ${channel.index}', + color: MeshPalette.blue, + fontSize: 10, + ), ], ), + 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( + lastPreview, + style: MeshTheme.mono( + fontSize: 11.5, + color: scheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], ), - if (showDragHandle && dragIndex != null) ...[ - const SizedBox(width: 4), - ReorderableDragStartListener( - index: dragIndex, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - Icons.drag_handle, + ), + const SizedBox(width: 8), + // Right side: time + unread badge + muted + drag handle + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (lastTime != null) + Text( + _relativeTime(lastTime), + style: MeshTheme.mono( + fontSize: 11, color: scheme.onSurfaceVariant, ), ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isMuted) ...[ + Icon( + Icons.notifications_off, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + if (unreadCount > 0) UnreadBadge(count: unreadCount), + ], ), ], + ), + if (showDragHandle && dragIndex != null) ...[ + const SizedBox(width: 4), + ReorderableDragStartListener( + index: dragIndex, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.drag_handle, + color: scheme.onSurfaceVariant, + ), + ), + ), ], - ), + ], ), ), ); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 0aa6495d..a6b82af8 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1457,6 +1457,9 @@ class _MessageBubble extends StatelessWidget { color: textColor.withValues(alpha: 0.72), fontSize: bodyFontSize * textScale, ), + onSecondaryTap: PlatformInfo.isDesktop + ? onLongPress + : null, ), ), ], diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 415562c3..4a782d6f 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -147,13 +147,6 @@ class _DiscoveryScreenState extends State { connector, index, ); - if (PlatformInfo.isDesktop) { - return GestureDetector( - onSecondaryTapUp: (_) => - _showContactContextMenu(contact, connector), - child: tile, - ); - } return tile; }, ), @@ -204,6 +197,9 @@ class _DiscoveryScreenState extends State { } }, onLongPress: () => _showContactContextMenu(contact, connector), + onSecondaryTap: PlatformInfo.isDesktop + ? () => _showContactContextMenu(contact, connector) + : null, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( children: [ diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 57d7603f..fe935e43 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -430,6 +430,7 @@ class _LineOfSightMapScreenState extends State { minZoom: _mapMinZoom, maxZoom: _mapMaxZoom, onLongPress: (_, point) => _addCustomPoint(point), + onSecondaryTap: (_, point) => _addCustomPoint(point), onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; if (!_didReceivePositionUpdate || diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 9be1a584..ef8798a9 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -287,6 +287,31 @@ class _MapScreenState extends State { ); } + void _handleMapContextPress( + BuildContext context, + MeshCoreConnector connector, + LatLng latLng, + ) { + if (_isSelectingPoi) { + setState(() { + _isSelectingPoi = false; + }); + _shareMarker( + context: context, + connector: connector, + position: latLng, + defaultLabel: context.l10n.map_pointOfInterest, + flags: 'poi', + ); + return; + } + _showShareMarkerAtPositionSheet( + context: context, + connector: connector, + position: latLng, + ); + } + @override Widget build(BuildContext context) { return Builder( @@ -714,24 +739,10 @@ class _MapScreenState extends State { } }, onLongPress: (_, latLng) { - if (_isSelectingPoi) { - setState(() { - _isSelectingPoi = false; - }); - _shareMarker( - context: context, - connector: connector, - position: latLng, - defaultLabel: context.l10n.map_pointOfInterest, - flags: 'poi', - ); - return; - } - _showShareMarkerAtPositionSheet( - context: context, - connector: connector, - position: latLng, - ); + _handleMapContextPress(context, connector, latLng); + }, + onSecondaryTap: (_, latLng) { + _handleMapContextPress(context, connector, latLng); }, onPositionChanged: (camera, hasGesture) { // Track zoom in half-step buckets so cluster/marker @@ -1185,9 +1196,12 @@ class _MapScreenState extends State { width: 48, height: 48, child: GestureDetector( - onLongPress: () => _isBuildingPathTrace - ? _showNodeInfo(context, guess.contact) - : null, + onLongPress: () { + if (_isBuildingPathTrace) _showNodeInfo(context, guess.contact); + }, + onSecondaryTap: () { + if (_isBuildingPathTrace) _showNodeInfo(context, guess.contact); + }, onTap: () => _isBuildingPathTrace ? _addToPath(context, guess.contact, position: guess.position) : _selectNode(guess.contact, guessedPosition: guess.position), @@ -1405,8 +1419,12 @@ class _MapScreenState extends State { height: size, child: GestureDetector( behavior: HitTestBehavior.opaque, - onLongPress: () => - _isBuildingPathTrace ? _showNodeInfo(context, contact) : null, + onLongPress: () { + if (_isBuildingPathTrace) _showNodeInfo(context, contact); + }, + onSecondaryTap: () { + if (_isBuildingPathTrace) _showNodeInfo(context, contact); + }, onTap: () => _isBuildingPathTrace ? _addToPath(context, contact) : _selectNode(contact), 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 4b947f3b..381a3335 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/lib/widgets/mesh_ui.dart b/lib/widgets/mesh_ui.dart index 797af7a7..cb4698bb 100644 --- a/lib/widgets/mesh_ui.dart +++ b/lib/widgets/mesh_ui.dart @@ -50,6 +50,7 @@ class MeshCard extends StatelessWidget { final Widget child; final VoidCallback? onTap; final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; final EdgeInsetsGeometry padding; final EdgeInsetsGeometry margin; final Color? color; @@ -61,6 +62,7 @@ class MeshCard extends StatelessWidget { required this.child, this.onTap, this.onLongPress, + this.onSecondaryTap, this.padding = const EdgeInsets.all(14), this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4), this.color, @@ -89,6 +91,7 @@ class MeshCard extends StatelessWidget { HapticFeedback.selectionClick(); onLongPress!(); }, + onSecondaryTap: onSecondaryTap, child: Padding(padding: padding, child: child), ), ), diff --git a/lib/widgets/routing_sheet.dart b/lib/widgets/routing_sheet.dart index 1370fb3d..ee11937b 100644 --- a/lib/widgets/routing_sheet.dart +++ b/lib/widgets/routing_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../utils/platform_info.dart'; import '../helpers/path_helper.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -565,56 +566,67 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { l10n.routing_deliveryCounts(record.successCount, record.failureCount), ]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - enabled: hasBytes, - leading: CircleAvatar( - radius: 18, - backgroundColor: bg, - child: Icon( - _qualityIcon(quality), - size: 18, - color: fg, - semanticLabel: _qualityLabel(context, quality), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onSecondaryTapUp: PlatformInfo.isDesktop && hasBytes + ? (_) => + _showPathDetail(context, connector, contact, record.pathBytes) + : null, + child: Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + enabled: hasBytes, + leading: CircleAvatar( + radius: 18, + backgroundColor: bg, + child: Icon( + _qualityIcon(quality), + size: 18, + color: fg, + semanticLabel: _qualityLabel(context, quality), + ), ), - ), - title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - '$line1\n${line2Parts.join(' • ')}', - style: const TextStyle(fontSize: 11), - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (inUse) - Tooltip( - message: l10n.routing_inUse, - child: Icon( - Icons.check_circle, - color: scheme.primary, - semanticLabel: l10n.routing_inUse, + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + '$line1\n${line2Parts.join(' • ')}', + style: const TextStyle(fontSize: 11), + ), + isThreeLine: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (inUse) + Tooltip( + message: l10n.routing_inUse, + child: Icon( + Icons.check_circle, + color: scheme.primary, + semanticLabel: l10n.routing_inUse, + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + tooltip: l10n.chat_removePath, + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), + onPressed: () => pathService.removePathRecord( + contact.publicKeyHex, + record.pathBytes, ), ), - IconButton( - icon: const Icon(Icons.delete_outline, size: 20), - tooltip: l10n.chat_removePath, - constraints: const BoxConstraints(minWidth: 44, minHeight: 44), - onPressed: () => pathService.removePathRecord( - contact.publicKeyHex, - record.pathBytes, - ), - ), - ], + ], + ), + onTap: hasBytes && !inUse + ? () => _applyHistoryPath(connector, contact, record) + : null, + onLongPress: hasBytes + ? () => _showPathDetail( + context, + connector, + contact, + record.pathBytes, + ) + : null, ), - onTap: hasBytes && !inUse - ? () => _applyHistoryPath(connector, contact, record) - : null, - onLongPress: hasBytes - ? () => - _showPathDetail(context, connector, contact, record.pathBytes) - : null, ), ); } diff --git a/lib/widgets/translated_message_content.dart b/lib/widgets/translated_message_content.dart index 3495897d..a40bacf8 100644 --- a/lib/widgets/translated_message_content.dart +++ b/lib/widgets/translated_message_content.dart @@ -8,6 +8,7 @@ class TranslatedMessageContent extends StatelessWidget { final TextStyle style; final TextStyle? originalStyle; final bool showOriginalFirst; + final VoidCallback? onSecondaryTap; const TranslatedMessageContent({ super.key, @@ -16,6 +17,7 @@ class TranslatedMessageContent extends StatelessWidget { this.originalText, this.originalStyle, this.showOriginalFirst = true, + this.onSecondaryTap, }); @override @@ -36,12 +38,14 @@ class TranslatedMessageContent extends StatelessWidget { fontStyle: FontStyle.italic, fontSize: style.fontSize, ), + onSecondaryTap: onSecondaryTap, ) : null; final translatedWidget = LinkHandler.buildLinkifyText( context: context, text: trimmedDisplay, style: style, + onSecondaryTap: onSecondaryTap, ); if (!shouldShowOriginal) { diff --git a/untranslated.json b/untranslated.json index dd0a7e15..bacd26ce 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,12 +1,31 @@ { "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" ], "de": [ + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", @@ -14,107 +33,347 @@ ], "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "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", "repeater_pathHashModeOption0", "repeater_pathHashModeOption1", "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" + "repeater_pathHashModeOption3", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion" ] }