From 0e074fd806ba09e97ed548e6bee5dcac29bd38cc Mon Sep 17 00:00:00 2001 From: Stephan Rodemeier Date: Sun, 5 Apr 2026 21:35:39 +0200 Subject: [PATCH 1/3] Add region management This adds region management: the user can manage a list of available regions and for each channel pick a region from that list to apply to messages. Region discovery from nearby repeaters will be done in a separate PR. This is a part of the work needed for #120. --- documentation/ble-protocol.md | 1 + lib/connector/meshcore_connector.dart | 59 ++++-- lib/connector/meshcore_protocol.dart | 17 ++ lib/l10n/app_de.arb | 32 ++- lib/l10n/app_en.arb | 32 ++- lib/l10n/app_localizations.dart | 78 ++++++++ lib/l10n/app_localizations_bg.dart | 43 ++++ lib/l10n/app_localizations_de.dart | 44 +++++ lib/l10n/app_localizations_en.dart | 43 ++++ lib/l10n/app_localizations_es.dart | 43 ++++ lib/l10n/app_localizations_fr.dart | 43 ++++ lib/l10n/app_localizations_hu.dart | 43 ++++ lib/l10n/app_localizations_it.dart | 43 ++++ lib/l10n/app_localizations_ja.dart | 43 ++++ lib/l10n/app_localizations_ko.dart | 43 ++++ lib/l10n/app_localizations_nl.dart | 43 ++++ lib/l10n/app_localizations_pl.dart | 43 ++++ lib/l10n/app_localizations_pt.dart | 43 ++++ lib/l10n/app_localizations_ru.dart | 43 ++++ lib/l10n/app_localizations_sk.dart | 43 ++++ lib/l10n/app_localizations_sl.dart | 43 ++++ lib/l10n/app_localizations_sv.dart | 43 ++++ lib/l10n/app_localizations_uk.dart | 43 ++++ lib/l10n/app_localizations_zh.dart | 43 ++++ lib/models/channel.dart | 4 + lib/screens/channel_chat_screen.dart | 226 ++++++++++++++++++---- lib/screens/channels_screen.dart | 26 ++- lib/screens/region_management_screen.dart | 160 +++++++++++++++ lib/screens/settings_screen.dart | 9 + lib/services/ble_debug_log_service.dart | 2 + lib/storage/channel_region_store.dart | 39 ++++ lib/storage/region_store.dart | 53 +++++ untranslated.json | 208 ++++++++++++++++++++ 33 files changed, 1653 insertions(+), 68 deletions(-) create mode 100644 lib/screens/region_management_screen.dart create mode 100644 lib/storage/channel_region_store.dart create mode 100644 lib/storage/region_store.dart diff --git a/documentation/ble-protocol.md b/documentation/ble-protocol.md index ec240948..852f7dc5 100644 --- a/documentation/ble-protocol.md +++ b/documentation/ble-protocol.md @@ -118,6 +118,7 @@ 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+) | | 57 | CMD_SEND_ANON_REQ | Send anonymous request | | 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration | | 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration | diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b4322773..dd310121 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'; @@ -34,6 +35,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'; @@ -276,6 +278,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(); @@ -283,6 +286,7 @@ class MeshCoreConnector extends ChangeNotifier { final UnreadStore _unreadStore = UnreadStore(); List _cachedChannels = []; final Map _channelSmazEnabled = {}; + final Map _channelRegions = {}; bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command final Map _contactSmazEnabled = {}; @@ -603,6 +607,14 @@ class MeshCoreConnector extends ChangeNotifier { return _contactSmazEnabled[contactKeyHex] ?? false; } + bool hasChannelRegion(int channelIndex) { + return _channelRegions[channelIndex] != ''; + } + + Region getChannelRegion(int channelIndex) { + return _channelRegions[channelIndex] ?? ''; + } + void ensureContactSmazSettingLoaded(String contactKeyHex) { _ensureContactSmazSettingLoaded(contactKeyHex); } @@ -692,6 +704,14 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } + Future setChannelRegion(int channelIndex, String region) async { + _channelRegions[channelIndex] = await _channelRegionStore.saveRegion( + channelIndex, + region, + ); + notifyListeners(); + } + Future _loadChannelOrder() async { _channelOrder = await _channelOrderStore.loadChannelOrder(); _applyChannelOrder(); @@ -840,9 +860,11 @@ class MeshCoreConnector extends ChangeNotifier { Future loadChannelSettings({int? maxChannels}) async { _channelSmazEnabled.clear(); + _channelRegions.clear(); final channelCount = maxChannels ?? _maxChannels; for (int i = 0; i < channelCount; i++) { _channelSmazEnabled[i] = await _channelSettingsStore.loadSmazEnabled(i); + _channelRegions[i] = await _channelRegionStore.loadRegion(i); } } @@ -2973,12 +2995,19 @@ 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, - ); + try { + await sendFrame( + buildSetFloodScopeFrame(getChannelRegion(channel.index)), + ); + await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, text), + channelSendQueueId: reactionQueueId, + expectsGenericAck: true, + ); + } finally { + await sendFrame(buildSetFloodScopeFrame('')); + } return; } @@ -3001,12 +3030,17 @@ class MeshCoreConnector extends ChangeNotifier { (isChannelSmazEnabled(channel.index) && !isStructuredPayload) ? Smaz.encodeIfSmaller(text) : text; - await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( - buildSendChannelTextMsgFrame(channel.index, outboundText), - channelSendQueueId: message.messageId, - expectsGenericAck: true, - ); + try { + await sendFrame(buildSetFloodScopeFrame(getChannelRegion(channel.index))); + await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, outboundText), + channelSendQueueId: message.messageId, + expectsGenericAck: true, + ); + } finally { + await sendFrame(buildSetFloodScopeFrame('')); + } } Future removeContact(Contact contact) async { @@ -3680,6 +3714,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 396d78b3..8a1b3a57 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 @@ -211,6 +212,7 @@ const int cmdSendAnonReq = 57; const int cmdSetAutoAddConfig = 58; const int cmdGetAutoAddConfig = 59; const int cmdSetPathHashMode = 61; +const int cmdSetFloodScope = 54; // Text message types const int txtTypePlain = 0; @@ -955,3 +957,18 @@ Uint8List buildSendTelemetryReq(Uint8List? pubKey) { } return writer.toBytes(); } + +//Build CMD_SET_FLOOD_SCOPE +// Format: [cmd][scope] +Uint8List buildSetFloodScopeFrame(String region) { + if (region == '') { + // reset scope + return Uint8List.fromList([cmdSetFloodScope, 0]); + } + + final name = region.startsWith('#') ? region : '#$region'; + final hash = crypto.sha256.convert(utf8.encode(name)).bytes; + final scope = Uint8List.fromList(hash.sublist(0, 16)); + + return Uint8List.fromList([cmdSetFloodScope, 0, ...scope]); +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 54683d21..9e01dbdb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2097,5 +2097,33 @@ "description": "Repeater setting subtitle: describes the clock sync after login behavior" }, "repeater_clockSyncAfterLogin": "Uhrzeit-Synchronisation nach dem Anmelden", - "repeater_clockSyncAfterLoginSubtitle": "Automatisch \"Uhrzeit-Synchronisierung\" nach erfolgreicher Anmeldung senden." -} + "repeater_clockSyncAfterLoginSubtitle": "Automatisch \"Uhrzeit-Synchronisierung\" nach erfolgreicher Anmeldung senden.", + "@settings_deleteRegionConfirm": { + "placeholders": { + "region": { + "type": "String" + } + } + }, + "@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" +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1ac23572..e6a80272 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -127,7 +127,6 @@ } } }, - "scanner_stop": "Stop", "scanner_scan": "Scan", "scanner_bluetoothOff": "Bluetooth is off", @@ -149,6 +148,22 @@ "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_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", @@ -606,6 +621,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 message to {name}", @@ -2065,7 +2092,6 @@ "radioStats_stripWaiting": "Fetching radio stats…", "radioStats_settingsTile": "Radio stats", "radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime", - "translation_title": "Translation", "translation_enableTitle": "Enable translation", "translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.", @@ -2119,4 +2145,4 @@ }, "translation_translationOptions": "Translation options", "translation_systemLanguage": "System language" -} +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index efcbd0f2..0419b15e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -724,6 +724,60 @@ 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_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: @@ -2290,6 +2344,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 bb07229d..ba5145c4 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -336,6 +336,35 @@ 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_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 => 'Местоположение'; @@ -1236,6 +1265,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 49cf19a7..512ce67a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -338,6 +338,36 @@ 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_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'; @@ -1235,6 +1265,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 e13934b8..8122a362 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -332,6 +332,35 @@ 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_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'; @@ -1210,6 +1239,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 ddb9b6e2..8afc538c 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -336,6 +336,35 @@ 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_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'; @@ -1235,6 +1264,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 fbe106d7..7db2cdf6 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -338,6 +338,35 @@ 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_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'; @@ -1240,6 +1269,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 920efd88..1316bc23 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -335,6 +335,35 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_radioSettingsUpdated => 'A rádió beállítások 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_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 => 'Helyszín'; @@ -1243,6 +1272,20 @@ class AppLocalizationsHu extends AppLocalizations { @override String get channels_hashtagHint => 'pl. #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 nincs üzenet.'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index b492d6af..c90cdc8e 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -338,6 +338,35 @@ 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_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'; @@ -1236,6 +1265,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 daebcba6..2e4e6225 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -323,6 +323,35 @@ 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_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 => '場所'; @@ -1176,6 +1205,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 605cc96b..bfee59c1 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -322,6 +322,35 @@ 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_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 => '위치'; @@ -1171,6 +1200,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 3d1644f0..928ba65c 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -334,6 +334,35 @@ 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_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'; @@ -1224,6 +1253,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 f0006b18..66cd913e 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -340,6 +340,35 @@ 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_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'; @@ -1244,6 +1273,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 f4b7ffca..512d138f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -338,6 +338,35 @@ 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_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'; @@ -1235,6 +1264,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 dcd9d9c8..dbab1565 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -337,6 +337,35 @@ 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_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 => 'Позиция'; @@ -1235,6 +1264,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 7323ddcc..2b9c9698 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -336,6 +336,35 @@ 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_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'; @@ -1223,6 +1252,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 a374d4bb..4ac4d570 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -334,6 +334,35 @@ 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_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'; @@ -1221,6 +1250,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 6e2f563a..b629795e 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -334,6 +334,35 @@ 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_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'; @@ -1214,6 +1243,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 dd189ebc..4b969770 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -336,6 +336,35 @@ 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_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 => 'Розташування'; @@ -1227,6 +1256,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 b48b31df..3ec5ef9b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -320,6 +320,35 @@ 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_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 => '位置'; @@ -1158,6 +1187,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 4fdd6270..6bb73e91 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -24,6 +24,10 @@ class Channel { bool get isPublicChannel => pskHex == publicChannelPsk; + bool get isHashtagChannel => name.startsWith('#'); + + bool get isPrivateChannel => !isPublicChannel && !isHashtagChannel; + static Channel? fromFrame(Uint8List frame) { // CHANNEL_INFO format: // [0] = RESP_CODE_CHANNEL_INFO (18) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 64da058f..335a973e 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -5,6 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meshcore_open/screens/region_management_screen.dart'; +import 'package:meshcore_open/storage/region_store.dart'; +import 'package:meshcore_open/widgets/adaptive_app_bar_title.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -50,6 +53,7 @@ class _ChannelChatScreenState extends State { ChannelMessage? _replyingToMessage; final Map _messageKeys = {}; bool _isLoadingOlder = false; + Region region = ''; MeshCoreConnector? _connector; DateTime? _lastChannelSendAt; @@ -60,12 +64,17 @@ class _ChannelChatScreenState extends State { super.initState(); _textFieldFocusNode.addListener(_onTextFieldFocusChange); _scrollController.onScrollNearTop = _loadOlderMessages; + region = context.read().getChannelRegion( + widget.channel.index, + ); + SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final connector = context.read(); final settings = context.read().settings; final idx = widget.channel.index; final unread = connector.getUnreadCountForChannelIndex(idx); + ChannelMessage? anchor; if (settings.jumpToOldestUnread && unread > 0) { anchor = _findOldestUnreadChannelAnchor( @@ -166,47 +175,81 @@ class _ChannelChatScreenState extends State { @override Widget build(BuildContext context) { + final connector = context.watch(); + + // Determine icon and colors based on channel type + IconData icon = Icons.lock; + Color iconColor = Colors.blue; + Color bgColor = Colors.blue.withValues(alpha: 0.2); + + // TODO(clauwn): add community handling + final isCommunityChannel = false; + final isCommunityPublic = false; + + if (isCommunityChannel) { + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + icon = isCommunityPublic ? Icons.groups : Icons.tag; + } else if (widget.channel.isPublicChannel) { + icon = Icons.public; + iconColor = Colors.green; + bgColor = Colors.green.withValues(alpha: 0.2); + } else if (widget.channel.isHashtagChannel) { + icon = Icons.tag; + } + + final regionHeader = region != '' + ? context.l10n.channels_regionSetTo(region) + : context.l10n.channels_regionNotSet; + return Scaffold( appBar: AppBar( - title: Row( - children: [ - Icon( - widget.channel.isPublicChannel ? Icons.public : Icons.tag, - size: 20, - ), - 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), - ), - 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( + onTap: () => openRegionSelectDialog(widget.channel), + child: Row( + children: [ + CircleAvatar( + backgroundColor: bgColor, + child: Icon(icon, color: iconColor), ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Consumer( + builder: (context, connector, _) { + return Text( + widget.channel.name.isEmpty + ? context.l10n.channels_channelIndex( + widget.channel.index, + ) + : widget.channel.name, + style: const TextStyle(fontSize: 16), + ); + }, + ), + Consumer( + builder: (context, connector, _) { + final unreadCount = connector + .getUnreadCountForChannelIndex( + widget.channel.index, + ); + return Text( + '$regionHeader • ${context.l10n.chat_unread(unreadCount)}', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ); + }, + ), + ], + ), + ), + ], + ), ), centerTitle: false, + titleSpacing: 0, actions: [ const RadioStatsIconButton(), PopupMenuButton( @@ -1341,6 +1384,117 @@ class _ChannelChatScreenState extends State { .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(','); } + + void openRegionSelectDialog(Channel channel) async { + await showDialog( + context: context, + builder: (BuildContext context) => _RegionSelectDialog(channel: channel), + ); + if (context.mounted) { + await _connector?.loadChannelSettings(); + setState(() { + region = _connector?.getChannelRegion(channel.index) ?? ''; + }); + } + } +} + +class _RegionSelectDialog extends StatefulWidget { + final Channel channel; + + const _RegionSelectDialog({required this.channel}); + + @override + _RegionSelectDialogState createState() => _RegionSelectDialogState(); +} + +class _RegionSelectDialogState extends State<_RegionSelectDialog> { + final RegionStore regionStore = RegionStore(); + + List regions = []; + int selectedIndex = 0; + + @override + void initState() { + super.initState(); + loadRegions(); + } + + void loadRegions() { + setState(() { + regions = regionStore.loadRegions(); + Region channelRegion = context.read().getChannelRegion( + widget.channel.index, + ); + selectedIndex = regions.indexOf(channelRegion); + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppBar( + backgroundColor: Colors.transparent, + title: AdaptiveAppBarTitle( + 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, + '', + ); + if (context.mounted) { + Navigator.pop(context); + } + }, + ), + IconButton( + tooltip: context.l10n.settings_regionSettingsSubtitle, + icon: const Icon(Icons.settings), + onPressed: () async { + await pushRegionManagementScreen(context); + loadRegions(); + }, + ), + ], + ), + SizedBox(height: 15), + Expanded( + child: ListView.builder( + itemCount: regions.length, + itemBuilder: (context, index) => ListTile( + title: Text(regions[index]), + tileColor: selectedIndex == index + ? Colors.blue.withValues(alpha: 0.2) + : null, + onTap: () { + context.read().setChannelRegion( + widget.channel.index, + regions[index], + ); + if (context.mounted) { + Navigator.pop(context); + } + }, + ), + ), + ), + ], + ), + ), + ); + } } class _SwipeReplyBubble extends StatefulWidget { diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 51d24533..71417117 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:math'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:meshcore_open/storage/channel_message_store.dart'; import 'package:meshcore_open/utils/platform_info.dart'; import 'package:meshcore_open/widgets/app_bar.dart'; @@ -380,10 +380,15 @@ class _ChannelsScreenState extends State isCommunityChannel && _isCommunityPublicChannel(channel, community); // Determine icon and colors based on channel type - IconData icon; - Color iconColor; - Color bgColor; - String subtitle; + IconData icon = Icons.lock; + Color iconColor = Colors.blue; + Color bgColor = Colors.blue.withValues(alpha: 0.2); + String region = connector.hasChannelRegion(channel.index) + ? context.l10n.channels_regionSetTo( + connector.getChannelRegion(channel.index), + ) + : context.l10n.channels_regionNotSet; + String subtitle = region; if (isCommunityChannel) { // Community channel styling @@ -402,17 +407,8 @@ class _ChannelsScreenState extends State icon = Icons.public; iconColor = Colors.green; bgColor = Colors.green.withValues(alpha: 0.2); - subtitle = context.l10n.channels_publicChannel; - } else if (channel.name.startsWith('#')) { + } else if (channel.isHashtagChannel) { icon = Icons.tag; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); - subtitle = context.l10n.channels_hashtagChannel; - } else { - icon = Icons.lock; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); - subtitle = context.l10n.channels_privateChannel; } return Card( diff --git a/lib/screens/region_management_screen.dart b/lib/screens/region_management_screen.dart new file mode 100644 index 00000000..5ebe6327 --- /dev/null +++ b/lib/screens/region_management_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/l10n/l10n.dart'; +import 'package:meshcore_open/storage/region_store.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 { + final RegionStore _regionStore = RegionStore(); + List _regions = []; + + String region = ''; + + @override + void initState() { + super.initState(); + final connector = context.read(); + _regionStore.setPublicKeyHex = connector.selfPublicKeyHex; + _loadRegions(); + } + + void _loadRegions() { + context.read().loadChannelSettings(); + + 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), + ), + ], + ), + 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(text: region); + 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), + ), + ], + ), + ); + } + + void _handleAddRegion(Region region, BuildContext context) { + Navigator.pop(context); + _regionStore.addRegion(region); + _loadRegions(); + } + + Widget _buildRegionTile(BuildContext context, Region region) { + return Card( + key: ValueKey(region), + child: ListTile( + dense: false, + title: Text(region), + trailing: IconButton( + icon: Icon(Icons.delete_outline), + 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 { + Navigator.pop(dialogContext); + await _regionStore.removeRegion(region); + _loadRegions(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.settings_regionDeleted)), + ); + }, + child: Text( + context.l10n.common_delete, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e9b73f83..150eaa9d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -15,6 +15,7 @@ import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import '../widgets/radio_stats_entry.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). @@ -287,6 +288,14 @@ class _SettingsScreenState extends State { onTap: () => _showRadioSettings(context, connector), ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.landscape), + title: Text(l10n.settings_regionSettings), + subtitle: Text(l10n.settings_regionSettingsSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => pushRegionManagementScreen(context), + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.sensors_outlined), title: Text(l10n.radioStats_settingsTile), 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..eead0899 --- /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) { + channelRegionStore.saveRegion(channel.index, ''); + } + } + regions.remove(region); + saveRegions(regions); + } +} diff --git a/untranslated.json b/untranslated.json index 1ebd9bc5..d6e7a51c 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,18 @@ { "bg": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], @@ -8,62 +21,257 @@ ], "es": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "fr": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "hu": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "it": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "ja": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "ko": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "nl": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "pl": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "pt": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "ru": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "sk": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "sl": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "sv": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "uk": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ], "zh": [ + "settings_regionSettings", + "settings_regionSettingsSubtitle", + "settings_regionManagement_screenTitle", + "settings_regionNameHint", + "settings_regionAddRegion", + "settings_regionName", + "settings_regionDeleted", + "settings_deleteRegion", + "settings_deleteRegionConfirm", + "channels_regionSetTo", + "channels_regionNotSet", + "channels_regionSelect_Title", + "channels_clearRegion", "chat_sendMessage" ] } From b72ade1387f304c0f0778b949d07f491395f6b6a Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sun, 31 May 2026 00:22:04 +0300 Subject: [PATCH 2/3] Update channel_chat_screen.dart --- lib/screens/channel_chat_screen.dart | 3645 +++++++++++++------------- 1 file changed, 1805 insertions(+), 1840 deletions(-) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index e52cf815..e9bfb100 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1,1840 +1,1805 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; -import 'package:meshcore_open/screens/region_management_screen.dart'; -import 'package:meshcore_open/storage/region_store.dart'; -import 'package:meshcore_open/widgets/adaptive_app_bar_title.dart'; -import 'package:provider/provider.dart'; - -import '../connector/meshcore_connector.dart'; -import '../models/community.dart'; -import '../storage/community_store.dart'; -import '../utils/platform_info.dart'; -import '../helpers/chat_scroll_controller.dart'; -import '../connector/meshcore_protocol.dart'; -import '../helpers/cyr2lat.dart'; -import '../helpers/gif_helper.dart'; -import '../helpers/reaction_helper.dart'; -import '../helpers/snack_bar_builder.dart'; -import '../l10n/l10n.dart'; -import '../models/channel.dart'; -import '../models/channel_message.dart'; -import '../models/translation_support.dart'; -import '../services/app_settings_service.dart'; -import '../services/chat_text_scale_service.dart'; -import '../services/translation_service.dart'; -import '../utils/emoji_utils.dart'; -import '../widgets/byte_count_input.dart'; -import '../widgets/chat_zoom_wrapper.dart'; -import '../widgets/emoji_picker.dart'; -import '../widgets/gif_message.dart'; -import '../widgets/jump_to_bottom_button.dart'; -import '../widgets/gif_picker.dart'; -import '../widgets/message_translation_button.dart'; -import '../widgets/message_status_icon.dart'; -import '../widgets/radio_stats_entry.dart'; -import '../widgets/sync_progress_overlay.dart'; -import '../widgets/translated_message_content.dart'; -import '../widgets/unread_divider.dart'; -import 'channel_message_path_screen.dart'; -import 'map_screen.dart'; - -class ChannelChatScreen extends StatefulWidget { - final Channel channel; - final int initialUnreadCount; - - const ChannelChatScreen({ - super.key, - required this.channel, - this.initialUnreadCount = 0, - }); - - @override - State createState() => _ChannelChatScreenState(); -} - -class _ChannelChatScreenState extends State { - final TextEditingController _textController = TextEditingController(); - final ChatScrollController _scrollController = ChatScrollController(); - final FocusNode _textFieldFocusNode = FocusNode(); - ChannelMessage? _replyingToMessage; - final CommunityStore _communityStore = CommunityStore(); - final CommunityPskIndex _communityIndex = CommunityPskIndex(); - final Map _messageKeys = {}; - bool _isLoadingOlder = false; - bool _communitiesLoaded = false; - Region region = ''; - - MeshCoreConnector? _connector; - DateTime? _lastChannelSendAt; - bool _channelSkipNextBottomSnap = false; - String? _unreadDividerMessageId; - - String? _cachedFormatLocale; - late DateFormat _hmFormat; - late DateFormat _mdFormat; - - @override - void initState() { - super.initState(); - _textFieldFocusNode.addListener(_onTextFieldFocusChange); - _scrollController.onScrollNearTop = _loadOlderMessages; - _scrollController.showJumpToBottom.addListener(_clearDividerAtBottom); - region = context.read().getChannelRegion( - widget.channel.index, - ); - - SchedulerBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final connector = context.read(); - final settings = context.read().settings; - final idx = widget.channel.index; - final unread = widget.initialUnreadCount; - final messages = connector.getChannelMessages(widget.channel); - _loadCommunities(); - ChannelMessage? anchor; - if (unread > 0) { - anchor = _findOldestUnreadChannelAnchor(messages, unread); - } - setState(() { - if (anchor != null) _unreadDividerMessageId = anchor.messageId; - }); - connector.setActiveChannel(idx); - _connector = connector; - if (anchor != null && settings.jumpToOldestUnread) { - _channelSkipNextBottomSnap = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _scrollController.jumpToEstimatedOffset( - unreadCount: unread, - totalMessages: messages.length, - onJumped: () { - if (!mounted) return; - _scrollToMessage(anchor!.messageId); - }, - ); - }); - } - }); - } - - // TODO: Reload communities when returning from another screen - Future _loadCommunities() async { - final connector = context.read(); - _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; - final communities = await _communityStore.loadCommunities(); - if (mounted) { - setState(() { - _communityIndex.initialize(communities); - _communitiesLoaded = true; - }); - } - } - - ChannelMessage? _findOldestUnreadChannelAnchor( - List messages, - int unreadCount, - ) { - if (unreadCount <= 0 || messages.isEmpty) return null; - var n = 0; - ChannelMessage? oldest; - for (final m in messages.reversed) { - if (m.isOutgoing) continue; - n++; - oldest = m; - if (n >= unreadCount) break; - } - return oldest; - } - - void _clearDividerAtBottom() { - if (!_scrollController.showJumpToBottom.value && - _unreadDividerMessageId != null) { - setState(() => _unreadDividerMessageId = null); - } - } - - void _onTextFieldFocusChange() { - if (_textFieldFocusNode.hasFocus && mounted) { - _scrollController.handleKeyboardOpen(); - } - } - - Future _loadOlderMessages() async { - if (_isLoadingOlder) return; - setState(() => _isLoadingOlder = true); - - final connector = context.read(); - await connector.loadOlderChannelMessages(widget.channel.index); - - if (mounted) { - setState(() => _isLoadingOlder = false); - } - } - - @override - void dispose() { - _connector?.setActiveChannel(null); - _scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom); - _textFieldFocusNode.removeListener(_onTextFieldFocusChange); - _textFieldFocusNode.dispose(); - _textController.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - void _setReplyingTo(ChannelMessage message) { - setState(() { - _replyingToMessage = message; - }); - } - - void _cancelReply() { - setState(() { - _replyingToMessage = null; - }); - } - - Future _scrollToMessage(String messageId) async { - final key = _messageKeys[messageId]; - if (key == null) { - showDismissibleSnackBar( - context, - content: Text(context.l10n.chat_originalMessageNotFound), - duration: const Duration(seconds: 2), - ); - return; - } - - final targetContext = key.currentContext; - if (targetContext == null) return; - - await Scrollable.ensureVisible( - targetContext, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - alignment: 0.3, - ); - } - - Widget _channelIcon(Channel channel) { - // Determine icon based on channel type - final ChannelType channelType = Channel.getChannelType( - channel, - _communityIndex, - ); - final bool isCommunityChannel = Channel.isCommunityChannel(channelType); - IconData icon; - switch (channelType) { - case ChannelType.communityPublic: - icon = Icons.groups; - case ChannelType.communityHashtag: - icon = Icons.tag; - case ChannelType.public: - icon = Icons.public; - case ChannelType.hashtag: - icon = Icons.tag; - case ChannelType.private: - icon = Icons.lock; - } - return Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: _communitiesLoaded - ? Icon(icon, size: 20) - : SizedBox.square(dimension: 20), - ), - if (isCommunityChannel) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: Colors.purple, - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).cardColor, - width: 2, - ), - ), - child: const Icon(Icons.people, size: 8, color: Colors.white), - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - final connector = context.watch(); - - // Determine icon and colors based on channel type - IconData icon = Icons.lock; - Color iconColor = Colors.blue; - Color bgColor = Colors.blue.withValues(alpha: 0.2); - - // TODO(clauwn): add community handling - final isCommunityChannel = false; - final isCommunityPublic = false; - - if (isCommunityChannel) { - iconColor = Colors.purple; - bgColor = Colors.purple.withValues(alpha: 0.2); - icon = isCommunityPublic ? Icons.groups : Icons.tag; - } else if (widget.channel.isPublicChannel) { - icon = Icons.public; - iconColor = Colors.green; - bgColor = Colors.green.withValues(alpha: 0.2); - } else if (widget.channel.isHashtagChannel) { - icon = Icons.tag; - } - - final regionHeader = region != '' - ? context.l10n.channels_regionSetTo(region) - : context.l10n.channels_regionNotSet; - - return Scaffold( - appBar: AppBar( - title: GestureDetector( - onTap: () => openRegionSelectDialog(widget.channel), - child: Row( - children: [ - CircleAvatar( - backgroundColor: bgColor, - child: Icon(icon, color: iconColor), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Consumer( - builder: (context, connector, _) { - return Text( - widget.channel.name.isEmpty - ? context.l10n.channels_channelIndex( - widget.channel.index, - ) - : widget.channel.name, - style: const TextStyle(fontSize: 16), - ); - }, - ), - Consumer( - builder: (context, connector, _) { - final unreadCount = connector - .getUnreadCountForChannelIndex( - widget.channel.index, - ); - return Text( - '$regionHeader • ${context.l10n.chat_unread(unreadCount)}', - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ); - }, - ), - ], - ), - ), - ], - ), - ), - centerTitle: false, - bottom: const SyncProgressAppBarBottom(), - titleSpacing: 0, - actions: [ - const RadioStatsIconButton(), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - if (value == 'clearChat') { - context.read().clearMessagesForChannel( - widget.channel.index, - ); - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'clearChat', - child: Row( - children: [ - const Icon(Icons.delete, size: 20, color: Colors.red), - const SizedBox(width: 12), - Text( - context.l10n.contact_clearChat, - style: const TextStyle(color: Colors.red), - ), - ], - ), - ), - ], - ), - ], - ), - body: SafeArea( - top: false, - child: Column( - children: [ - Expanded( - child: Consumer( - builder: (context, connector, child) { - final messages = connector.getChannelMessages(widget.channel); - - if (messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - widget.channel.isPublicChannel - ? Icons.public - : Icons.tag, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - context.l10n.chat_noMessages, - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), - ), - const SizedBox(height: 8), - Text( - context.l10n.chat_sendMessageToStart, - style: TextStyle( - fontSize: 14, - color: Colors.grey[500], - ), - ), - ], - ), - ); - } - - // Reverse messages so newest appear at bottom with reverse: true - final reversedMessages = messages.reversed.toList(); - final itemCount = - reversedMessages.length + (_isLoadingOlder ? 1 : 0); - - // Auto-scroll to bottom if user is already at bottom - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_channelSkipNextBottomSnap) { - _channelSkipNextBottomSnap = false; - return; - } - _scrollController.scrollToBottomIfAtBottom(); - }); - - return Stack( - children: [ - ChatZoomWrapper( - child: ListView.builder( - reverse: true, // List grows from bottom up - controller: _scrollController, - padding: const EdgeInsets.all(8), - itemCount: itemCount, - itemBuilder: (context, index) { - // Loading indicator now appears at end (bottom) of reversed list - if (_isLoadingOlder && index == itemCount - 1) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ), - ); - } - final messageIndex = index; - final message = reversedMessages[messageIndex]; - if (!_messageKeys.containsKey(message.messageId)) { - _messageKeys[message.messageId] = GlobalKey(); - } - final isUnreadAnchor = - _unreadDividerMessageId != null && - message.messageId == _unreadDividerMessageId; - return Container( - key: _messageKeys[message.messageId]!, - child: Builder( - builder: (context) { - final textScale = context - .select( - (service) => service.scale, - ); - final bubble = _buildMessageBubble( - message, - textScale, - ); - if (isUnreadAnchor) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [const UnreadDivider(), bubble], - ); - } - return bubble; - }, - ), - ); - }, - ), - ), - JumpToBottomButton(scrollController: _scrollController), - ], - ); - }, - ), - ), - _buildMessageComposer(), - ], - ), - ), - ); - } - - void _markAsUnread(ChannelMessage message) { - final connector = context.read(); - final messages = connector.getChannelMessages(widget.channel); - var count = 0; - var found = false; - for (final m in messages) { - if (m.messageId == message.messageId) found = true; - if (found && !m.isOutgoing) count++; - } - connector.setChannelUnreadCount(widget.channel.index, count); - } - - Widget _buildMessageBubble(ChannelMessage message, double textScale) { - final settingsService = context.watch(); - final enableTracing = settingsService.settings.enableMessageTracing; - final isOutgoing = message.isOutgoing; - final gifId = GifHelper.parseGif(message.text); - final poi = parseMarkerText(message.text); - final translatedDisplayText = - message.translatedText != null && - message.translatedText!.trim().isNotEmpty - ? message.translatedText!.trim() - : message.text; - final originalDisplayText = message.isOutgoing - ? message.originalText - : (translatedDisplayText != message.text ? message.text : null); - final displayPath = message.pathBytes.isNotEmpty - ? message.pathBytes - : (message.pathVariants.isNotEmpty - ? message.pathVariants.first - : Uint8List(0)); - - const maxSwipeOffset = 64.0; - const replySwipeThreshold = 64.0; - const bodyFontSize = 14.0; - final messageBody = Column( - crossAxisAlignment: isOutgoing - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: isOutgoing - ? MainAxisAlignment.end - : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - _buildAvatar(message.senderName), - const SizedBox(width: 8), - ], - Flexible( - child: GestureDetector( - onTap: PlatformInfo.isDesktop - ? null - : () => _showMessagePathInfo(message), - onLongPress: () => _showMessageActions(message), - onSecondaryTapUp: PlatformInfo.isDesktop - ? (_) => _showMessageActions(message) - : null, - child: Container( - padding: gifId != null - ? const EdgeInsets.all(4) - : const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.65, - ), - decoration: BoxDecoration( - color: isOutgoing - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - Padding( - padding: gifId != null - ? const EdgeInsets.only( - left: 8, - top: 4, - bottom: 4, - ) - : EdgeInsets.zero, - child: Text( - message.senderName, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - if (gifId == null) const SizedBox(height: 4), - ], - if (message.replyToMessageId != null) ...[ - _buildReplyPreview(message, textScale), - const SizedBox(height: 8), - ], - if (poi != null) - _buildPoiMessage( - context, - poi, - isOutgoing, - textScale, - message.senderName, - trailing: (!enableTracing && isOutgoing) - ? Padding( - padding: const EdgeInsets.only(bottom: 2), - child: MessageStatusIcon( - isAcked: - message.status == - ChannelMessageStatus.sent && - displayPath.isNotEmpty, - isFailed: - message.status == - ChannelMessageStatus.failed, - ), - ) - : null, - ) - else if (gifId != null) - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Colors.transparent, - fallbackTextColor: isOutgoing - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.6), - ), - ), - if (!enableTracing && isOutgoing) - Positioned( - top: 0, - right: 0, - child: Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: isOutgoing - ? Theme.of( - context, - ).colorScheme.primaryContainer - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(10), - topRight: Radius.circular(8), - ), - ), - child: MessageStatusIcon( - isAcked: - message.status == - ChannelMessageStatus.sent && - displayPath.isNotEmpty, - isFailed: - message.status == - ChannelMessageStatus.failed, - ), - ), - ), - ], - ) - else - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: TranslatedMessageContent( - displayText: translatedDisplayText, - originalText: originalDisplayText, - style: TextStyle( - fontSize: bodyFontSize * textScale, - ), - originalStyle: TextStyle( - fontSize: bodyFontSize * textScale, - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.72), - ), - ), - ), - if (!enableTracing && isOutgoing) ...[ - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: MessageStatusIcon( - isAcked: - message.status == - ChannelMessageStatus.sent && - displayPath.isNotEmpty, - isFailed: - message.status == - ChannelMessageStatus.failed, - ), - ), - ], - ], - ), - if (enableTracing) ...[ - if (displayPath.isNotEmpty) ...[ - const SizedBox(height: 4), - Padding( - padding: gifId != null - ? const EdgeInsets.symmetric(horizontal: 8) - : EdgeInsets.zero, - child: Text( - context.l10n.channels_via( - _formatPathPrefixes(displayPath), - ), - style: TextStyle( - fontSize: 11, - color: Colors.grey[600], - ), - ), - ), - ], - const SizedBox(height: 4), - Padding( - padding: gifId != null - ? const EdgeInsets.only( - left: 8, - right: 8, - bottom: 4, - ) - : EdgeInsets.zero, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatTime(context, message.timestamp), - style: TextStyle( - fontSize: 11, - color: Colors.grey[600], - ), - ), - if (message.repeatCount > 0) ...[ - const SizedBox(width: 6), - Icon( - Icons.repeat, - size: 12, - color: Colors.grey[600], - ), - const SizedBox(width: 2), - Text( - '${message.repeatCount}', - style: TextStyle( - fontSize: 11, - color: Colors.grey[600], - ), - ), - ], - if (isOutgoing) ...[ - const SizedBox(width: 4), - Icon( - message.status == ChannelMessageStatus.sent - ? Icons.check - : message.status == - ChannelMessageStatus.pending - ? Icons.schedule - : Icons.error_outline, - size: 14, - color: - message.status == - ChannelMessageStatus.failed - ? Colors.red - : Colors.grey[600], - ), - ], - ], - ), - ), - ], - ], - ), - ), - ), - ), - ], - ), - if (message.reactions.isNotEmpty) ...[ - const SizedBox(height: 4), - Padding( - padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), - child: _buildReactionsDisplay(message), - ), - ], - ], - ); - - if (!isOutgoing && !PlatformInfo.isDesktop) { - return _SwipeReplyBubble( - maxSwipeOffset: maxSwipeOffset, - replySwipeThreshold: replySwipeThreshold, - onReplyTriggered: () => _setReplyingTo(message), - hintBuilder: ({required isStart}) => - _buildReplySwipeHint(isStart: isStart), - child: messageBody, - ); - } else { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: messageBody, - ); - } - } - - Widget _buildReplySwipeHint({required bool isStart}) { - final colorScheme = Theme.of(context).colorScheme; - final content = Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.reply, color: colorScheme.primary), - const SizedBox(width: 6), - Text( - context.l10n.chat_reply, - style: TextStyle( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - ); - - return Container( - alignment: isStart ? Alignment.centerLeft : Alignment.centerRight, - padding: const EdgeInsets.symmetric(horizontal: 16), - color: colorScheme.primary.withValues(alpha: 0.08), - child: isStart - ? content - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.chat_reply, - style: TextStyle( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 6), - Icon(Icons.reply, color: colorScheme.primary), - ], - ), - ); - } - - Widget _buildReplyPreview(ChannelMessage message, double textScale) { - final connector = context.read(); - final isOwnNode = message.replyToSenderName == connector.selfName; - final replyText = message.replyToText ?? ''; - final colorScheme = Theme.of(context).colorScheme; - final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7); - - final gifId = GifHelper.parseGif(replyText); - final poi = parseMarkerText(replyText); - - Widget contentPreview; - if (gifId != null) { - contentPreview = ClipRRect( - borderRadius: BorderRadius.circular(4), - child: GifMessage( - url: 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: colorScheme.surfaceContainerHighest, - fallbackTextColor: previewTextColor, - maxSize: 80, - ), - ); - } else if (poi != null) { - contentPreview = Row( - children: [ - Icon(Icons.location_on_outlined, size: 14, color: previewTextColor), - const SizedBox(width: 4), - Text( - context.l10n.chat_location, - style: TextStyle(fontSize: 12 * textScale, color: previewTextColor), - ), - ], - ); - } else { - contentPreview = Text( - replyText, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12 * textScale, - color: previewTextColor, - fontStyle: FontStyle.italic, - ), - ); - } - - return GestureDetector( - onTap: () => _scrollToMessage(message.replyToMessageId!), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), - border: Border( - left: BorderSide(color: colorScheme.primary, width: 3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.chat_replyTo(message.replyToSenderName ?? ''), - style: TextStyle( - fontSize: 11 * textScale, - fontWeight: FontWeight.bold, - color: isOwnNode - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 2), - contentPreview, - ], - ), - ), - ); - } - - Widget _buildReactionsDisplay(ChannelMessage message) { - return Wrap( - spacing: 6, - runSpacing: 6, - children: message.reactions.entries.map((entry) { - final emoji = entry.key; - final count = entry.value; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(emoji, style: const TextStyle(fontSize: 16)), - if (count > 1) ...[ - const SizedBox(width: 4), - Text( - '$count', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - ], - ], - ), - ); - }).toList(), - ); - } - - Widget _buildPoiMessage( - BuildContext context, - MarkerPayload poi, - bool isOutgoing, - double textScale, - String senderName, { - Widget? trailing, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textColor = isOutgoing - ? colorScheme.onPrimaryContainer - : colorScheme.onSurface; - final metaColor = textColor.withValues(alpha: 0.7); - final channelColor = widget.channel.isPublicChannel - ? Colors.orange - : Colors.blue; - - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: Icon(Icons.location_on_outlined, color: channelColor), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () { - final selfName = context.read().selfName ?? 'Me'; - final fromName = isOutgoing ? selfName : senderName; - final key = buildSharedMarkerKey( - sourceId: 'channel:${widget.channel.index}', - label: poi.label, - fromName: fromName, - flags: poi.flags, - isChannel: true, - ); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MapScreen( - highlightPosition: poi.position, - highlightLabel: poi.label, - highlightMarkerKey: key, - ), - ), - ); - }, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.chat_poiShared, - style: TextStyle( - color: textColor, - fontWeight: FontWeight.w600, - fontSize: 14 * textScale, - ), - ), - if (poi.label.isNotEmpty) - Text( - poi.label, - style: TextStyle(color: metaColor, fontSize: 12 * textScale), - ), - ], - ), - ), - if (trailing != null) ...[const SizedBox(width: 4), trailing], - ], - ); - } - - void _showGifPicker(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => GifPicker( - onGifSelected: (gifId) { - _textController.text = GifHelper.encodeGif(gifId); - }, - ), - ); - } - - Widget _buildAvatar(String senderName) { - final initial = _getFirstCharacterOrEmoji(senderName); - final color = _getColorForName(senderName); - - return CircleAvatar( - radius: 18, - backgroundColor: color.withValues(alpha: 0.2), - child: Text( - initial, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ); - } - - String _getFirstCharacterOrEmoji(String name) { - if (name.isEmpty) return '?'; - - final emoji = firstEmoji(name); - if (emoji != null) return emoji; - - final runes = name.runes.toList(); - if (runes.isEmpty) return '?'; - return String.fromCharCode(runes[0]).toUpperCase(); - } - - Color _getColorForName(String name) { - // Generate a consistent color based on the name hash - final hash = name.hashCode; - final colors = [ - Colors.blue, - Colors.green, - Colors.orange, - Colors.purple, - Colors.pink, - Colors.teal, - Colors.indigo, - Colors.cyan, - Colors.amber, - Colors.deepOrange, - ]; - - return colors[hash.abs() % colors.length]; - } - - Widget _buildReplyBanner(double textScale) { - final message = _replyingToMessage!; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), - ), - ), - child: Row( - children: [ - Icon( - Icons.reply, - size: 18, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.chat_replyingTo(message.senderName), - style: TextStyle( - fontSize: 12 * textScale, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - Text( - message.text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11 * textScale, - color: Theme.of( - context, - ).colorScheme.onSecondaryContainer.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: _cancelReply, - color: Theme.of(context).colorScheme.onSecondaryContainer, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - ); - } - - Widget _buildMessageComposer() { - final connector = context.watch(); - final maxBytes = maxChannelMessageBytes(connector.selfName); - final settings = context.watch().settings; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_replyingToMessage != null) - Builder( - builder: (context) { - final textScale = context.select( - (service) => service.scale, - ); - return _buildReplyBanner(textScale); - }, - ), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], - ), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.gif_box), - onPressed: () => _showGifPicker(context), - tooltip: context.l10n.chat_sendGif, - ), - if (settings.translationEnabled) - MessageTranslationButton( - enabled: settings.composerTranslationEnabled, - languageCode: settings.translationTargetLanguageCode, - onPressed: _showTranslationOptions, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: _textController, - builder: (context, value, child) { - final gifId = GifHelper.parseGif(value.text); - if (gifId != null) { - return Focus( - autofocus: true, - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - (event.logicalKey == LogicalKeyboardKey.enter || - event.logicalKey == - LogicalKeyboardKey.numpadEnter)) { - _sendMessage(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - fallbackTextColor: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.6), - maxSize: 160, - ), - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _textController.clear(); - _textFieldFocusNode.requestFocus(); - }, - ), - ], - ), - ); - } - return ByteCountedTextField( - maxBytes: maxBytes, - controller: _textController, - focusNode: _textFieldFocusNode, - hintText: context.l10n.chat_typeMessage, - onSubmitted: (_) => _sendMessage(), - encoder: - (connector.isChannelSmazEnabled( - widget.channel.index, - ) || - connector.isChannelCyr2LatEnabled( - widget.channel.index, - )) - ? (text) => connector.prepareChannelOutboundText( - widget.channel.index, - text, - ) - : null, - decoration: InputDecoration( - hintText: context.l10n.chat_typeMessage, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 14, - ), - ), - ); - }, - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.send), - tooltip: context.l10n.chat_sendMessage, - onPressed: _sendMessage, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - ), - ], - ); - } - - Future _showTranslationOptions() async { - final settingsService = context.read(); - final settings = settingsService.settings; - await showMessageTranslationSheet( - context: context, - enabled: settings.composerTranslationEnabled, - selectedLanguageCode: settings.translationTargetLanguageCode, - onEnabledChanged: settingsService.setComposerTranslationEnabled, - onLanguageSelected: settingsService.setTranslationTargetLanguageCode, - ); - } - - Future _sendMessage() async { - final text = _textController.text.trim(); - if (text.isEmpty) return; - - final now = DateTime.now(); - if (_lastChannelSendAt != null && - now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) { - showDismissibleSnackBar( - context, - content: Text(context.l10n.chat_sendCooldown), - ); - return; - } - _lastChannelSendAt = now; - - final connector = context.read(); - final settings = context.read().settings; - final translationService = context.read(); - - String messageText = text; - String? originalText; - String? translatedLanguageCode; - String? translationModelId; - if (settings.translationEnabled) { - final targetLanguageCode = translationService.resolvedTargetLanguageCode( - Localizations.localeOf(context).languageCode, - ); - if (translationService.shouldTranslateOutgoing( - text: text, - targetLanguageCode: targetLanguageCode, - )) { - final result = await translationService.translateOutgoingText( - text: text, - targetLanguageCode: targetLanguageCode, - ); - if (!mounted) return; - if (result != null && - result.status == MessageTranslationStatus.completed && - result.translatedText.isNotEmpty) { - messageText = result.translatedText; - originalText = text; - translatedLanguageCode = result.targetLanguageCode; - translationModelId = result.modelId; - } - } - } - if (_replyingToMessage != null) { - messageText = '@[${_replyingToMessage!.senderName}] $messageText'; - } - - final maxBytes = maxChannelMessageBytes(connector.selfName); - final outboundText = connector.prepareChannelOutboundText( - widget.channel.index, - messageText, - ); - if (utf8.encode(outboundText).length > maxBytes) { - showDismissibleSnackBar( - context, - content: Text(context.l10n.chat_messageTooLong(maxBytes)), - ); - return; - } - - // When messageText is transformed with cyr2lat, it (generally) hasn't visual differences, - // but we getting messages doubles in chat screen (source text and transformed). - // To prevent, we'll perform transform of source before pass to main sender logic. - // We can pass whole text, senderName will be kept intact - if (connector.isChannelCyr2LatEnabled(widget.channel.index)) { - messageText = Cyr2Lat.encode(messageText); - } - // end transform - - _textController.clear(); - _cancelReply(); - _textFieldFocusNode.requestFocus(); - connector.sendChannelMessage( - widget.channel, - messageText, - originalText: originalText, - translatedLanguageCode: translatedLanguageCode, - translationModelId: translationModelId, - ); - } - - String _formatTime(BuildContext context, DateTime time) { - final now = DateTime.now(); - final diff = now.difference(time); - final locale = Localizations.localeOf(context).toString(); - if (locale != _cachedFormatLocale) { - _cachedFormatLocale = locale; - _hmFormat = DateFormat.Hm(locale); - _mdFormat = DateFormat.Md(locale); - } - final hm = _hmFormat.format(time); - - if (diff.inDays > 0) { - return '${_mdFormat.format(time)} $hm'; - } else { - return hm; - } - } - - void _showMessagePathInfo(ChannelMessage message) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ChannelMessagePathScreen(message: message, channelMessage: true), - ), - ); - } - - void _showMessageActions(ChannelMessage message) { - final translationService = context.read(); - final canTranslateMessage = - translationService.canTranslateIncoming( - text: message.text, - isCli: false, - isOutgoing: message.isOutgoing, - ) && - (message.translatedText?.trim().isEmpty ?? true); - - showModalBottomSheet( - context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.reply), - title: Text(context.l10n.chat_reply), - onTap: () { - Navigator.pop(sheetContext); - _setReplyingTo(message); - }, - ), - if (PlatformInfo.isDesktop) - ListTile( - leading: const Icon(Icons.route), - title: Text(context.l10n.chat_path), - onTap: () { - Navigator.pop(sheetContext); - _showMessagePathInfo(message); - }, - ), - // Can't react to your own messages - if (!message.isOutgoing) - ListTile( - leading: const Icon(Icons.add_reaction_outlined), - title: Text(context.l10n.chat_addReaction), - onTap: () { - Navigator.pop(sheetContext); - _showEmojiPicker(message); - }, - ), - ListTile( - leading: const Icon(Icons.copy), - title: Text(context.l10n.common_copy), - onTap: () { - Navigator.pop(sheetContext); - _copyMessageText(message.text); - }, - ), - if (canTranslateMessage) - ListTile( - leading: const Icon(Icons.translate), - title: Text(context.l10n.translation_translateMessage), - onTap: () { - Navigator.pop(sheetContext); - unawaited( - context.read().translateChannelMessage( - widget.channel.index, - message, - manualTranslation: true, - ), - ); - }, - ), - if (!message.isOutgoing) - ListTile( - leading: const Icon(Icons.mark_chat_unread_outlined), - title: Text(context.l10n.chat_markAsUnread), - onTap: () { - Navigator.pop(sheetContext); - _markAsUnread(message); - }, - ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: Text(context.l10n.common_delete), - onTap: () async { - Navigator.pop(sheetContext); - await _deleteMessage(message); - }, - ), - ListTile( - leading: const Icon(Icons.close), - title: Text(context.l10n.common_cancel), - onTap: () => Navigator.pop(sheetContext), - ), - ], - ), - ), - ); - } - - void _showEmojiPicker(ChannelMessage message) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => EmojiPicker( - onEmojiSelected: (emoji) { - _sendReaction(message, emoji); - }, - ), - ); - } - - void _sendReaction(ChannelMessage message, String emoji) { - final connector = context.read(); - final emojiIndex = ReactionHelper.emojiToIndex(emoji); - if (emojiIndex == null) return; // Unknown emoji, skip - final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000; - final hash = ReactionHelper.computeReactionHash( - timestampSecs, - message.senderName, - message.text, - ); - final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex); - connector.sendChannelMessage(widget.channel, reactionText); - } - - void _copyMessageText(String text) { - Clipboard.setData(ClipboardData(text: text)); - showDismissibleSnackBar( - context, - content: Text(context.l10n.chat_messageCopied), - ); - } - - Future _deleteMessage(ChannelMessage message) async { - await context.read().deleteChannelMessage(message); - if (!mounted) return; - showDismissibleSnackBar( - context, - content: Text(context.l10n.chat_messageDeleted), - ); - } - - String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); - } - - void openRegionSelectDialog(Channel channel) async { - await showDialog( - context: context, - builder: (BuildContext context) => _RegionSelectDialog(channel: channel), - ); - if (context.mounted) { - await _connector?.loadChannelSettings(); - setState(() { - region = _connector?.getChannelRegion(channel.index) ?? ''; - }); - } - } -} - -class _RegionSelectDialog extends StatefulWidget { - final Channel channel; - - const _RegionSelectDialog({required this.channel}); - - @override - _RegionSelectDialogState createState() => _RegionSelectDialogState(); -} - -class _RegionSelectDialogState extends State<_RegionSelectDialog> { - final RegionStore regionStore = RegionStore(); - - List regions = []; - int selectedIndex = 0; - - @override - void initState() { - super.initState(); - loadRegions(); - } - - void loadRegions() { - setState(() { - regions = regionStore.loadRegions(); - Region channelRegion = context.read().getChannelRegion( - widget.channel.index, - ); - selectedIndex = regions.indexOf(channelRegion); - }); - } - - @override - Widget build(BuildContext context) { - return Dialog( - child: Padding( - padding: EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppBar( - backgroundColor: Colors.transparent, - title: AdaptiveAppBarTitle( - 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, - '', - ); - if (context.mounted) { - Navigator.pop(context); - } - }, - ), - IconButton( - tooltip: context.l10n.settings_regionSettingsSubtitle, - icon: const Icon(Icons.settings), - onPressed: () async { - await pushRegionManagementScreen(context); - loadRegions(); - }, - ), - ], - ), - SizedBox(height: 15), - Expanded( - child: ListView.builder( - itemCount: regions.length, - itemBuilder: (context, index) => ListTile( - title: Text(regions[index]), - tileColor: selectedIndex == index - ? Colors.blue.withValues(alpha: 0.2) - : null, - onTap: () { - context.read().setChannelRegion( - widget.channel.index, - regions[index], - ); - if (context.mounted) { - Navigator.pop(context); - } - }, - ), - ), - ), - ], - ), - ), - ); - } -} - -class _SwipeReplyBubble extends StatefulWidget { - final double maxSwipeOffset; - final double replySwipeThreshold; - final VoidCallback onReplyTriggered; - final Widget Function({required bool isStart}) hintBuilder; - final Widget child; - - const _SwipeReplyBubble({ - required this.maxSwipeOffset, - required this.replySwipeThreshold, - required this.onReplyTriggered, - required this.hintBuilder, - required this.child, - }); - - @override - State<_SwipeReplyBubble> createState() => _SwipeReplyBubbleState(); -} - -class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> { - Offset? _swipeStartPosition; - double _swipeOffset = 0; - double _maxSwipeDistance = 0; - int? _swipePointerId; - bool _swipeLockedToHorizontal = false; - - void _handleSwipeStart(Offset position) { - _swipeStartPosition = position; - _maxSwipeDistance = 0; - if (_swipeOffset != 0) { - setState(() => _swipeOffset = 0); - } - } - - void _handleSwipePointerDown(PointerDownEvent event) { - _swipePointerId = event.pointer; - _swipeLockedToHorizontal = false; - _handleSwipeStart(event.position); - } - - void _handleSwipePointerMove(PointerMoveEvent event) { - if (_swipePointerId != event.pointer || _swipeStartPosition == null) { - return; - } - - final dx = event.position.dx - _swipeStartPosition!.dx; - - const axisLockThreshold = 12.0; - if (!_swipeLockedToHorizontal) { - if (-dx < axisLockThreshold) { - return; - } - _swipeLockedToHorizontal = true; - } - - _handleSwipeUpdate(event.position); - } - - void _handleSwipeUpdate(Offset position) { - if (_swipeStartPosition == null) return; - - final dx = position.dx - _swipeStartPosition!.dx; - if (dx >= 0) return; - - if (-dx < 6) return; - - if (-dx > _maxSwipeDistance) { - _maxSwipeDistance = -dx; - } - - final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble(); - final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset); - if (adjusted != _swipeOffset) { - setState(() => _swipeOffset = adjusted); - } - } - - void _handleSwipePointerUp(Offset position) { - if (_swipeLockedToHorizontal && _swipeStartPosition != null) { - final dx = position.dx - _swipeStartPosition!.dx; - final peak = math.max( - _maxSwipeDistance, - (-dx).clamp(0.0, double.infinity), - ); - if (peak >= widget.replySwipeThreshold) { - widget.onReplyTriggered(); - HapticFeedback.selectionClick(); - } - } - _resetSwipe(); - } - - void _resetSwipe() { - if (_swipeOffset != 0) { - setState(() => _swipeOffset = 0); - } - _swipeStartPosition = null; - _maxSwipeDistance = 0; - _swipePointerId = null; - _swipeLockedToHorizontal = false; - } - - double _applySwipeResistance(double rawOffset, double maxOffset) { - final abs = rawOffset.abs(); - if (abs <= 0) return 0; - final norm = (abs / maxOffset).clamp(0.0, 1.0); - const deadZone = 0.18; - if (norm <= deadZone) { - return rawOffset.sign * maxOffset * (norm * 0.08); - } - final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0); - final curved = t < 0.5 - ? 16 * math.pow(t, 5) - : 1 - math.pow(-2 * t + 2, 5) / 2; - const deadZoneEnd = 0.0144; - return rawOffset.sign * - maxOffset * - (deadZoneEnd + curved * (1 - deadZoneEnd)); - } - - @override - Widget build(BuildContext context) { - return Listener( - onPointerDown: _handleSwipePointerDown, - onPointerMove: _handleSwipePointerMove, - onPointerUp: (event) => _handleSwipePointerUp(event.position), - onPointerCancel: (_) => _resetSwipe(), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Stack( - alignment: Alignment.center, - children: [ - Positioned.fill( - child: Opacity( - opacity: _swipeOffset.abs() / widget.maxSwipeOffset, - child: widget.hintBuilder(isStart: false), - ), - ), - AnimatedContainer( - duration: const Duration(milliseconds: 150), - transform: Matrix4.translationValues(_swipeOffset, 0, 0), - curve: Curves.easeOut, - child: widget.child, - ), - ], - ), - ), - ); - } -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:meshcore_open/screens/region_management_screen.dart'; +import 'package:meshcore_open/storage/region_store.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../models/community.dart'; +import '../storage/community_store.dart'; +import '../utils/platform_info.dart'; +import '../helpers/chat_scroll_controller.dart'; +import '../connector/meshcore_protocol.dart'; +import '../helpers/cyr2lat.dart'; +import '../helpers/gif_helper.dart'; +import '../helpers/reaction_helper.dart'; +import '../helpers/snack_bar_builder.dart'; +import '../l10n/l10n.dart'; +import '../models/channel.dart'; +import '../models/channel_message.dart'; +import '../models/translation_support.dart'; +import '../services/app_settings_service.dart'; +import '../services/chat_text_scale_service.dart'; +import '../services/translation_service.dart'; +import '../utils/emoji_utils.dart'; +import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/byte_count_input.dart'; +import '../widgets/chat_zoom_wrapper.dart'; +import '../widgets/emoji_picker.dart'; +import '../widgets/gif_message.dart'; +import '../widgets/jump_to_bottom_button.dart'; +import '../widgets/gif_picker.dart'; +import '../widgets/message_translation_button.dart'; +import '../widgets/message_status_icon.dart'; +import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; +import '../widgets/translated_message_content.dart'; +import '../widgets/unread_divider.dart'; +import 'channel_message_path_screen.dart'; +import 'map_screen.dart'; + +class ChannelChatScreen extends StatefulWidget { + final Channel channel; + final int initialUnreadCount; + + const ChannelChatScreen({ + super.key, + required this.channel, + this.initialUnreadCount = 0, + }); + + @override + State createState() => _ChannelChatScreenState(); +} + +class _ChannelChatScreenState extends State { + final TextEditingController _textController = TextEditingController(); + final ChatScrollController _scrollController = ChatScrollController(); + final FocusNode _textFieldFocusNode = FocusNode(); + ChannelMessage? _replyingToMessage; + final CommunityStore _communityStore = CommunityStore(); + final CommunityPskIndex _communityIndex = CommunityPskIndex(); + final Map _messageKeys = {}; + bool _isLoadingOlder = false; + bool _communitiesLoaded = false; + Region region = ''; + + MeshCoreConnector? _connector; + DateTime? _lastChannelSendAt; + bool _channelSkipNextBottomSnap = false; + String? _unreadDividerMessageId; + + String? _cachedFormatLocale; + late DateFormat _hmFormat; + late DateFormat _mdFormat; + + @override + void initState() { + super.initState(); + _textFieldFocusNode.addListener(_onTextFieldFocusChange); + _scrollController.onScrollNearTop = _loadOlderMessages; + _scrollController.showJumpToBottom.addListener(_clearDividerAtBottom); + region = context.read().getChannelRegion( + widget.channel.index, + ); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final connector = context.read(); + final settings = context.read().settings; + final idx = widget.channel.index; + final unread = widget.initialUnreadCount; + final messages = connector.getChannelMessages(widget.channel); + _loadCommunities(); + ChannelMessage? anchor; + if (unread > 0) { + anchor = _findOldestUnreadChannelAnchor(messages, unread); + } + setState(() { + if (anchor != null) _unreadDividerMessageId = anchor.messageId; + }); + connector.setActiveChannel(idx); + _connector = connector; + if (anchor != null && settings.jumpToOldestUnread) { + _channelSkipNextBottomSnap = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _scrollController.jumpToEstimatedOffset( + unreadCount: unread, + totalMessages: messages.length, + onJumped: () { + if (!mounted) return; + _scrollToMessage(anchor!.messageId); + }, + ); + }); + } + }); + } + + // TODO: Reload communities when returning from another screen + Future _loadCommunities() async { + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + final communities = await _communityStore.loadCommunities(); + if (mounted) { + setState(() { + _communityIndex.initialize(communities); + _communitiesLoaded = true; + }); + } + } + + ChannelMessage? _findOldestUnreadChannelAnchor( + List messages, + int unreadCount, + ) { + if (unreadCount <= 0 || messages.isEmpty) return null; + var n = 0; + ChannelMessage? oldest; + for (final m in messages.reversed) { + if (m.isOutgoing) continue; + n++; + oldest = m; + if (n >= unreadCount) break; + } + return oldest; + } + + void _clearDividerAtBottom() { + if (!_scrollController.showJumpToBottom.value && + _unreadDividerMessageId != null) { + setState(() => _unreadDividerMessageId = null); + } + } + + void _onTextFieldFocusChange() { + if (_textFieldFocusNode.hasFocus && mounted) { + _scrollController.handleKeyboardOpen(); + } + } + + Future _loadOlderMessages() async { + if (_isLoadingOlder) return; + setState(() => _isLoadingOlder = true); + + final connector = context.read(); + await connector.loadOlderChannelMessages(widget.channel.index); + + if (mounted) { + setState(() => _isLoadingOlder = false); + } + } + + @override + void dispose() { + _connector?.setActiveChannel(null); + _scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom); + _textFieldFocusNode.removeListener(_onTextFieldFocusChange); + _textFieldFocusNode.dispose(); + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _setReplyingTo(ChannelMessage message) { + setState(() { + _replyingToMessage = message; + }); + } + + void _cancelReply() { + setState(() { + _replyingToMessage = null; + }); + } + + Future _scrollToMessage(String messageId) async { + final key = _messageKeys[messageId]; + if (key == null) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.chat_originalMessageNotFound), + duration: const Duration(seconds: 2), + ); + return; + } + + final targetContext = key.currentContext; + if (targetContext == null) return; + + await Scrollable.ensureVisible( + targetContext, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: 0.3, + ); + } + + Widget _channelIcon(Channel channel) { + // Determine icon based on channel type + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + IconData icon; + switch (channelType) { + case ChannelType.communityPublic: + icon = Icons.groups; + case ChannelType.communityHashtag: + icon = Icons.tag; + case ChannelType.public: + icon = Icons.public; + case ChannelType.hashtag: + icon = Icons.tag; + case ChannelType.private: + icon = Icons.lock; + } + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: _communitiesLoaded + ? Icon(icon, size: 20) + : SizedBox.square(dimension: 20), + ), + if (isCommunityChannel) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: Colors.purple, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).cardColor, + width: 2, + ), + ), + child: const Icon(Icons.people, size: 8, color: Colors.white), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + 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), + ), + Consumer( + builder: (context, connector, _) { + final unreadCount = connector + .getUnreadCountForChannelIndex( + widget.channel.index, + ); + final regionHeader = region.isNotEmpty + ? context.l10n.channels_regionSetTo(region) + : context.l10n.channels_regionNotSet; + return Text( + '$regionHeader • ${context.l10n.chat_unread(unreadCount)}', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ); + }, + ), + ], + ), + ), + ], + ), + ), + centerTitle: false, + bottom: const SyncProgressAppBarBottom(), + actions: [ + const RadioStatsIconButton(), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + if (value == 'clearChat') { + context.read().clearMessagesForChannel( + widget.channel.index, + ); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'clearChat', + child: Row( + children: [ + const Icon(Icons.delete, size: 20, color: Colors.red), + const SizedBox(width: 12), + Text( + context.l10n.contact_clearChat, + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + ], + ), + ], + ), + body: SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: Consumer( + builder: (context, connector, child) { + final messages = connector.getChannelMessages(widget.channel); + + if (messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + widget.channel.isPublicChannel + ? Icons.public + : Icons.tag, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + context.l10n.chat_noMessages, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + context.l10n.chat_sendMessageToStart, + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + // Reverse messages so newest appear at bottom with reverse: true + final reversedMessages = messages.reversed.toList(); + final itemCount = + reversedMessages.length + (_isLoadingOlder ? 1 : 0); + + // Auto-scroll to bottom if user is already at bottom + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_channelSkipNextBottomSnap) { + _channelSkipNextBottomSnap = false; + return; + } + _scrollController.scrollToBottomIfAtBottom(); + }); + + return Stack( + children: [ + ChatZoomWrapper( + child: ListView.builder( + reverse: true, // List grows from bottom up + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: itemCount, + itemBuilder: (context, index) { + // Loading indicator now appears at end (bottom) of reversed list + if (_isLoadingOlder && index == itemCount - 1) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ); + } + final messageIndex = index; + final message = reversedMessages[messageIndex]; + if (!_messageKeys.containsKey(message.messageId)) { + _messageKeys[message.messageId] = GlobalKey(); + } + final isUnreadAnchor = + _unreadDividerMessageId != null && + message.messageId == _unreadDividerMessageId; + return Container( + key: _messageKeys[message.messageId]!, + child: Builder( + builder: (context) { + final textScale = context + .select( + (service) => service.scale, + ); + final bubble = _buildMessageBubble( + message, + textScale, + ); + if (isUnreadAnchor) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [const UnreadDivider(), bubble], + ); + } + return bubble; + }, + ), + ); + }, + ), + ), + JumpToBottomButton(scrollController: _scrollController), + ], + ); + }, + ), + ), + _buildMessageComposer(), + ], + ), + ), + ); + } + + void _markAsUnread(ChannelMessage message) { + final connector = context.read(); + final messages = connector.getChannelMessages(widget.channel); + var count = 0; + var found = false; + for (final m in messages) { + if (m.messageId == message.messageId) found = true; + if (found && !m.isOutgoing) count++; + } + connector.setChannelUnreadCount(widget.channel.index, count); + } + + Widget _buildMessageBubble(ChannelMessage message, double textScale) { + final settingsService = context.watch(); + final enableTracing = settingsService.settings.enableMessageTracing; + final isOutgoing = message.isOutgoing; + final gifId = GifHelper.parseGif(message.text); + final poi = parseMarkerText(message.text); + final translatedDisplayText = + message.translatedText != null && + message.translatedText!.trim().isNotEmpty + ? message.translatedText!.trim() + : message.text; + final originalDisplayText = message.isOutgoing + ? message.originalText + : (translatedDisplayText != message.text ? message.text : null); + final displayPath = message.pathBytes.isNotEmpty + ? message.pathBytes + : (message.pathVariants.isNotEmpty + ? message.pathVariants.first + : Uint8List(0)); + + const maxSwipeOffset = 64.0; + const replySwipeThreshold = 64.0; + const bodyFontSize = 14.0; + final messageBody = Column( + crossAxisAlignment: isOutgoing + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isOutgoing + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + _buildAvatar(message.senderName), + const SizedBox(width: 8), + ], + Flexible( + child: GestureDetector( + onTap: PlatformInfo.isDesktop + ? null + : () => _showMessagePathInfo(message), + onLongPress: () => _showMessageActions(message), + onSecondaryTapUp: PlatformInfo.isDesktop + ? (_) => _showMessageActions(message) + : null, + child: Container( + padding: gifId != null + ? const EdgeInsets.all(4) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.65, + ), + decoration: BoxDecoration( + color: isOutgoing + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + Padding( + padding: gifId != null + ? const EdgeInsets.only( + left: 8, + top: 4, + bottom: 4, + ) + : EdgeInsets.zero, + child: Text( + message.senderName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + if (gifId == null) const SizedBox(height: 4), + ], + if (message.replyToMessageId != null) ...[ + _buildReplyPreview(message, textScale), + const SizedBox(height: 8), + ], + if (poi != null) + _buildPoiMessage( + context, + poi, + isOutgoing, + textScale, + message.senderName, + trailing: (!enableTracing && isOutgoing) + ? Padding( + padding: const EdgeInsets.only(bottom: 2), + child: MessageStatusIcon( + isAcked: + message.status == + ChannelMessageStatus.sent && + displayPath.isNotEmpty, + isFailed: + message.status == + ChannelMessageStatus.failed, + ), + ) + : null, + ) + else if (gifId != null) + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: Colors.transparent, + fallbackTextColor: isOutgoing + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.6), + ), + ), + if (!enableTracing && isOutgoing) + Positioned( + top: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: isOutgoing + ? Theme.of( + context, + ).colorScheme.primaryContainer + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(10), + topRight: Radius.circular(8), + ), + ), + child: MessageStatusIcon( + isAcked: + message.status == + ChannelMessageStatus.sent && + displayPath.isNotEmpty, + isFailed: + message.status == + ChannelMessageStatus.failed, + ), + ), + ), + ], + ) + else + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: TranslatedMessageContent( + displayText: translatedDisplayText, + originalText: originalDisplayText, + style: TextStyle( + fontSize: bodyFontSize * textScale, + ), + originalStyle: TextStyle( + fontSize: bodyFontSize * textScale, + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.72), + ), + ), + ), + if (!enableTracing && isOutgoing) ...[ + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: MessageStatusIcon( + isAcked: + message.status == + ChannelMessageStatus.sent && + displayPath.isNotEmpty, + isFailed: + message.status == + ChannelMessageStatus.failed, + ), + ), + ], + ], + ), + if (enableTracing) ...[ + if (displayPath.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: gifId != null + ? const EdgeInsets.symmetric(horizontal: 8) + : EdgeInsets.zero, + child: Text( + context.l10n.channels_via( + _formatPathPrefixes(displayPath), + ), + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ), + ], + const SizedBox(height: 4), + Padding( + padding: gifId != null + ? const EdgeInsets.only( + left: 8, + right: 8, + bottom: 4, + ) + : EdgeInsets.zero, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(context, message.timestamp), + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + if (message.repeatCount > 0) ...[ + const SizedBox(width: 6), + Icon( + Icons.repeat, + size: 12, + color: Colors.grey[600], + ), + const SizedBox(width: 2), + Text( + '${message.repeatCount}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ], + if (isOutgoing) ...[ + const SizedBox(width: 4), + Icon( + message.status == ChannelMessageStatus.sent + ? Icons.check + : message.status == + ChannelMessageStatus.pending + ? Icons.schedule + : Icons.error_outline, + size: 14, + color: + message.status == + ChannelMessageStatus.failed + ? Colors.red + : Colors.grey[600], + ), + ], + ], + ), + ), + ], + ], + ), + ), + ), + ), + ], + ), + if (message.reactions.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), + child: _buildReactionsDisplay(message), + ), + ], + ], + ); + + if (!isOutgoing && !PlatformInfo.isDesktop) { + return _SwipeReplyBubble( + maxSwipeOffset: maxSwipeOffset, + replySwipeThreshold: replySwipeThreshold, + onReplyTriggered: () => _setReplyingTo(message), + hintBuilder: ({required isStart}) => + _buildReplySwipeHint(isStart: isStart), + child: messageBody, + ); + } else { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: messageBody, + ); + } + } + + Widget _buildReplySwipeHint({required bool isStart}) { + final colorScheme = Theme.of(context).colorScheme; + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.reply, color: colorScheme.primary), + const SizedBox(width: 6), + Text( + context.l10n.chat_reply, + style: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + + return Container( + alignment: isStart ? Alignment.centerLeft : Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 16), + color: colorScheme.primary.withValues(alpha: 0.08), + child: isStart + ? content + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.chat_reply, + style: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + Icon(Icons.reply, color: colorScheme.primary), + ], + ), + ); + } + + Widget _buildReplyPreview(ChannelMessage message, double textScale) { + final connector = context.read(); + final isOwnNode = message.replyToSenderName == connector.selfName; + final replyText = message.replyToText ?? ''; + final colorScheme = Theme.of(context).colorScheme; + final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7); + + final gifId = GifHelper.parseGif(replyText); + final poi = parseMarkerText(replyText); + + Widget contentPreview; + if (gifId != null) { + contentPreview = ClipRRect( + borderRadius: BorderRadius.circular(4), + child: GifMessage( + url: 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: colorScheme.surfaceContainerHighest, + fallbackTextColor: previewTextColor, + maxSize: 80, + ), + ); + } else if (poi != null) { + contentPreview = Row( + children: [ + Icon(Icons.location_on_outlined, size: 14, color: previewTextColor), + const SizedBox(width: 4), + Text( + context.l10n.chat_location, + style: TextStyle(fontSize: 12 * textScale, color: previewTextColor), + ), + ], + ); + } else { + contentPreview = Text( + replyText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12 * textScale, + color: previewTextColor, + fontStyle: FontStyle.italic, + ), + ); + } + + return GestureDetector( + onTap: () => _scrollToMessage(message.replyToMessageId!), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide(color: colorScheme.primary, width: 3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.chat_replyTo(message.replyToSenderName ?? ''), + style: TextStyle( + fontSize: 11 * textScale, + fontWeight: FontWeight.bold, + color: isOwnNode + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + contentPreview, + ], + ), + ), + ); + } + + Widget _buildReactionsDisplay(ChannelMessage message) { + return Wrap( + spacing: 6, + runSpacing: 6, + children: message.reactions.entries.map((entry) { + final emoji = entry.key; + final count = entry.value; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 16)), + if (count > 1) ...[ + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ], + ], + ), + ); + }).toList(), + ); + } + + Widget _buildPoiMessage( + BuildContext context, + MarkerPayload poi, + bool isOutgoing, + double textScale, + String senderName, { + Widget? trailing, + }) { + final colorScheme = Theme.of(context).colorScheme; + final textColor = isOutgoing + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface; + final metaColor = textColor.withValues(alpha: 0.7); + final channelColor = widget.channel.isPublicChannel + ? Colors.orange + : Colors.blue; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Icons.location_on_outlined, color: channelColor), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () { + final selfName = context.read().selfName ?? 'Me'; + final fromName = isOutgoing ? selfName : senderName; + final key = buildSharedMarkerKey( + sourceId: 'channel:${widget.channel.index}', + label: poi.label, + fromName: fromName, + flags: poi.flags, + isChannel: true, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MapScreen( + highlightPosition: poi.position, + highlightLabel: poi.label, + highlightMarkerKey: key, + ), + ), + ); + }, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.chat_poiShared, + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + fontSize: 14 * textScale, + ), + ), + if (poi.label.isNotEmpty) + Text( + poi.label, + style: TextStyle(color: metaColor, fontSize: 12 * textScale), + ), + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 4), trailing], + ], + ); + } + + void _showGifPicker(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => GifPicker( + onGifSelected: (gifId) { + _textController.text = GifHelper.encodeGif(gifId); + }, + ), + ); + } + + Widget _buildAvatar(String senderName) { + final initial = _getFirstCharacterOrEmoji(senderName); + final color = _getColorForName(senderName); + + return CircleAvatar( + radius: 18, + backgroundColor: color.withValues(alpha: 0.2), + child: Text( + initial, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ); + } + + String _getFirstCharacterOrEmoji(String name) { + if (name.isEmpty) return '?'; + + final emoji = firstEmoji(name); + if (emoji != null) return emoji; + + final runes = name.runes.toList(); + if (runes.isEmpty) return '?'; + return String.fromCharCode(runes[0]).toUpperCase(); + } + + Color _getColorForName(String name) { + // Generate a consistent color based on the name hash + final hash = name.hashCode; + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.pink, + Colors.teal, + Colors.indigo, + Colors.cyan, + Colors.amber, + Colors.deepOrange, + ]; + + return colors[hash.abs() % colors.length]; + } + + Widget _buildReplyBanner(double textScale) { + final message = _replyingToMessage!; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), + ), + ), + child: Row( + children: [ + Icon( + Icons.reply, + size: 18, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.chat_replyingTo(message.senderName), + style: TextStyle( + fontSize: 12 * textScale, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + Text( + message.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11 * textScale, + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: _cancelReply, + color: Theme.of(context).colorScheme.onSecondaryContainer, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + + Widget _buildMessageComposer() { + final connector = context.watch(); + final maxBytes = maxChannelMessageBytes(connector.selfName); + final settings = context.watch().settings; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_replyingToMessage != null) + Builder( + builder: (context) { + final textScale = context.select( + (service) => service.scale, + ); + return _buildReplyBanner(textScale); + }, + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.gif_box), + onPressed: () => _showGifPicker(context), + tooltip: context.l10n.chat_sendGif, + ), + if (settings.translationEnabled) + MessageTranslationButton( + enabled: settings.composerTranslationEnabled, + languageCode: settings.translationTargetLanguageCode, + onPressed: _showTranslationOptions, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) { + final gifId = GifHelper.parseGif(value.text); + if (gifId != null) { + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == + LogicalKeyboardKey.numpadEnter)) { + _sendMessage(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + fallbackTextColor: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + maxSize: 160, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _textController.clear(); + _textFieldFocusNode.requestFocus(); + }, + ), + ], + ), + ); + } + return ByteCountedTextField( + maxBytes: maxBytes, + controller: _textController, + focusNode: _textFieldFocusNode, + hintText: context.l10n.chat_typeMessage, + onSubmitted: (_) => _sendMessage(), + encoder: + (connector.isChannelSmazEnabled( + widget.channel.index, + ) || + connector.isChannelCyr2LatEnabled( + widget.channel.index, + )) + ? (text) => connector.prepareChannelOutboundText( + widget.channel.index, + text, + ) + : null, + decoration: InputDecoration( + hintText: context.l10n.chat_typeMessage, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerLow, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + ); + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + tooltip: context.l10n.chat_sendMessage, + onPressed: _sendMessage, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + ); + } + + Future _showTranslationOptions() async { + final settingsService = context.read(); + final settings = settingsService.settings; + await showMessageTranslationSheet( + context: context, + enabled: settings.composerTranslationEnabled, + selectedLanguageCode: settings.translationTargetLanguageCode, + onEnabledChanged: settingsService.setComposerTranslationEnabled, + onLanguageSelected: settingsService.setTranslationTargetLanguageCode, + ); + } + + Future _sendMessage() async { + final text = _textController.text.trim(); + if (text.isEmpty) return; + + final now = DateTime.now(); + if (_lastChannelSendAt != null && + now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.chat_sendCooldown), + ); + return; + } + _lastChannelSendAt = now; + + final connector = context.read(); + final settings = context.read().settings; + final translationService = context.read(); + + String messageText = text; + String? originalText; + String? translatedLanguageCode; + String? translationModelId; + if (settings.translationEnabled) { + final targetLanguageCode = translationService.resolvedTargetLanguageCode( + Localizations.localeOf(context).languageCode, + ); + if (translationService.shouldTranslateOutgoing( + text: text, + targetLanguageCode: targetLanguageCode, + )) { + final result = await translationService.translateOutgoingText( + text: text, + targetLanguageCode: targetLanguageCode, + ); + if (!mounted) return; + if (result != null && + result.status == MessageTranslationStatus.completed && + result.translatedText.isNotEmpty) { + messageText = result.translatedText; + originalText = text; + translatedLanguageCode = result.targetLanguageCode; + translationModelId = result.modelId; + } + } + } + if (_replyingToMessage != null) { + messageText = '@[${_replyingToMessage!.senderName}] $messageText'; + } + + final maxBytes = maxChannelMessageBytes(connector.selfName); + final outboundText = connector.prepareChannelOutboundText( + widget.channel.index, + messageText, + ); + if (utf8.encode(outboundText).length > maxBytes) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.chat_messageTooLong(maxBytes)), + ); + return; + } + + // When messageText is transformed with cyr2lat, it (generally) hasn't visual differences, + // but we getting messages doubles in chat screen (source text and transformed). + // To prevent, we'll perform transform of source before pass to main sender logic. + // We can pass whole text, senderName will be kept intact + if (connector.isChannelCyr2LatEnabled(widget.channel.index)) { + messageText = Cyr2Lat.encode(messageText); + } + // end transform + + _textController.clear(); + _cancelReply(); + _textFieldFocusNode.requestFocus(); + connector.sendChannelMessage( + widget.channel, + messageText, + originalText: originalText, + translatedLanguageCode: translatedLanguageCode, + translationModelId: translationModelId, + ); + } + + String _formatTime(BuildContext context, DateTime time) { + final now = DateTime.now(); + final diff = now.difference(time); + final locale = Localizations.localeOf(context).toString(); + if (locale != _cachedFormatLocale) { + _cachedFormatLocale = locale; + _hmFormat = DateFormat.Hm(locale); + _mdFormat = DateFormat.Md(locale); + } + final hm = _hmFormat.format(time); + + if (diff.inDays > 0) { + return '${_mdFormat.format(time)} $hm'; + } else { + return hm; + } + } + + void _showMessagePathInfo(ChannelMessage message) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ChannelMessagePathScreen(message: message, channelMessage: true), + ), + ); + } + + void _showMessageActions(ChannelMessage message) { + final translationService = context.read(); + final canTranslateMessage = + translationService.canTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ) && + (message.translatedText?.trim().isEmpty ?? true); + + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.reply), + title: Text(context.l10n.chat_reply), + onTap: () { + Navigator.pop(sheetContext); + _setReplyingTo(message); + }, + ), + if (PlatformInfo.isDesktop) + ListTile( + leading: const Icon(Icons.route), + title: Text(context.l10n.chat_path), + onTap: () { + Navigator.pop(sheetContext); + _showMessagePathInfo(message); + }, + ), + // Can't react to your own messages + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: Text(context.l10n.chat_addReaction), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message); + }, + ), + ListTile( + leading: const Icon(Icons.copy), + title: Text(context.l10n.common_copy), + onTap: () { + Navigator.pop(sheetContext); + _copyMessageText(message.text); + }, + ), + if (canTranslateMessage) + ListTile( + leading: const Icon(Icons.translate), + title: Text(context.l10n.translation_translateMessage), + onTap: () { + Navigator.pop(sheetContext); + unawaited( + context.read().translateChannelMessage( + widget.channel.index, + message, + manualTranslation: true, + ), + ); + }, + ), + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: Text(context.l10n.chat_markAsUnread), + onTap: () { + Navigator.pop(sheetContext); + _markAsUnread(message); + }, + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: Text(context.l10n.common_delete), + onTap: () async { + Navigator.pop(sheetContext); + await _deleteMessage(message); + }, + ), + ListTile( + leading: const Icon(Icons.close), + title: Text(context.l10n.common_cancel), + onTap: () => Navigator.pop(sheetContext), + ), + ], + ), + ), + ); + } + + void _showEmojiPicker(ChannelMessage message) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + onEmojiSelected: (emoji) { + _sendReaction(message, emoji); + }, + ), + ); + } + + void _sendReaction(ChannelMessage message, String emoji) { + final connector = context.read(); + final emojiIndex = ReactionHelper.emojiToIndex(emoji); + if (emojiIndex == null) return; // Unknown emoji, skip + final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000; + final hash = ReactionHelper.computeReactionHash( + timestampSecs, + message.senderName, + message.text, + ); + final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex); + connector.sendChannelMessage(widget.channel, reactionText); + } + + void _copyMessageText(String text) { + Clipboard.setData(ClipboardData(text: text)); + showDismissibleSnackBar( + context, + content: Text(context.l10n.chat_messageCopied), + ); + } + + Future _deleteMessage(ChannelMessage message) async { + await context.read().deleteChannelMessage(message); + if (!mounted) return; + showDismissibleSnackBar( + context, + content: Text(context.l10n.chat_messageDeleted), + ); + } + + String _formatPathPrefixes(Uint8List pathBytes) { + return pathBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(','); + } + + Future openRegionSelectDialog(Channel channel) async { + await showDialog( + context: context, + builder: (BuildContext context) => _RegionSelectDialog(channel: channel), + ); + if (!mounted) return; + await _connector?.loadChannelSettings(); + if (!mounted) return; + setState(() { + region = _connector?.getChannelRegion(channel.index) ?? ''; + }); + } +} + +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: AdaptiveAppBarTitle( + 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) => ListTile( + title: Text(regions[index]), + tileColor: selectedIndex == index + ? Colors.blue.withValues(alpha: 0.2) + : null, + onTap: () { + context.read().setChannelRegion( + widget.channel.index, + regions[index], + ); + Navigator.pop(context); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SwipeReplyBubble extends StatefulWidget { + final double maxSwipeOffset; + final double replySwipeThreshold; + final VoidCallback onReplyTriggered; + final Widget Function({required bool isStart}) hintBuilder; + final Widget child; + + const _SwipeReplyBubble({ + required this.maxSwipeOffset, + required this.replySwipeThreshold, + required this.onReplyTriggered, + required this.hintBuilder, + required this.child, + }); + + @override + State<_SwipeReplyBubble> createState() => _SwipeReplyBubbleState(); +} + +class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> { + Offset? _swipeStartPosition; + double _swipeOffset = 0; + double _maxSwipeDistance = 0; + int? _swipePointerId; + bool _swipeLockedToHorizontal = false; + + void _handleSwipeStart(Offset position) { + _swipeStartPosition = position; + _maxSwipeDistance = 0; + if (_swipeOffset != 0) { + setState(() => _swipeOffset = 0); + } + } + + void _handleSwipePointerDown(PointerDownEvent event) { + _swipePointerId = event.pointer; + _swipeLockedToHorizontal = false; + _handleSwipeStart(event.position); + } + + void _handleSwipePointerMove(PointerMoveEvent event) { + if (_swipePointerId != event.pointer || _swipeStartPosition == null) { + return; + } + + final dx = event.position.dx - _swipeStartPosition!.dx; + + const axisLockThreshold = 12.0; + if (!_swipeLockedToHorizontal) { + if (-dx < axisLockThreshold) { + return; + } + _swipeLockedToHorizontal = true; + } + + _handleSwipeUpdate(event.position); + } + + void _handleSwipeUpdate(Offset position) { + if (_swipeStartPosition == null) return; + + final dx = position.dx - _swipeStartPosition!.dx; + if (dx >= 0) return; + + if (-dx < 6) return; + + if (-dx > _maxSwipeDistance) { + _maxSwipeDistance = -dx; + } + + final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble(); + final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset); + if (adjusted != _swipeOffset) { + setState(() => _swipeOffset = adjusted); + } + } + + void _handleSwipePointerUp(Offset position) { + if (_swipeLockedToHorizontal && _swipeStartPosition != null) { + final dx = position.dx - _swipeStartPosition!.dx; + final peak = math.max( + _maxSwipeDistance, + (-dx).clamp(0.0, double.infinity), + ); + if (peak >= widget.replySwipeThreshold) { + widget.onReplyTriggered(); + HapticFeedback.selectionClick(); + } + } + _resetSwipe(); + } + + void _resetSwipe() { + if (_swipeOffset != 0) { + setState(() => _swipeOffset = 0); + } + _swipeStartPosition = null; + _maxSwipeDistance = 0; + _swipePointerId = null; + _swipeLockedToHorizontal = false; + } + + double _applySwipeResistance(double rawOffset, double maxOffset) { + final abs = rawOffset.abs(); + if (abs <= 0) return 0; + final norm = (abs / maxOffset).clamp(0.0, 1.0); + const deadZone = 0.18; + if (norm <= deadZone) { + return rawOffset.sign * maxOffset * (norm * 0.08); + } + final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0); + final curved = t < 0.5 + ? 16 * math.pow(t, 5) + : 1 - math.pow(-2 * t + 2, 5) / 2; + const deadZoneEnd = 0.0144; + return rawOffset.sign * + maxOffset * + (deadZoneEnd + curved * (1 - deadZoneEnd)); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _handleSwipePointerDown, + onPointerMove: _handleSwipePointerMove, + onPointerUp: (event) => _handleSwipePointerUp(event.position), + onPointerCancel: (_) => _resetSwipe(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Opacity( + opacity: _swipeOffset.abs() / widget.maxSwipeOffset, + child: widget.hintBuilder(isStart: false), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 150), + transform: Matrix4.translationValues(_swipeOffset, 0, 0), + curve: Curves.easeOut, + child: widget.child, + ), + ], + ), + ), + ); + } +} From 0ca96ce5b116166011943b4f2e73fbbeaa1ea735 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sun, 31 May 2026 16:47:22 +0300 Subject: [PATCH 3/3] Regions discovery from nearby repeaters --- lib/connector/meshcore_connector.dart | 92 +++++- lib/connector/meshcore_protocol.dart | 69 +++++ lib/l10n/app_en.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_bg.dart | 10 + lib/l10n/app_localizations_de.dart | 10 + lib/l10n/app_localizations_en.dart | 10 + lib/l10n/app_localizations_es.dart | 10 + lib/l10n/app_localizations_fr.dart | 10 + lib/l10n/app_localizations_hu.dart | 10 + lib/l10n/app_localizations_it.dart | 10 + lib/l10n/app_localizations_ja.dart | 10 + lib/l10n/app_localizations_ko.dart | 10 + lib/l10n/app_localizations_nl.dart | 10 + lib/l10n/app_localizations_pl.dart | 10 + lib/l10n/app_localizations_pt.dart | 10 + lib/l10n/app_localizations_ru.dart | 10 + lib/l10n/app_localizations_sk.dart | 10 + lib/l10n/app_localizations_sl.dart | 10 + lib/l10n/app_localizations_sv.dart | 10 + lib/l10n/app_localizations_uk.dart | 10 + lib/l10n/app_localizations_zh.dart | 10 + lib/screens/region_management_screen.dart | 334 ++++++++++++++++++++++ untranslated.json | 54 ++++ 24 files changed, 737 insertions(+), 13 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 28010ca1..f95725e3 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -264,6 +264,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 @@ -679,7 +683,7 @@ class MeshCoreConnector extends ChangeNotifier { } bool hasChannelRegion(int channelIndex) { - return _channelRegions[channelIndex] != ''; + return (_channelRegions[channelIndex] ?? '').isNotEmpty; } Region getChannelRegion(int channelIndex) { @@ -3217,19 +3221,14 @@ class MeshCoreConnector extends ChangeNotifier { // Send the reaction to the device (don't add as a visible message) final reactionQueueId = _nextReactionSendQueueId(); _pendingChannelSentQueue.add(reactionQueueId); - try { - await sendFrame( - buildSetFloodScopeFrame(getChannelRegion(channel.index)), - ); + await _runScopedChannelSend(() async { await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( + await _sendFrameAndWaitForCommandAck( buildSendChannelTextMsgFrame(channel.index, text), channelSendQueueId: reactionQueueId, expectsGenericAck: true, ); - } finally { - await sendFrame(buildSetFloodScopeFrame('')); - } + }, region: getChannelRegion(channel.index)); return; } @@ -3246,16 +3245,80 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); final outboundText = prepareChannelOutboundText(channel.index, text); - try { - await sendFrame(buildSetFloodScopeFrame(getChannelRegion(channel.index))); + await _runScopedChannelSend(() async { await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); - await sendFrame( + await _sendFrameAndWaitForCommandAck( buildSendChannelTextMsgFrame(channel.index, outboundText), channelSendQueueId: message.messageId, expectsGenericAck: true, ); + }, 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 { + await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame(region)); + try { + await action(); + } finally { + if (isConnected) { + await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame('')); + } + } } finally { - await sendFrame(buildSetFloodScopeFrame('')); + completer.complete(); + } + } + + Future _sendFrameAndWaitForCommandAck( + Uint8List data, { + String? channelSendQueueId, + bool expectsGenericAck = false, + }) 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] == respCodeOk) { + 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(); } } @@ -3858,6 +3921,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; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index fdd90498..0f2deefa 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -207,6 +207,7 @@ const int cmdSendTelemetryReq = 39; const int cmdGetCustomVar = 40; const int cmdSetCustomVar = 41; const int cmdSendBinaryReq = 50; +const int cmdSendControlData = 55; const int cmdGetStats = 56; const int cmdSendAnonReq = 57; const int cmdSetAutoAddConfig = 58; @@ -226,6 +227,12 @@ const int reqTypeGetTelemetry = 0x03; const int reqTypeGetAccessList = 0x05; const int reqTypeGetNeighbors = 0x06; +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; @@ -268,6 +275,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; @@ -857,6 +865,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}) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 900e1b83..ddcdd075 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -154,6 +154,9 @@ "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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cc87e09f..542b69e4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -760,6 +760,24 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index a0f0e131..88958b0b 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -354,6 +354,16 @@ class AppLocalizationsBg extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e6b1d82b..44d82f16 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -357,6 +357,16 @@ class AppLocalizationsDe extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 01d401f0..389026e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -350,6 +350,16 @@ class AppLocalizationsEn extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a9f52b98..999d9893 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -354,6 +354,16 @@ class AppLocalizationsEs extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3e1d59b9..8aa80d86 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -356,6 +356,16 @@ class AppLocalizationsFr extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 34166d9d..065e011e 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -353,6 +353,16 @@ class AppLocalizationsHu extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 9d9e4d18..a4c98591 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -356,6 +356,16 @@ class AppLocalizationsIt extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8b52409c..5df46482 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -341,6 +341,16 @@ class AppLocalizationsJa extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a94fcec4..7656e97a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -340,6 +340,16 @@ class AppLocalizationsKo extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index b711f484..95304df7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -352,6 +352,16 @@ class AppLocalizationsNl extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index de06324b..01be4ec4 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -358,6 +358,16 @@ class AppLocalizationsPl extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 99195e36..5ca75ce9 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -356,6 +356,16 @@ class AppLocalizationsPt extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index cebab7ce..fb6f20f5 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -355,6 +355,16 @@ class AppLocalizationsRu extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 64d596f0..0c3d8b84 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -354,6 +354,16 @@ class AppLocalizationsSk extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f9e886ec..c9ccfc0e 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -352,6 +352,16 @@ class AppLocalizationsSl extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 9b9e8166..d13a4a99 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -352,6 +352,16 @@ class AppLocalizationsSv extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index efd9766c..ae67b5d4 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -354,6 +354,16 @@ class AppLocalizationsUk extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3f2dcf73..fa626e44 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -338,6 +338,16 @@ class AppLocalizationsZh extends AppLocalizations { @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'; diff --git a/lib/screens/region_management_screen.dart b/lib/screens/region_management_screen.dart index 5ebe6327..33a13b9c 100644 --- a/lib/screens/region_management_screen.dart +++ b/lib/screens/region_management_screen.dart @@ -1,7 +1,12 @@ +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:provider/provider.dart'; @@ -22,8 +27,11 @@ class RegionManagementScreen extends StatefulWidget { } class _RegionManagementScreenState extends State { + static final RegExp _validFetchedRegion = RegExp(r'^[a-z0-9-]{1,30}$'); + final RegionStore _regionStore = RegionStore(); List _regions = []; + bool _isFetchingRegions = false; String region = ''; @@ -59,6 +67,17 @@ class _RegionManagementScreenState extends State { 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( @@ -107,6 +126,321 @@ class _RegionManagementScreenState extends State { ); } + 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 Card( + child: ListTile( + title: Text(fetchedRegion), + trailing: 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); diff --git a/untranslated.json b/untranslated.json index d6a65bfb..f42fd2c3 100644 --- a/untranslated.json +++ b/untranslated.json @@ -5,6 +5,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -15,12 +18,21 @@ "channels_clearRegion" ], + "de": [ + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists" + ], + "es": [ "settings_regionSettings", "settings_regionSettingsSubtitle", "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -37,6 +49,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -53,6 +68,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -69,6 +87,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -85,6 +106,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -101,6 +125,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -117,6 +144,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -133,6 +163,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -149,6 +182,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -165,6 +201,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -181,6 +220,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -197,6 +239,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -213,6 +258,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -229,6 +277,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion", @@ -245,6 +296,9 @@ "settings_regionManagement_screenTitle", "settings_regionNameHint", "settings_regionAddRegion", + "settings_regionFetchRegions", + "settings_regionFetchRegionsFail", + "settings_regionFetchRegionsAlreadyExists", "settings_regionName", "settings_regionDeleted", "settings_deleteRegion",