From 6dad343004f813b4275a145b395673f30cb2103c Mon Sep 17 00:00:00 2001 From: ericz Date: Tue, 7 Apr 2026 17:53:46 +0200 Subject: [PATCH 1/2] Squashed commit for location sharing and input field popup. --- lib/connector/meshcore_connector.dart | 127 ++++++- lib/helpers/gif_helper.dart | 2 +- lib/helpers/reaction_helper.dart | 2 +- lib/helpers/utf8_length_limiter.dart | 46 ++- lib/l10n/app_bg.arb | 6 + lib/l10n/app_de.arb | 6 + lib/l10n/app_en.arb | 6 + lib/l10n/app_es.arb | 6 + lib/l10n/app_fr.arb | 6 + lib/l10n/app_hu.arb | 6 + lib/l10n/app_it.arb | 6 + lib/l10n/app_ja.arb | 6 + lib/l10n/app_ko.arb | 6 + lib/l10n/app_localizations.dart | 36 ++ lib/l10n/app_localizations_bg.dart | 19 ++ lib/l10n/app_localizations_de.dart | 18 + lib/l10n/app_localizations_en.dart | 18 + lib/l10n/app_localizations_es.dart | 19 ++ lib/l10n/app_localizations_fr.dart | 19 ++ lib/l10n/app_localizations_hu.dart | 18 + lib/l10n/app_localizations_it.dart | 19 ++ lib/l10n/app_localizations_ja.dart | 18 + lib/l10n/app_localizations_ko.dart | 18 + lib/l10n/app_localizations_nl.dart | 18 + lib/l10n/app_localizations_pl.dart | 19 ++ lib/l10n/app_localizations_pt.dart | 19 ++ lib/l10n/app_localizations_ru.dart | 19 ++ lib/l10n/app_localizations_sk.dart | 18 + lib/l10n/app_localizations_sl.dart | 18 + lib/l10n/app_localizations_sv.dart | 18 + lib/l10n/app_localizations_uk.dart | 19 ++ lib/l10n/app_localizations_zh.dart | 18 + lib/l10n/app_nl.arb | 6 + lib/l10n/app_pl.arb | 6 + lib/l10n/app_pt.arb | 6 + lib/l10n/app_ru.arb | 6 + lib/l10n/app_sk.arb | 6 + lib/l10n/app_sl.arb | 6 + lib/l10n/app_sv.arb | 6 + lib/l10n/app_uk.arb | 6 + lib/l10n/app_zh.arb | 6 + lib/screens/channel_chat_screen.dart | 339 ++++++++++++++++++- lib/screens/chat_screen.dart | 420 +++++++++++++++++++----- lib/screens/map_screen.dart | 98 ++++-- lib/services/message_retry_service.dart | 13 +- lib/widgets/emoji_picker.dart | 5 +- 46 files changed, 1374 insertions(+), 154 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index a8934f1..7cd9a79 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -151,6 +151,11 @@ class MeshCoreConnector extends ChangeNotifier { Timer? _batteryPollTimer; Timer? _radioStatsPollTimer; int _radioStatsPollRefCount = 0; + Timer? _locationSharingTimer; + DateTime? _locationSharingEnd; + String? _locationSharingContactKey; + int? _locationSharingChannelIndex; + String? _lastSharedLocationPositionKey; final ValueNotifier radioStatsNotifier = ValueNotifier(null); int _reconnectAttempts = 0; @@ -375,6 +380,8 @@ class MeshCoreConnector extends ChangeNotifier { bool? get clientRepeat => _clientRepeat; int? get firmwareVerCode => _firmwareVerCode; Map? get currentCustomVars => _currentCustomVars; + String? get locationSharingContactKey => _locationSharingContactKey; + int? get locationSharingChannelIndex => _locationSharingChannelIndex; int? get batteryMillivolts => _batteryMillivolts; int? get storageUsedKb => _storageUsedKb; int? get storageTotalKb => _storageTotalKb; @@ -2222,6 +2229,12 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.disconnecting); _stopBatteryPolling(); _stopRadioStatsPolling(); + _locationSharingTimer?.cancel(); + _locationSharingTimer = null; + _locationSharingEnd = null; + _locationSharingContactKey = null; + _locationSharingChannelIndex = null; + _lastSharedLocationPositionKey = null; await _usbFrameSubscription?.cancel(); _usbFrameSubscription = null; @@ -2384,6 +2397,100 @@ class MeshCoreConnector extends ChangeNotifier { _radioStatsPollTimer = null; } + void startLocationSharing({ + String? contactKey, + int? channelIndex, + required Duration duration, + }) { + _locationSharingTimer?.cancel(); + _locationSharingEnd = DateTime.now().add(duration); + _locationSharingContactKey = contactKey; + _locationSharingChannelIndex = channelIndex; + _lastSharedLocationPositionKey = null; + final gpsInterval = + int.tryParse(_currentCustomVars?['gps_interval'] ?? '') ?? 900; + _sendLocationOnce(contactKey, channelIndex); + _locationSharingTimer = Timer.periodic(Duration(seconds: gpsInterval), (_) { + if (!isConnected || DateTime.now().isAfter(_locationSharingEnd!)) { + stopLocationSharing(); + return; + } + _sendLocationOnce(contactKey, channelIndex); + }); + notifyListeners(); + } + + void stopLocationSharing() { + if (_locationSharingTimer == null && + _locationSharingEnd == null && + _locationSharingContactKey == null && + _locationSharingChannelIndex == null && + _lastSharedLocationPositionKey == null) { + return; + } + _locationSharingTimer?.cancel(); + _locationSharingTimer = null; + _locationSharingEnd = null; + _locationSharingContactKey = null; + _locationSharingChannelIndex = null; + _lastSharedLocationPositionKey = null; + notifyListeners(); + } + + @visibleForTesting + static String sharedLocationPositionKey(double lat, double lon) { + return '${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'; + } + + void _sendLocationOnce(String? contactKey, int? channelIndex) { + final lat = _selfLatitude; + final lon = _selfLongitude; + if (lat == null || lon == null) return; + final positionKey = sharedLocationPositionKey(lat, lon); + final label = deviceDisplayName.replaceAll('|', '/'); + final text = 'm:$positionKey|$label|loc'; + if (contactKey != null) { + final idx = _contacts.indexWhere((c) => c.publicKeyHex == contactKey); + if (idx < 0) return; + final contact = _contacts[idx]; + final resolved = resolvePathSelection(contact); + // For timed direct sharing in flood mode, only send when location changes. + if (resolved.useFlood && _lastSharedLocationPositionKey == positionKey) { + return; + } + if (_retryService != null) { + // Timed direct shares should participate in ACK/timeout handling. + unawaited( + _retryService!.sendMessageWithRetry( + contact: contact, + text: text, + forceClearPathOnMaxRetry: true, + ), + ); + } else { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + unawaited(_sendMessageDirect(contact, text, 1, now)); + } + if (resolved.useFlood) { + _lastSharedLocationPositionKey = positionKey; + } + } else if (channelIndex != null) { + // Keep channel traffic low by suppressing repeated unchanged location packets. + if (_lastSharedLocationPositionKey == positionKey) { + return; + } + _lastSharedLocationPositionKey = positionKey; + unawaited( + sendFrame( + buildSendChannelTextMsgFrame( + channelIndex, + prepareChannelOutboundText(channelIndex, text), + ), + ), + ); + } + } + void acquireRadioStatsPolling() { _radioStatsPollRefCount++; if (_radioStatsPollRefCount == 1 && isConnected) { @@ -2942,13 +3049,7 @@ class MeshCoreConnector extends ChangeNotifier { _pendingChannelSentQueue.add(message.messageId); notifyListeners(); - final trimmed = text.trim(); - final isStructuredPayload = - trimmed.startsWith('g:') || trimmed.startsWith('m:'); - final outboundText = - (isChannelSmazEnabled(channel.index) && !isStructuredPayload) - ? Smaz.encodeIfSmaller(text) - : text; + final outboundText = prepareChannelOutboundText(channel.index, text); await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); await sendFrame( buildSendChannelTextMsgFrame(channel.index, outboundText), @@ -4389,6 +4490,16 @@ class MeshCoreConnector extends ChangeNotifier { return text; } + String prepareChannelOutboundText(int channelIndex, String text) { + final trimmed = text.trim(); + final isStructuredPayload = + trimmed.startsWith('g:') || trimmed.startsWith('m:'); + if (!isStructuredPayload && isChannelSmazEnabled(channelIndex)) { + return Smaz.encodeIfSmaller(text); + } + return text; + } + String _channelDisplayName(int channelIndex) { for (final channel in _channels) { if (channel.index != channelIndex) continue; @@ -5434,6 +5545,8 @@ class MeshCoreConnector extends ChangeNotifier { void _handleDisconnection() { _stopBatteryPolling(); _stopRadioStatsPolling(); + _locationSharingTimer?.cancel(); + _locationSharingTimer = null; _latestRadioStats = null; radioStatsNotifier.value = null; _prevTotalAirSecs = 0; diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart index 8dd187b..5b68e90 100644 --- a/lib/helpers/gif_helper.dart +++ b/lib/helpers/gif_helper.dart @@ -30,7 +30,7 @@ class GifHelper { ).firstMatch(trimmed); return pageMatch?.group(1); } - + /// Encode a GIF in a format that parseGif() can parse. static String encodeGif(String gifId) { return 'g:$gifId'; diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 169b1a1..36118ca 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -109,7 +109,7 @@ class ReactionHelper { return ReactionInfo(targetHash: match.group(1)!, emoji: emoji); } - + /// Encode a reaction message that parseReaction() can parse. static String encodeReaction(String hash, String emojiIndex) { return 'r:$hash:$emojiIndex'; diff --git a/lib/helpers/utf8_length_limiter.dart b/lib/helpers/utf8_length_limiter.dart index c6acdd2..f706d49 100644 --- a/lib/helpers/utf8_length_limiter.dart +++ b/lib/helpers/utf8_length_limiter.dart @@ -2,10 +2,32 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +String truncateToUtf8Bytes(String text, int maxBytes) { + if (maxBytes <= 0) return ''; + + final buffer = StringBuffer(); + var usedBytes = 0; + for (final rune in text.runes) { + final character = String.fromCharCode(rune); + final characterBytes = utf8.encode(character).length; + if (usedBytes + characterBytes > maxBytes) break; + buffer.write(character); + usedBytes += characterBytes; + } + + return buffer.toString(); +} + class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { final int maxBytes; + final String Function(String)? encoder; - const Utf8LengthLimitingTextInputFormatter(this.maxBytes); + const Utf8LengthLimitingTextInputFormatter(this.maxBytes, {this.encoder}); + + int _effectiveByteLength(String text) { + final effective = encoder != null ? encoder!(text) : text; + return utf8.encode(effective).length; + } @override TextEditingValue formatEditUpdate( @@ -13,10 +35,9 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { TextEditingValue newValue, ) { if (maxBytes <= 0) return oldValue; - final bytes = utf8.encode(newValue.text); - if (bytes.length <= maxBytes) return newValue; + if (_effectiveByteLength(newValue.text) <= maxBytes) return newValue; - final truncated = _truncateToMaxBytes(newValue.text, maxBytes); + final truncated = _truncate(newValue.text); return TextEditingValue( text: truncated, selection: TextSelection.collapsed(offset: truncated.length), @@ -24,16 +45,13 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { ); } - String _truncateToMaxBytes(String text, int limit) { - final buffer = StringBuffer(); - var used = 0; - for (final rune in text.runes) { - final char = String.fromCharCode(rune); - final charBytes = utf8.encode(char).length; - if (used + charBytes > limit) break; - buffer.write(char); - used += charBytes; + String _truncate(String text) { + if (encoder == null) return truncateToUtf8Bytes(text, maxBytes); + final runes = text.runes.toList(); + while (runes.isNotEmpty && + _effectiveByteLength(String.fromCharCodes(runes)) > maxBytes) { + runes.removeLast(); } - return buffer.toString(); + return String.fromCharCodes(runes); } } diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 13e9de7..dc9a175 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Изпрати GIF", + "chat_insertEmoji": "Вмъкнете емоджи", + "chat_shareLocation": "Споделете местоположение", + "chat_stopSharingLocationConfirm": "Да спра ли споделянето на местоположение?", + "chat_once": "Веднъж", + "chat_locationUnavailable": "Местоположението не е налично", "chat_reply": "Отговори", "chat_addReaction": "Добави Реакция", "chat_me": "Аз", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Покажи споделени маркери", "map_lastSeenTime": "Последна видяна дата", "map_sharedPin": "Споделено копие", + "map_sharedAt": "Споделено", "map_joinRoom": "Присъедини се към стаята", "map_manageRepeater": "Управление на Повтарящ се Елемент", "mapCache_title": "Кеш на офлайн карти", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 62badce..07df73f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "GIF senden", + "chat_insertEmoji": "Emoji einfügen", + "chat_shareLocation": "Standort teilen", + "chat_stopSharingLocationConfirm": "Standortfreigabe beenden?", + "chat_once": "Einmal", + "chat_locationUnavailable": "Standort nicht verfügbar", "chat_reply": "Beantworten", "chat_addReaction": "Reaktion hinzufügen", "chat_me": "Ich", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Zeige gemeinsam genutzte Marker", "map_lastSeenTime": "Letzte Sichtung", "map_sharedPin": "Gemeinsames Passwort", + "map_sharedAt": "Geteilt", "map_joinRoom": "Beitreten Sie dem Raum", "map_manageRepeater": "Repeater verwalten", "mapCache_title": "Offline-Karten-Cache", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0617553..2480114 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -654,6 +654,11 @@ } }, "chat_sendGif": "Send GIF", + "chat_insertEmoji": "Insert emoji", + "chat_shareLocation": "Share location", + "chat_stopSharingLocationConfirm": "Stop sharing location?", + "chat_once": "Once", + "chat_locationUnavailable": "Location not available", "chat_reply": "Reply", "chat_addReaction": "Add Reaction", "chat_me": "Me", @@ -890,6 +895,7 @@ "map_guessedLocation": "Guessed location", "map_lastSeenTime": "Last Seen Time", "map_sharedPin": "Shared pin", + "map_sharedAt": "Shared", "map_joinRoom": "Join Room", "map_manageRepeater": "Manage Repeater", "map_tapToAdd": "Tap on nodes to add them to the path.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4d465bb..abf8441 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Enviar GIF", + "chat_insertEmoji": "Insertar emoji", + "chat_shareLocation": "Compartir ubicación", + "chat_stopSharingLocationConfirm": "¿Dejar de compartir la ubicación?", + "chat_once": "Una vez", + "chat_locationUnavailable": "Ubicación no disponible", "chat_reply": "Responder", "chat_addReaction": "Añadir Reacción", "chat_me": "Yo", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Mostrar marcadores compartidos", "map_lastSeenTime": "Última vez que se vio", "map_sharedPin": "Pin compartido", + "map_sharedAt": "Compartido", "map_joinRoom": "Únete a la sala", "map_manageRepeater": "Gestionar Repetidor", "mapCache_title": "Caché de Mapa Offline", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 16e1d3d..c14ea3f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Envoyer GIF", + "chat_insertEmoji": "Insérer un emoji", + "chat_shareLocation": "Partager la localisation", + "chat_stopSharingLocationConfirm": "Arrêter le partage de position ?", + "chat_once": "Une fois", + "chat_locationUnavailable": "Localisation non disponible", "chat_reply": "Répondre", "chat_addReaction": "Ajouter une Réaction", "chat_me": "Moi", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Afficher les marqueurs partagés", "map_lastSeenTime": "Dernière fois vu", "map_sharedPin": "Clé partagée", + "map_sharedAt": "Partagé", "map_joinRoom": "Rejoindre le room server", "map_manageRepeater": "Gérer le répéteur", "mapCache_title": "Cache de Carte Hors Ligne", diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index cf42e1b..d1c248c 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -628,6 +628,11 @@ "chat_sendGif": "Küldj GIF-ot", "chat_reply": "Válasz", "chat_addReaction": "Hozzon létre reakciót", + "chat_insertEmoji": "Emoji beszúrása", + "chat_shareLocation": "Helyszín megosztása", + "chat_stopSharingLocationConfirm": "Leállítja a helymegosztást?", + "chat_once": "Egyszer", + "chat_locationUnavailable": "A helyszín nem elérhető", "chat_me": "Én", "emojiCategorySmileys": "Emoji", "emojiCategoryGestures": "Testmozgások", @@ -861,6 +866,7 @@ "map_guessedLocation": "Tippolt hely", "map_lastSeenTime": "Utoljára megjelent idő", "map_sharedPin": "Gemeinsames PIN-kód", + "map_sharedAt": "Megosztva", "map_joinRoom": "Csatlakozás a szobához", "map_manageRepeater": "Ellenőriző eszköz kezelése", "map_tapToAdd": "Nyomj meg a csomópontokhoz, hogy hozzáadd őket az útvonalhoz.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index b9676bb..40b95e5 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Invia GIF", + "chat_insertEmoji": "Inserisci emoji", + "chat_shareLocation": "Condividi posizione", + "chat_stopSharingLocationConfirm": "Interrompere la condivisione della posizione?", + "chat_once": "Una volta", + "chat_locationUnavailable": "Posizione non disponibile", "chat_reply": "Rispondi", "chat_addReaction": "Aggiungi Reazione", "chat_me": "Me", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Mostra i segnaposto condivisi", "map_lastSeenTime": "Ultimo Tempo di Visualizzazione", "map_sharedPin": "Condividi PIN", + "map_sharedAt": "Condiviso", "map_joinRoom": "Unisciti alla stanza", "map_manageRepeater": "Gestisci Ripetitore", "mapCache_title": "Cache Mappa Offline", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 6a9c975..99417a5 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -628,6 +628,11 @@ "chat_sendGif": "GIFを送信する", "chat_reply": "返信", "chat_addReaction": "反応を追加", + "chat_insertEmoji": "絵文字を挿入", + "chat_shareLocation": "位置を共有する", + "chat_stopSharingLocationConfirm": "位置情報の共有を停止しますか?", + "chat_once": "1回", + "chat_locationUnavailable": "位置情報が利用できません", "chat_me": "私", "emojiCategorySmileys": "笑顔の絵文字", "emojiCategoryGestures": "身振り、動作", @@ -861,6 +866,7 @@ "map_guessedLocation": "推測された場所", "map_lastSeenTime": "最後に確認された時間", "map_sharedPin": "共有パスワード", + "map_sharedAt": "共有済み", "map_joinRoom": "部屋に参加する", "map_manageRepeater": "リピーターの管理", "map_tapToAdd": "ノードをクリックして、パスに追加します。", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 2050e3b..e178c2e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -628,6 +628,11 @@ "chat_sendGif": "GIF 보내기", "chat_reply": "답변", "chat_addReaction": "댓글 추가", + "chat_insertEmoji": "이모지 삽입", + "chat_shareLocation": "위치 공유", + "chat_stopSharingLocationConfirm": "위치 공유를 중지하시겠습니까?", + "chat_once": "한 번", + "chat_locationUnavailable": "위치를 사용할 수 없습니다", "chat_me": "나", "emojiCategorySmileys": "이모티콘", "emojiCategoryGestures": "제스처", @@ -861,6 +866,7 @@ "map_guessedLocation": "추측된 위치", "map_lastSeenTime": "마지막으로 확인된 시간", "map_sharedPin": "공유 비밀번호", + "map_sharedAt": "공유됨", "map_joinRoom": "방에 참여", "map_manageRepeater": "리피터 관리", "map_tapToAdd": "노드에 클릭하여 경로에 추가합니다.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e2bd2f3..809d67d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2374,6 +2374,36 @@ abstract class AppLocalizations { /// **'Send GIF'** String get chat_sendGif; + /// No description provided for @chat_insertEmoji. + /// + /// In en, this message translates to: + /// **'Insert emoji'** + String get chat_insertEmoji; + + /// No description provided for @chat_shareLocation. + /// + /// In en, this message translates to: + /// **'Share location'** + String get chat_shareLocation; + + /// No description provided for @chat_stopSharingLocationConfirm. + /// + /// In en, this message translates to: + /// **'Stop sharing location?'** + String get chat_stopSharingLocationConfirm; + + /// No description provided for @chat_once. + /// + /// In en, this message translates to: + /// **'Once'** + String get chat_once; + + /// No description provided for @chat_locationUnavailable. + /// + /// In en, this message translates to: + /// **'Location not available'** + String get chat_locationUnavailable; + /// No description provided for @chat_reply. /// /// In en, this message translates to: @@ -3124,6 +3154,12 @@ abstract class AppLocalizations { /// **'Shared pin'** String get map_sharedPin; + /// No description provided for @map_sharedAt. + /// + /// In en, this message translates to: + /// **'Shared'** + String get map_sharedAt; + /// No description provided for @map_joinRoom. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 283860e..309411f 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1288,6 +1288,22 @@ class AppLocalizationsBg extends AppLocalizations { @override String get chat_sendGif => 'Изпрати GIF'; + @override + String get chat_insertEmoji => 'Вмъкнете емоджи'; + + @override + String get chat_shareLocation => 'Споделете местоположение'; + + @override + String get chat_stopSharingLocationConfirm => + 'Да спра ли споделянето на местоположение?'; + + @override + String get chat_once => 'Веднъж'; + + @override + String get chat_locationUnavailable => 'Местоположението не е налично'; + @override String get chat_reply => 'Отговори'; @@ -1723,6 +1739,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_sharedPin => 'Споделено копие'; + @override + String get map_sharedAt => 'Споделено'; + @override String get map_joinRoom => 'Присъедини се към стаята'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e29ae9e..f589c18 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1287,6 +1287,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chat_sendGif => 'GIF senden'; + @override + String get chat_insertEmoji => 'Emoji einfügen'; + + @override + String get chat_shareLocation => 'Standort teilen'; + + @override + String get chat_stopSharingLocationConfirm => 'Standortfreigabe beenden?'; + + @override + String get chat_once => 'Einmal'; + + @override + String get chat_locationUnavailable => 'Standort nicht verfügbar'; + @override String get chat_reply => 'Beantworten'; @@ -1720,6 +1735,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get map_sharedPin => 'Gemeinsames Passwort'; + @override + String get map_sharedAt => 'Geteilt'; + @override String get map_joinRoom => 'Beitreten Sie dem Raum'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 877e11d..112986d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1262,6 +1262,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chat_sendGif => 'Send GIF'; + @override + String get chat_insertEmoji => 'Insert emoji'; + + @override + String get chat_shareLocation => 'Share location'; + + @override + String get chat_stopSharingLocationConfirm => 'Stop sharing location?'; + + @override + String get chat_once => 'Once'; + + @override + String get chat_locationUnavailable => 'Location not available'; + @override String get chat_reply => 'Reply'; @@ -1689,6 +1704,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_sharedPin => 'Shared pin'; + @override + String get map_sharedAt => 'Shared'; + @override String get map_joinRoom => 'Join Room'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c963902..6597d1e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1287,6 +1287,22 @@ class AppLocalizationsEs extends AppLocalizations { @override String get chat_sendGif => 'Enviar GIF'; + @override + String get chat_insertEmoji => 'Insertar emoji'; + + @override + String get chat_shareLocation => 'Compartir ubicación'; + + @override + String get chat_stopSharingLocationConfirm => + '¿Dejar de compartir la ubicación?'; + + @override + String get chat_once => 'Una vez'; + + @override + String get chat_locationUnavailable => 'Ubicación no disponible'; + @override String get chat_reply => 'Responder'; @@ -1719,6 +1735,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get map_sharedPin => 'Pin compartido'; + @override + String get map_sharedAt => 'Compartido'; + @override String get map_joinRoom => 'Únete a la sala'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index eea88f5..8361cd9 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1292,6 +1292,22 @@ class AppLocalizationsFr extends AppLocalizations { @override String get chat_sendGif => 'Envoyer GIF'; + @override + String get chat_insertEmoji => 'Insérer un emoji'; + + @override + String get chat_shareLocation => 'Partager la localisation'; + + @override + String get chat_stopSharingLocationConfirm => + 'Arrêter le partage de position ?'; + + @override + String get chat_once => 'Une fois'; + + @override + String get chat_locationUnavailable => 'Localisation non disponible'; + @override String get chat_reply => 'Répondre'; @@ -1729,6 +1745,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get map_sharedPin => 'Clé partagée'; + @override + String get map_sharedAt => 'Partagé'; + @override String get map_joinRoom => 'Rejoindre le room server'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 5e36e94..a8e2c23 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1295,6 +1295,21 @@ class AppLocalizationsHu extends AppLocalizations { @override String get chat_sendGif => 'Küldj GIF-ot'; + @override + String get chat_insertEmoji => 'Emoji beszúrása'; + + @override + String get chat_shareLocation => 'Helyszín megosztása'; + + @override + String get chat_stopSharingLocationConfirm => 'Leállítja a helymegosztást?'; + + @override + String get chat_once => 'Egyszer'; + + @override + String get chat_locationUnavailable => 'A helyszín nem elérhető'; + @override String get chat_reply => 'Válasz'; @@ -1732,6 +1747,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get map_sharedPin => 'Gemeinsames PIN-kód'; + @override + String get map_sharedAt => 'Megosztva'; + @override String get map_joinRoom => 'Csatlakozás a szobához'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index bb9e0d2..38ba546 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1288,6 +1288,22 @@ class AppLocalizationsIt extends AppLocalizations { @override String get chat_sendGif => 'Invia GIF'; + @override + String get chat_insertEmoji => 'Inserisci emoji'; + + @override + String get chat_shareLocation => 'Condividi posizione'; + + @override + String get chat_stopSharingLocationConfirm => + 'Interrompere la condivisione della posizione?'; + + @override + String get chat_once => 'Una volta'; + + @override + String get chat_locationUnavailable => 'Posizione non disponibile'; + @override String get chat_reply => 'Rispondi'; @@ -1720,6 +1736,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_sharedPin => 'Condividi PIN'; + @override + String get map_sharedAt => 'Condiviso'; + @override String get map_joinRoom => 'Unisciti alla stanza'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 5151ab8..e0440f8 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1228,6 +1228,21 @@ class AppLocalizationsJa extends AppLocalizations { @override String get chat_sendGif => 'GIFを送信する'; + @override + String get chat_insertEmoji => '絵文字を挿入'; + + @override + String get chat_shareLocation => '位置を共有する'; + + @override + String get chat_stopSharingLocationConfirm => '位置情報の共有を停止しますか?'; + + @override + String get chat_once => '1回'; + + @override + String get chat_locationUnavailable => '位置情報が利用できません'; + @override String get chat_reply => '返信'; @@ -1647,6 +1662,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get map_sharedPin => '共有パスワード'; + @override + String get map_sharedAt => '共有済み'; + @override String get map_joinRoom => '部屋に参加する'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index be64545..bce76ed 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1223,6 +1223,21 @@ class AppLocalizationsKo extends AppLocalizations { @override String get chat_sendGif => 'GIF 보내기'; + @override + String get chat_insertEmoji => '이모지 삽입'; + + @override + String get chat_shareLocation => '위치 공유'; + + @override + String get chat_stopSharingLocationConfirm => '위치 공유를 중지하시겠습니까?'; + + @override + String get chat_once => '한 번'; + + @override + String get chat_locationUnavailable => '위치를 사용할 수 없습니다'; + @override String get chat_reply => '답변'; @@ -1643,6 +1658,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get map_sharedPin => '공유 비밀번호'; + @override + String get map_sharedAt => '공유됨'; + @override String get map_joinRoom => '방에 참여'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 86809df..6095f2e 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1276,6 +1276,21 @@ class AppLocalizationsNl extends AppLocalizations { @override String get chat_sendGif => 'GIF verzenden'; + @override + String get chat_insertEmoji => 'Emoji invoegen'; + + @override + String get chat_shareLocation => 'Locatie delen'; + + @override + String get chat_stopSharingLocationConfirm => 'Locatie delen stoppen?'; + + @override + String get chat_once => 'Eenmalig'; + + @override + String get chat_locationUnavailable => 'Locatie niet beschikbaar'; + @override String get chat_reply => 'Reageren'; @@ -1708,6 +1723,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get map_sharedPin => 'Gedeelde pin'; + @override + String get map_sharedAt => 'Gedeeld'; + @override String get map_joinRoom => 'Kamer Toetreden'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 8952815..f17a2f1 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1297,6 +1297,22 @@ class AppLocalizationsPl extends AppLocalizations { @override String get chat_sendGif => 'Wyślij GIF'; + @override + String get chat_insertEmoji => 'Wstaw emoji'; + + @override + String get chat_shareLocation => 'Udostępnij lokalizację'; + + @override + String get chat_stopSharingLocationConfirm => + 'Zatrzymać udostępnianie lokalizacji?'; + + @override + String get chat_once => 'Raz'; + + @override + String get chat_locationUnavailable => 'Lokalizacja niedostępna'; + @override String get chat_reply => 'Odpowiedz'; @@ -1732,6 +1748,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get map_sharedPin => 'Udostępniona pinezka'; + @override + String get map_sharedAt => 'Udostępnione'; + @override String get map_joinRoom => 'Dołącz do pokoju'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 43dc27a..f918171 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1287,6 +1287,22 @@ class AppLocalizationsPt extends AppLocalizations { @override String get chat_sendGif => 'Enviar GIF'; + @override + String get chat_insertEmoji => 'Inserir emoji'; + + @override + String get chat_shareLocation => 'Compartilhar local'; + + @override + String get chat_stopSharingLocationConfirm => + 'Parar de compartilhar a localização?'; + + @override + String get chat_once => 'Uma vez'; + + @override + String get chat_locationUnavailable => 'Localização indisponível'; + @override String get chat_reply => 'Responder'; @@ -1720,6 +1736,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get map_sharedPin => 'Pin compartilhado'; + @override + String get map_sharedAt => 'Compartilhado'; + @override String get map_joinRoom => 'Junte-se à Sala'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 703d80d..958314a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1287,6 +1287,22 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chat_sendGif => 'Отправить GIF'; + @override + String get chat_insertEmoji => 'Вставить эмодзи'; + + @override + String get chat_shareLocation => 'Поделиться местоположением'; + + @override + String get chat_stopSharingLocationConfirm => + 'Остановить передачу местоположения?'; + + @override + String get chat_once => 'Один раз'; + + @override + String get chat_locationUnavailable => 'Местоположение недоступно'; + @override String get chat_reply => 'Ответить'; @@ -1723,6 +1739,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_sharedPin => 'Общая метка'; + @override + String get map_sharedAt => 'Поделено'; + @override String get map_joinRoom => 'Присоединиться к комнате'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 980657d..79df5a7 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1275,6 +1275,21 @@ class AppLocalizationsSk extends AppLocalizations { @override String get chat_sendGif => 'Odoslať GIF'; + @override + String get chat_insertEmoji => 'Vložiť emoji'; + + @override + String get chat_shareLocation => 'Zdieľať polohu'; + + @override + String get chat_stopSharingLocationConfirm => 'Zastaviť zdieľanie polohy?'; + + @override + String get chat_once => 'Raz'; + + @override + String get chat_locationUnavailable => 'Poloha nie je k dispozícii'; + @override String get chat_reply => 'Odpovedať'; @@ -1709,6 +1724,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get map_sharedPin => 'Zdieľaný PIN'; + @override + String get map_sharedAt => 'Zdieľané'; + @override String get map_joinRoom => 'Pripojiť miestnosť'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index ad2a278..d76e83f 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1274,6 +1274,21 @@ class AppLocalizationsSl extends AppLocalizations { @override String get chat_sendGif => 'Pošlji GIF'; + @override + String get chat_insertEmoji => 'Vstavi emoji'; + + @override + String get chat_shareLocation => 'Deli lokacijo'; + + @override + String get chat_stopSharingLocationConfirm => 'Ustavim deljenje lokacije?'; + + @override + String get chat_once => 'Enkrat'; + + @override + String get chat_locationUnavailable => 'Lokacija ni na voljo'; + @override String get chat_reply => 'Odgovori'; @@ -1704,6 +1719,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_sharedPin => 'Deljeno naslovno geslo'; + @override + String get map_sharedAt => 'Deljeno'; + @override String get map_joinRoom => 'Pridružiti sobo'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index cc590c2..f272a28 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1268,6 +1268,21 @@ class AppLocalizationsSv extends AppLocalizations { @override String get chat_sendGif => 'Skicka GIF'; + @override + String get chat_insertEmoji => 'Infoga emoji'; + + @override + String get chat_shareLocation => 'Dela plats'; + + @override + String get chat_stopSharingLocationConfirm => 'Sluta dela plats?'; + + @override + String get chat_once => 'En gång'; + + @override + String get chat_locationUnavailable => 'Plats inte tillgänglig'; + @override String get chat_reply => 'Svara'; @@ -1698,6 +1713,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get map_sharedPin => 'Delad PIN'; + @override + String get map_sharedAt => 'Delad'; + @override String get map_joinRoom => 'Gå med i rum'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index dd7bf63..c214df9 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1280,6 +1280,22 @@ class AppLocalizationsUk extends AppLocalizations { @override String get chat_sendGif => 'Надіслати GIF'; + @override + String get chat_insertEmoji => 'Вставити емодзі'; + + @override + String get chat_shareLocation => 'Поділитися місцезнаходженням'; + + @override + String get chat_stopSharingLocationConfirm => + 'Зупинити поширення місцезнаходження?'; + + @override + String get chat_once => 'Один раз'; + + @override + String get chat_locationUnavailable => 'Місцезнаходження недоступне'; + @override String get chat_reply => 'Відповісти'; @@ -1718,6 +1734,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_sharedPin => 'Спільний пін'; + @override + String get map_sharedAt => 'Поділено'; + @override String get map_joinRoom => 'Приєднатися до кімнати'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8910dcd..89c916f 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1210,6 +1210,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get chat_sendGif => '发送 GIF'; + @override + String get chat_insertEmoji => '插入表情'; + + @override + String get chat_shareLocation => '分享位置'; + + @override + String get chat_stopSharingLocationConfirm => '停止共享位置?'; + + @override + String get chat_once => '一次'; + + @override + String get chat_locationUnavailable => '位置不可用'; + @override String get chat_reply => '回复'; @@ -1615,6 +1630,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_sharedPin => '共享标记'; + @override + String get map_sharedAt => '已分享'; + @override String get map_joinRoom => '加入房间'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index cb1a11c..4e59b7e 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "GIF verzenden", + "chat_insertEmoji": "Emoji invoegen", + "chat_shareLocation": "Locatie delen", + "chat_stopSharingLocationConfirm": "Locatie delen stoppen?", + "chat_once": "Eenmalig", + "chat_locationUnavailable": "Locatie niet beschikbaar", "chat_reply": "Reageren", "chat_addReaction": "Reactie toevoegen", "chat_me": "Mijn", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Toon gedeelde markeringen", "map_lastSeenTime": "Laatste Bekeken Tijd", "map_sharedPin": "Gedeelde pin", + "map_sharedAt": "Gedeeld", "map_joinRoom": "Kamer Toetreden", "map_manageRepeater": "Beheer Repeater", "mapCache_title": "Offline Kaarten Cache", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index aa3049f..ead8a6f 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -464,6 +464,11 @@ } }, "chat_sendGif": "Wyślij GIF", + "chat_insertEmoji": "Wstaw emoji", + "chat_shareLocation": "Udostępnij lokalizację", + "chat_stopSharingLocationConfirm": "Zatrzymać udostępnianie lokalizacji?", + "chat_once": "Raz", + "chat_locationUnavailable": "Lokalizacja niedostępna", "chat_reply": "Odpowiedz", "chat_addReaction": "Dodaj Reakcję", "chat_me": "Ja", @@ -692,6 +697,7 @@ "map_showSharedMarkers": "Pokaż udostępnione znaczniki.", "map_lastSeenTime": "Ostatni raz widziany", "map_sharedPin": "Udostępniona pinezka", + "map_sharedAt": "Udostępnione", "map_joinRoom": "Dołącz do pokoju", "map_manageRepeater": "Zarządzaj przekaźnikiem", "mapCache_title": "Pamięć podręczna map offline", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index c667cb0..56d41e5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Enviar GIF", + "chat_insertEmoji": "Inserir emoji", + "chat_shareLocation": "Compartilhar local", + "chat_stopSharingLocationConfirm": "Parar de compartilhar a localização?", + "chat_once": "Uma vez", + "chat_locationUnavailable": "Localização indisponível", "chat_reply": "Responder", "chat_addReaction": "Adicionar Reação", "chat_me": "Eu", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Mostrar marcadores compartilhados", "map_lastSeenTime": "Último Tempo de Visualização", "map_sharedPin": "Pin compartilhado", + "map_sharedAt": "Compartilhado", "map_joinRoom": "Junte-se à Sala", "map_manageRepeater": "Gerenciar Repetidor", "mapCache_title": "Cache de Mapa Offline", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 730cfc9..43cd7e6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -285,6 +285,11 @@ "chat_retryingMessage": "Повтор отправки сообщения", "chat_retryCount": "Попытка {current}/{max}", "chat_sendGif": "Отправить GIF", + "chat_insertEmoji": "Вставить эмодзи", + "chat_shareLocation": "Поделиться местоположением", + "chat_stopSharingLocationConfirm": "Остановить передачу местоположения?", + "chat_once": "Один раз", + "chat_locationUnavailable": "Местоположение недоступно", "chat_reply": "Ответить", "chat_addReaction": "Добавить реакцию", "chat_me": "Я", @@ -397,6 +402,7 @@ "map_showSharedMarkers": "Показывать общие метки", "map_lastSeenTime": "Время последнего появления", "map_sharedPin": "Общая метка", + "map_sharedAt": "Поделено", "map_joinRoom": "Присоединиться к комнате", "map_manageRepeater": "Управление репитером", "mapCache_title": "Кэш офлайн-карты", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index cf99ca8..5290fc6 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Odoslať GIF", + "chat_insertEmoji": "Vložiť emoji", + "chat_shareLocation": "Zdieľať polohu", + "chat_stopSharingLocationConfirm": "Zastaviť zdieľanie polohy?", + "chat_once": "Raz", + "chat_locationUnavailable": "Poloha nie je k dispozícii", "chat_reply": "Odpovedať", "chat_addReaction": "Pridať Reakciu", "chat_me": "Mne", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Zobraziť zdieľané značky", "map_lastSeenTime": "Posledný čas sledovania", "map_sharedPin": "Zdieľaný PIN", + "map_sharedAt": "Zdieľané", "map_joinRoom": "Pripojiť miestnosť", "map_manageRepeater": "Spravovať Opakovanie", "mapCache_title": "Offline Mapa Pamäť", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 0c29a86..079cc4b 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Pošlji GIF", + "chat_insertEmoji": "Vstavi emoji", + "chat_shareLocation": "Deli lokacijo", + "chat_stopSharingLocationConfirm": "Ustavim deljenje lokacije?", + "chat_once": "Enkrat", + "chat_locationUnavailable": "Lokacija ni na voljo", "chat_reply": "Odgovori", "chat_addReaction": "Dodaj reakcijo", "chat_me": "jaz", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Pokaži skupno označenja", "map_lastSeenTime": "Datum zadnjega vpogleda", "map_sharedPin": "Deljeno naslovno geslo", + "map_sharedAt": "Deljeno", "map_joinRoom": "Pridružiti sobo", "map_manageRepeater": "Upravljajte Ponovitve", "mapCache_title": "Omrezni predpomnilnik zemljeških zemljejevskih slik", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 3232888..dbd71ce 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -454,6 +454,11 @@ } }, "chat_sendGif": "Skicka GIF", + "chat_insertEmoji": "Infoga emoji", + "chat_shareLocation": "Dela plats", + "chat_stopSharingLocationConfirm": "Sluta dela plats?", + "chat_once": "En gång", + "chat_locationUnavailable": "Plats inte tillgänglig", "chat_reply": "Svara", "chat_addReaction": "Lägg till reaktion", "chat_me": "Mig", @@ -682,6 +687,7 @@ "map_showSharedMarkers": "Visa delade markörer", "map_lastSeenTime": "Senaste Visats Tid", "map_sharedPin": "Delad PIN", + "map_sharedAt": "Delad", "map_joinRoom": "Gå med i rum", "map_manageRepeater": "Hantera Upprepare", "mapCache_title": "Offline Kartcache", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index ddab576..9330a2b 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -455,6 +455,11 @@ } }, "chat_sendGif": "Надіслати GIF", + "chat_insertEmoji": "Вставити емодзі", + "chat_shareLocation": "Поділитися місцезнаходженням", + "chat_stopSharingLocationConfirm": "Зупинити поширення місцезнаходження?", + "chat_once": "Один раз", + "chat_locationUnavailable": "Місцезнаходження недоступне", "chat_reply": "Відповісти", "chat_addReaction": "Додати реакцію", "chat_me": "Я", @@ -683,6 +688,7 @@ "map_showSharedMarkers": "Показувати спільні маркери", "map_lastSeenTime": "Час останньої активності", "map_sharedPin": "Спільний пін", + "map_sharedAt": "Поділено", "map_joinRoom": "Приєднатися до кімнати", "map_manageRepeater": "Керувати ретранслятором", "mapCache_title": "Офлайн-кеш карти", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 766be44..1b76006 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -483,6 +483,11 @@ "chat_sendGif": "发送 GIF", "chat_reply": "回复", "chat_addReaction": "添加表情", + "chat_insertEmoji": "插入表情", + "chat_shareLocation": "分享位置", + "chat_stopSharingLocationConfirm": "停止共享位置?", + "chat_once": "一次", + "chat_locationUnavailable": "位置不可用", "chat_me": "我", "emojiCategorySmileys": "表情", "emojiCategoryGestures": "手势", @@ -709,6 +714,7 @@ "map_showSharedMarkers": "显示共享标记", "map_lastSeenTime": "最后在线时间", "map_sharedPin": "共享标记", + "map_sharedAt": "已分享", "map_joinRoom": "加入房间", "map_manageRepeater": "管理转发节点", "mapCache_title": "离线地图缓存", diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 7beaaf4..a9668ea 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -14,6 +14,7 @@ import '../connector/meshcore_protocol.dart'; import '../helpers/gif_helper.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/utf8_length_limiter.dart'; +import '../helpers/smaz.dart'; import '../l10n/l10n.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; @@ -43,6 +44,8 @@ class ChannelChatScreen extends StatefulWidget { State createState() => _ChannelChatScreenState(); } +enum _ChannelChatInputAction { sendGif, insertEmoji, shareLocation } + class _ChannelChatScreenState extends State { final TextEditingController _textController = TextEditingController(); final ChatScrollController _scrollController = ChatScrollController(); @@ -898,6 +901,251 @@ class _ChannelChatScreenState extends State { ); } + void _insertTextAtCursor(String text) { + final currentValue = _textController.value; + final selection = currentValue.selection; + final newText = selection.isValid + ? currentValue.text.replaceRange(selection.start, selection.end, text) + : currentValue.text + text; + final caret = + (selection.isValid ? selection.start : currentValue.text.length) + + text.length; + + _textController.value = currentValue.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: caret), + ); + } + + void _showEmojiPickerForComposer(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + title: context.l10n.chat_insertEmoji, + onEmojiSelected: (emoji) { + _insertTextAtCursor(emoji); + _textFieldFocusNode.requestFocus(); + }, + ), + ); + } + + Future _shareLocation() async { + final connector = context.read(); + final lat = connector.selfLatitude; + final lon = connector.selfLongitude; + final allowsTimedLocationSharing = + !widget.channel.isPublicChannel && !widget.channel.name.startsWith('#'); + if (lat == null || lon == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.chat_locationUnavailable)), + ); + return; + } + + if (allowsTimedLocationSharing && + connector.locationSharingChannelIndex == widget.channel.index) { + final stop = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.chat_shareLocation), + content: Text(context.l10n.chat_stopSharingLocationConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.common_ok), + ), + ], + ), + ); + if (stop == true) connector.stopLocationSharing(); + return; + } + + final maxBytes = maxChannelMessageBytes(connector.selfName); + final prefix = 'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|'; + const suffix = '|loc'; + final maxLabelBytes = + maxBytes - utf8.encode(prefix).length - utf8.encode(suffix).length; + + if (allowsTimedLocationSharing) { + final gpsInterval = + int.tryParse(connector.currentCustomVars?['gps_interval'] ?? '') ?? + 900; + final minIntervals = (300.0 / gpsInterval).ceil().clamp(2, 9999); + final sliderMax = ((86400 / gpsInterval).floor() - minIntervals + 1) + .clamp(1, 99999); + final labelCtrl = TextEditingController( + text: truncateToUtf8Bytes(connector.deviceDisplayName, maxLabelBytes), + ); + int sliderVal = 0; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + final actual = sliderVal == 0 ? 0 : sliderVal + minIntervals - 1; + final duration = Duration(seconds: actual * gpsInterval); + final durationLabel = sliderVal == 0 + ? context.l10n.chat_once + : (duration.inHours > 0 + ? '${duration.inHours}h ${duration.inMinutes.remainder(60)}m' + : '${duration.inMinutes}m'); + return AlertDialog( + title: Text(context.l10n.chat_shareLocation), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(durationLabel), + Slider( + min: 0, + max: sliderMax.toDouble(), + divisions: sliderMax, + value: sliderVal.toDouble(), + onChanged: (value) => + setDialogState(() => sliderVal = value.round()), + ), + if (sliderVal == 0) + TextField( + controller: labelCtrl, + decoration: InputDecoration( + labelText: context.l10n.chat_location, + ), + autofocus: true, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter(maxLabelBytes), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.common_ok), + ), + ], + ); + }, + ), + ); + + if (confirmed != true || !mounted) return; + + if (sliderVal == 0) { + var label = labelCtrl.text.trim().replaceAll('|', '/'); + if (label.isEmpty) return; + + final markerText = + 'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|$label|loc'; + if (utf8.encode(markerText).length > maxBytes) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))), + ); + return; + } + + connector.sendChannelMessage(widget.channel, markerText); + } else { + connector.startLocationSharing( + channelIndex: widget.channel.index, + duration: Duration( + seconds: (sliderVal + minIntervals - 1) * gpsInterval, + ), + ); + } + return; + } + + final defaultLabel = connector.deviceDisplayName; + final controller = TextEditingController( + text: truncateToUtf8Bytes(defaultLabel, maxLabelBytes), + ); + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + + var label = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.chat_shareLocation), + content: TextField( + controller: controller, + decoration: InputDecoration(labelText: context.l10n.chat_location), + autofocus: true, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter(maxLabelBytes), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: Text(context.l10n.common_save), + ), + ], + ), + ); + + if (label == null || label.isEmpty) return; + label = label.replaceAll('|', '/'); + + if (!mounted) return; + + final markerText = + 'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|$label|loc'; + if (utf8.encode(markerText).length > maxBytes) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))), + ); + return; + } + + if (widget.channel.isPublicChannel) { + final channelLabel = widget.channel.name.isEmpty + ? context.l10n.channels_channelIndex(widget.channel.index) + : widget.channel.name; + final canShare = await _confirmPublicShare(channelLabel); + if (!mounted || !canShare) return; + } + + connector.sendChannelMessage(widget.channel, markerText); + } + + Future _confirmPublicShare(String channelLabel) async { + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.map_publicLocationShare), + content: Text( + context.l10n.map_publicLocationShareConfirm(channelLabel), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.common_share), + ), + ], + ), + ); + return result ?? false; + } + Widget _buildAvatar(String senderName) { final initial = _getFirstCharacterOrEmoji(senderName); final color = _getColorForName(senderName); @@ -1007,6 +1255,10 @@ class _ChannelChatScreenState extends State { final connector = context.watch(); final maxBytes = maxChannelMessageBytes(connector.selfName); final settings = context.watch().settings; + final smazEncoder = connector.isChannelSmazEnabled(widget.channel.index) + ? Smaz.encodeIfSmaller + : null; + final gpsEnabled = connector.currentCustomVars?['gps'] == '1'; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1019,6 +1271,29 @@ class _ChannelChatScreenState extends State { return _buildReplyBanner(textScale); }, ), + if (connector.locationSharingChannelIndex == widget.channel.index) + Container( + color: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), + child: Row( + children: [ + const Icon(Icons.my_location, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.chat_shareLocation, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: connector.stopLocationSharing, + ), + ], + ), + ), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -1033,10 +1308,54 @@ class _ChannelChatScreenState extends State { ), child: Row( children: [ - IconButton( - icon: const Icon(Icons.gif_box), - onPressed: () => _showGifPicker(context), - tooltip: context.l10n.chat_sendGif, + PopupMenuButton<_ChannelChatInputAction>( + icon: const Icon(Icons.add_circle_outline), + tooltip: context.l10n.common_add, + requestFocus: false, + position: PopupMenuPosition.over, + offset: const Offset(0, -180), + onSelected: (action) { + if (action == _ChannelChatInputAction.sendGif) { + _showGifPicker(context); + } else if (action == _ChannelChatInputAction.insertEmoji) { + _showEmojiPickerForComposer(context); + } else if (action == _ChannelChatInputAction.shareLocation) { + _shareLocation(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _ChannelChatInputAction.shareLocation, + enabled: gpsEnabled, + child: Row( + children: [ + const Icon(Icons.location_on, size: 20), + const SizedBox(width: 8), + Text(context.l10n.chat_shareLocation), + ], + ), + ), + PopupMenuItem( + value: _ChannelChatInputAction.sendGif, + child: Row( + children: [ + const Icon(Icons.gif_box, size: 20), + const SizedBox(width: 8), + Text(context.l10n.chat_sendGif), + ], + ), + ), + PopupMenuItem( + value: _ChannelChatInputAction.insertEmoji, + child: Row( + children: [ + const Icon(Icons.emoji_emotions, size: 20), + const SizedBox(width: 8), + Text(context.l10n.chat_insertEmoji), + ], + ), + ), + ], ), if (settings.translationEnabled) MessageTranslationButton( @@ -1098,7 +1417,10 @@ class _ChannelChatScreenState extends State { controller: _textController, focusNode: _textFieldFocusNode, inputFormatters: [ - Utf8LengthLimitingTextInputFormatter(maxBytes), + Utf8LengthLimitingTextInputFormatter( + maxBytes, + encoder: smazEncoder, + ), ], textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( @@ -1193,7 +1515,11 @@ class _ChannelChatScreenState extends State { } final maxBytes = maxChannelMessageBytes(connector.selfName); - if (utf8.encode(messageText).length > maxBytes) { + final outboundText = connector.prepareChannelOutboundText( + widget.channel.index, + messageText, + ); + if (utf8.encode(outboundText).length > maxBytes) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))), ); @@ -1299,6 +1625,7 @@ class _ChannelChatScreenState extends State { context: context, isScrollControlled: true, builder: (context) => EmojiPicker( + title: context.l10n.chat_addReaction, onEmojiSelected: (emoji) { _sendReaction(message, emoji); }, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 8057f1f..f803963 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -19,6 +19,7 @@ import '../helpers/chat_scroll_controller.dart'; import '../helpers/gif_helper.dart'; import '../helpers/path_helper.dart'; import '../helpers/utf8_length_limiter.dart'; +import '../helpers/smaz.dart'; import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; @@ -45,6 +46,8 @@ import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; import 'telemetry_screen.dart'; +enum _ChatInputAction { sendGif, insertEmoji, shareLocation } + class ChatScreen extends StatefulWidget { final Contact contact; @@ -500,103 +503,343 @@ class _ChatScreenState extends State { final maxBytes = maxContactMessageBytes(); final colorScheme = Theme.of(context).colorScheme; final settings = context.watch().settings; - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: SafeArea( - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.gif_box), - onPressed: () => _showGifPicker(context), - tooltip: context.l10n.chat_sendGif, + final smazEncoder = + connector.isContactSmazEnabled(widget.contact.publicKeyHex) + ? Smaz.encodeIfSmaller + : null; + final gpsEnabled = connector.currentCustomVars?['gps'] == '1'; + final sharingHere = + connector.locationSharingContactKey == widget.contact.publicKeyHex; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (sharingHere) + Container( + color: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), + child: Row( + children: [ + const Icon(Icons.my_location, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.chat_shareLocation, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: connector.stopLocationSharing, + ), + ], ), - 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(connector); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: SafeArea( + child: Row( + children: [ + PopupMenuButton<_ChatInputAction>( + icon: const Icon(Icons.add_circle_outline), + tooltip: context.l10n.common_add, + requestFocus: false, + position: PopupMenuPosition.over, + offset: const Offset(0, -180), + onSelected: (action) { + if (action == _ChatInputAction.sendGif) { + _showGifPicker(context); + } else if (action == _ChatInputAction.insertEmoji) { + _showEmojiPicker(context); + } else if (action == _ChatInputAction.shareLocation) { + _shareLocation(context.read()); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _ChatInputAction.shareLocation, + enabled: gpsEnabled, child: Row( children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: - colorScheme.surfaceContainerHighest, - fallbackTextColor: colorScheme.onSurface - .withValues(alpha: 0.6), - maxSize: 160, - ), - ), - ), + const Icon(Icons.my_location, size: 20), const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _textController.clear(); - _textFieldFocusNode.requestFocus(); - }, - ), + Text(context.l10n.chat_shareLocation), ], ), - ); - } - - return TextField( - controller: _textController, - focusNode: _textFieldFocusNode, - inputFormatters: [ - Utf8LengthLimitingTextInputFormatter(maxBytes), - ], - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - hintText: context.l10n.chat_typeMessage, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + ), + PopupMenuItem( + value: _ChatInputAction.sendGif, + child: Row( + children: [ + const Icon(Icons.gif_box, size: 20), + const SizedBox(width: 8), + Text(context.l10n.chat_sendGif), + ], ), ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(connector), - ); - }, - ), + PopupMenuItem( + value: _ChatInputAction.insertEmoji, + child: Row( + children: [ + const Icon(Icons.emoji_emotions, size: 20), + const SizedBox(width: 8), + Text(context.l10n.chat_insertEmoji), + ], + ), + ), + ], + ), + if (settings.translationEnabled) + MessageTranslationButton( + enabled: settings.composerTranslationEnabled, + languageCode: settings.translationTargetLanguageCode, + onPressed: _showTranslationOptions, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) { + final gifId = _parseGifId(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(connector); + 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: + colorScheme.surfaceContainerHighest, + fallbackTextColor: colorScheme.onSurface + .withValues(alpha: 0.6), + maxSize: 160, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _textController.clear(); + _textFieldFocusNode.requestFocus(); + }, + ), + ], + ), + ); + } + + return TextField( + controller: _textController, + focusNode: _textFieldFocusNode, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter( + maxBytes, + encoder: smazEncoder, + ), + ], + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: context.l10n.chat_typeMessage, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(connector), + ); + }, + ), + ), + const SizedBox(width: 8), + IconButton.filled( + icon: const Icon(Icons.send), + onPressed: () => _sendMessage(connector), + ), + ], ), - const SizedBox(width: 8), - IconButton.filled( - icon: const Icon(Icons.send), - onPressed: () => _sendMessage(connector), + ), + ), + ], + ); + } + + String? _parseGifId(String text) { + final trimmed = text.trim(); + final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); + return match?.group(1); + } + + void _insertTextAtCursor(String text) { + final currentValue = _textController.value; + final selection = currentValue.selection; + final newText = selection.isValid + ? currentValue.text.replaceRange(selection.start, selection.end, text) + : currentValue.text + text; + final caret = + (selection.isValid ? selection.start : currentValue.text.length) + + text.length; + + _textController.value = currentValue.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: caret), + ); + } + + void _showEmojiPicker(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + title: context.l10n.chat_insertEmoji, + onEmojiSelected: (emoji) { + _insertTextAtCursor(emoji); + _textFieldFocusNode.requestFocus(); + }, + ), + ); + } + + Future _shareLocation(MeshCoreConnector connector) async { + final lat = connector.selfLatitude; + final lon = connector.selfLongitude; + if (lat == null || lon == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.chat_locationUnavailable)), + ); + return; + } + if (connector.locationSharingContactKey == widget.contact.publicKeyHex) { + final stop = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.chat_shareLocation), + content: Text(context.l10n.chat_stopSharingLocationConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.common_ok), ), ], ), + ); + if (stop == true) connector.stopLocationSharing(); + return; + } + final maxBytes = maxContactMessageBytes(); + final prefix = 'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|'; + const suffix = '|loc'; + final maxLabelBytes = + maxBytes - utf8.encode(prefix).length - utf8.encode(suffix).length; + final gpsInterval = + int.tryParse(connector.currentCustomVars?['gps_interval'] ?? '') ?? 900; + final minIntervals = (300.0 / gpsInterval).ceil().clamp(2, 9999); + final sliderMax = ((86400 / gpsInterval).floor() - minIntervals + 1).clamp( + 1, + 99999, + ); + final labelCtrl = TextEditingController( + text: truncateToUtf8Bytes(connector.deviceDisplayName, maxLabelBytes), + ); + int sliderVal = 0; + final confirmed = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx2, setS) { + final actual = sliderVal == 0 ? 0 : sliderVal + minIntervals - 1; + final dur = Duration(seconds: actual * gpsInterval); + final dLabel = sliderVal == 0 + ? context.l10n.chat_once + : (dur.inHours > 0 + ? '${dur.inHours}h ${dur.inMinutes.remainder(60)}m' + : '${dur.inMinutes}m'); + return AlertDialog( + title: Text(context.l10n.chat_shareLocation), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(dLabel), + Slider( + min: 0, + max: sliderMax.toDouble(), + divisions: sliderMax, + value: sliderVal.toDouble(), + onChanged: (v) => setS(() => sliderVal = v.round()), + ), + if (sliderVal == 0) + TextField( + controller: labelCtrl, + decoration: InputDecoration( + labelText: context.l10n.chat_location, + ), + autofocus: true, + inputFormatters: [ + Utf8LengthLimitingTextInputFormatter(maxLabelBytes), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.common_ok), + ), + ], + ); + }, ), ); + if (confirmed != true || !mounted) return; + if (sliderVal == 0) { + var label = labelCtrl.text.trim().replaceAll('|', '/'); + if (label.isEmpty) return; + final markerText = + 'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|$label|loc'; + if (utf8.encode(markerText).length > maxBytes) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))), + ); + return; + } + connector.sendMessage(_resolveContact(connector), markerText); + } else { + connector.startLocationSharing( + contactKey: widget.contact.publicKeyHex, + duration: Duration( + seconds: (sliderVal + minIntervals - 1) * gpsInterval, + ), + ); + } } void _showGifPicker(BuildContext context) { @@ -667,7 +910,11 @@ class _ChatScreenState extends State { } } final maxBytes = maxContactMessageBytes(); - if (utf8.encode(outgoingText).length > maxBytes) { + final outboundText = connector.prepareContactOutboundText( + widget.contact, + outgoingText, + ); + if (utf8.encode(outboundText).length > maxBytes) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))), ); @@ -1427,7 +1674,7 @@ class _ChatScreenState extends State { title: Text(context.l10n.chat_addReaction), onTap: () { Navigator.pop(sheetContext); - _showEmojiPicker(message, contact); + _showReactionEmojiPicker(message, contact); }, ), if (PlatformInfo.isDesktop) @@ -1509,11 +1756,12 @@ class _ChatScreenState extends State { ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage))); } - void _showEmojiPicker(Message message, Contact senderContact) { + void _showReactionEmojiPicker(Message message, Contact senderContact) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => EmojiPicker( + title: context.l10n.chat_addReaction, onEmojiSelected: (emoji) { _sendReaction(message, senderContact, emoji); }, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 9616d47..1fc7c84 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1222,7 +1222,7 @@ class _MapScreenState extends State { } List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) { - final markers = <_SharedMarker>[]; + final markersByKey = {}; final selfName = connector.selfName ?? 'Me'; for (final contact in connector.contacts) { @@ -1231,23 +1231,28 @@ class _MapScreenState extends State { final payload = _parseMarkerText(message.text); if (payload == null) continue; final fromName = message.isOutgoing ? selfName : contact.name; - final id = _buildMarkerId( + final key = _buildSharedMarkerKey( sourceId: contact.publicKeyHex, + label: payload.label, + fromName: fromName, + flags: payload.flags, + isChannel: false, + ); + final marker = _SharedMarker( + id: key, + position: payload.position, + label: payload.label, + flags: payload.flags, + fromName: fromName, + sourceLabel: contact.name, timestamp: message.timestamp, - text: message.text, - ); - markers.add( - _SharedMarker( - id: id, - position: payload.position, - label: payload.label, - flags: payload.flags, - fromName: fromName, - sourceLabel: contact.name, - isChannel: false, - isPublicChannel: false, - ), + isChannel: false, + isPublicChannel: false, ); + final existing = markersByKey[key]; + if (existing == null || marker.timestamp.isAfter(existing.timestamp)) { + markersByKey[key] = marker; + } } } @@ -1257,28 +1262,35 @@ class _MapScreenState extends State { for (final message in messages) { final payload = _parseMarkerText(message.text); if (payload == null) continue; - final id = _buildMarkerId( + final key = _buildSharedMarkerKey( sourceId: 'channel:${channel.index}', + label: payload.label, + fromName: message.senderName, + flags: payload.flags, + isChannel: true, + ); + final marker = _SharedMarker( + id: key, + position: payload.position, + label: payload.label, + flags: payload.flags, + fromName: message.senderName, + sourceLabel: channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name, timestamp: message.timestamp, - text: message.text, - ); - markers.add( - _SharedMarker( - id: id, - position: payload.position, - label: payload.label, - flags: payload.flags, - fromName: message.senderName, - sourceLabel: channel.name.isEmpty - ? 'Channel ${channel.index}' - : channel.name, - isChannel: true, - isPublicChannel: isPublic, - ), + isChannel: true, + isPublicChannel: isPublic, ); + final existing = markersByKey[key]; + if (existing == null || marker.timestamp.isAfter(existing.timestamp)) { + markersByKey[key] = marker; + } } } + final markers = markersByKey.values.toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); return markers; } @@ -1303,12 +1315,18 @@ class _MapScreenState extends State { ); } - String _buildMarkerId({ + String _buildSharedMarkerKey({ required String sourceId, - required DateTime timestamp, - required String text, + required String label, + required String fromName, + required String flags, + required bool isChannel, }) { - return '$sourceId|${timestamp.millisecondsSinceEpoch}|$text'; + final normalizedLabel = label.trim().toLowerCase(); + final normalizedFrom = fromName.trim().toLowerCase(); + final normalizedFlags = flags.trim().toLowerCase(); + final scope = isChannel ? 'ch' : 'dm'; + return '$scope|$sourceId|$normalizedFrom|$normalizedLabel|$normalizedFlags'; } Marker _buildSharedMarker(_SharedMarker marker) { @@ -1528,6 +1546,10 @@ class _MapScreenState extends State { children: [ _buildInfoRow(context.l10n.map_from, marker.fromName), _buildInfoRow(context.l10n.map_source, marker.sourceLabel), + _buildInfoRow( + context.l10n.map_sharedAt, + _formatLastSeen(marker.timestamp), + ), _buildInfoRow( 'Location', '${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}', @@ -1690,6 +1712,10 @@ class _MapScreenState extends State { String defaultLabel, ) async { final controller = TextEditingController(text: defaultLabel); + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); return showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -2294,6 +2320,7 @@ class _SharedMarker { final String flags; final String fromName; final String sourceLabel; + final DateTime timestamp; final bool isChannel; final bool isPublicChannel; @@ -2304,6 +2331,7 @@ class _SharedMarker { required this.flags, required this.fromName, required this.sourceLabel, + required this.timestamp, required this.isChannel, required this.isPublicChannel, }); diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 733dfc5..b572da1 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -88,6 +88,7 @@ class MessageRetryService extends ChangeNotifier { final Set _activeMessages = {}; final Set _resolvedMessages = {}; final Map _expectedHashToMessageId = {}; + final Set _forceClearPathOnMaxRetry = {}; RetryServiceConfig? _config; @@ -143,6 +144,7 @@ class MessageRetryService extends ChangeNotifier { String? translationModelId, Uint8List? pathBytes, int? pathLength, + bool forceClearPathOnMaxRetry = false, }) async { final messageId = const Uuid().v4(); final resolved = resolvePathSelection(contact); @@ -167,6 +169,9 @@ class MessageRetryService extends ChangeNotifier { _pendingMessages[messageId] = message; _pendingContacts[messageId] = contact; + if (forceClearPathOnMaxRetry) { + _forceClearPathOnMaxRetry.add(messageId); + } _config?.addMessage(contact.publicKeyHex, message); @@ -458,6 +463,7 @@ class MessageRetryService extends ChangeNotifier { _pendingMessages.remove(messageId); _pendingContacts.remove(messageId); _attemptPathHistory.remove(messageId); + _forceClearPathOnMaxRetry.remove(messageId); _timeoutTimers.remove(messageId); _resolvedMessages.remove(messageId); } @@ -519,8 +525,10 @@ class MessageRetryService extends ChangeNotifier { final failedMessage = message.copyWith(status: MessageStatus.failed); _pendingMessages[messageId] = failedMessage; - if (config?.appSettingsService?.settings.clearPathOnMaxRetry == true && - config?.clearContactPath != null) { + final shouldClearPathOnMaxRetry = + _forceClearPathOnMaxRetry.contains(messageId) || + config?.appSettingsService?.settings.clearPathOnMaxRetry == true; + if (shouldClearPathOnMaxRetry && config?.clearContactPath != null) { config!.clearContactPath!(contact); } @@ -756,6 +764,7 @@ class MessageRetryService extends ChangeNotifier { _sendQueue.clear(); _activeMessages.clear(); _resolvedMessages.clear(); + _forceClearPathOnMaxRetry.clear(); super.dispose(); } } diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 87fd1c9..a61a0dd 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -4,8 +4,9 @@ import '../l10n/l10n.dart'; class EmojiPicker extends StatelessWidget { final Function(String) onEmojiSelected; + final String? title; - const EmojiPicker({super.key, required this.onEmojiSelected}); + const EmojiPicker({super.key, required this.onEmojiSelected, this.title}); static const List quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥']; @@ -223,7 +224,7 @@ class EmojiPicker extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - l10n.chat_addReaction, + title ?? l10n.chat_addReaction, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, From 74a0a3960e17adbc9c06573093bdb2b70d5343b0 Mon Sep 17 00:00:00 2001 From: ericz Date: Tue, 7 Apr 2026 18:53:53 +0200 Subject: [PATCH 2/2] try to please codex --- lib/connector/meshcore_connector.dart | 7 ++++++- lib/screens/channel_chat_screen.dart | 3 ++- lib/screens/chat_screen.dart | 18 +++++++++--------- lib/screens/map_screen.dart | 6 ++++-- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7cd9a79..b59dc16 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2407,8 +2407,9 @@ class MeshCoreConnector extends ChangeNotifier { _locationSharingContactKey = contactKey; _locationSharingChannelIndex = channelIndex; _lastSharedLocationPositionKey = null; - final gpsInterval = + final rawGpsInterval = int.tryParse(_currentCustomVars?['gps_interval'] ?? '') ?? 900; + final gpsInterval = rawGpsInterval > 0 ? rawGpsInterval : 900; _sendLocationOnce(contactKey, channelIndex); _locationSharingTimer = Timer.periodic(Duration(seconds: gpsInterval), (_) { if (!isConnected || DateTime.now().isAfter(_locationSharingEnd!)) { @@ -5547,6 +5548,10 @@ class MeshCoreConnector extends ChangeNotifier { _stopRadioStatsPolling(); _locationSharingTimer?.cancel(); _locationSharingTimer = null; + _locationSharingEnd = null; + _locationSharingContactKey = null; + _locationSharingChannelIndex = null; + _lastSharedLocationPositionKey = null; _latestRadioStats = null; radioStatsNotifier.value = null; _prevTotalAirSecs = 0; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index a9668ea..6dde926 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -974,9 +974,10 @@ class _ChannelChatScreenState extends State { maxBytes - utf8.encode(prefix).length - utf8.encode(suffix).length; if (allowsTimedLocationSharing) { - final gpsInterval = + final rawGpsInterval = int.tryParse(connector.currentCustomVars?['gps_interval'] ?? '') ?? 900; + final gpsInterval = rawGpsInterval > 0 ? rawGpsInterval : 900; final minIntervals = (300.0 / gpsInterval).ceil().clamp(2, 9999); final sliderMax = ((86400 / gpsInterval).floor() - minIntervals + 1) .clamp(1, 99999); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index f803963..6ba281d 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -508,6 +508,7 @@ class _ChatScreenState extends State { ? Smaz.encodeIfSmaller : null; final gpsEnabled = connector.currentCustomVars?['gps'] == '1'; + final isRoomChat = _resolveContact(connector).type == advTypeRoom; final sharingHere = connector.locationSharingContactKey == widget.contact.publicKeyHex; return Column( @@ -565,7 +566,7 @@ class _ChatScreenState extends State { itemBuilder: (context) => [ PopupMenuItem( value: _ChatInputAction.shareLocation, - enabled: gpsEnabled, + enabled: gpsEnabled && !isRoomChat, child: Row( children: [ const Icon(Icons.my_location, size: 20), @@ -606,7 +607,7 @@ class _ChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = _parseGifId(value.text); + final gifId = GifHelper.parseGif(value.text); if (gifId != null) { return Focus( autofocus: true, @@ -686,12 +687,6 @@ class _ChatScreenState extends State { ); } - String? _parseGifId(String text) { - final trimmed = text.trim(); - final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); - return match?.group(1); - } - void _insertTextAtCursor(String text) { final currentValue = _textController.value; final selection = currentValue.selection; @@ -723,6 +718,10 @@ class _ChatScreenState extends State { } Future _shareLocation(MeshCoreConnector connector) async { + if (_resolveContact(connector).type == advTypeRoom) { + return; + } + final lat = connector.selfLatitude; final lon = connector.selfLongitude; if (lat == null || lon == null) { @@ -757,8 +756,9 @@ class _ChatScreenState extends State { const suffix = '|loc'; final maxLabelBytes = maxBytes - utf8.encode(prefix).length - utf8.encode(suffix).length; - final gpsInterval = + final rawGpsInterval = int.tryParse(connector.currentCustomVars?['gps_interval'] ?? '') ?? 900; + final gpsInterval = rawGpsInterval > 0 ? rawGpsInterval : 900; final minIntervals = (300.0 / gpsInterval).ceil().clamp(2, 9999); final sliderMax = ((86400 / gpsInterval).floor() - minIntervals + 1).clamp( 1, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1fc7c84..d893dbb 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1310,7 +1310,7 @@ class _MapScreenState extends State { final flags = parts.length > 2 ? parts[2].trim() : ''; return _MarkerPayload( position: LatLng(lat, lon), - label: label.isEmpty ? context.l10n.map_sharedPin : label, + label: label, flags: flags, ); } @@ -1539,7 +1539,9 @@ class _MapScreenState extends State { showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: Text(marker.label), + title: Text( + marker.label.isEmpty ? context.l10n.map_sharedPin : marker.label, + ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,