diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index fceee150..3ae31586 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -658,6 +658,27 @@ class MeshCoreConnector extends ChangeNotifier { } } + void setContactUnreadCount(String contactKeyHex, int count) { + _contactUnreadCount[contactKeyHex] = count; + _unreadStore.saveContactUnreadCount( + Map.from(_contactUnreadCount), + ); + notifyListeners(); + } + + void setChannelUnreadCount(int channelIndex, int count) { + final channel = _findChannelByIndex(channelIndex); + if (channel != null) { + channel.unreadCount = count; + unawaited( + _channelStore.saveChannels( + _channels.isNotEmpty ? _channels : _cachedChannels, + ), + ); + notifyListeners(); + } + } + void markChannelRead(int channelIndex) { final channel = _findChannelByIndex(channelIndex); if (channel != null && channel.unreadCount > 0) { diff --git a/lib/helpers/chat_scroll_controller.dart b/lib/helpers/chat_scroll_controller.dart index d2c73fbf..c0d19747 100644 --- a/lib/helpers/chat_scroll_controller.dart +++ b/lib/helpers/chat_scroll_controller.dart @@ -49,6 +49,25 @@ class ChatScrollController extends ScrollController { } } + /// Jumps toward an off-screen message so that lazy ListView.builder builds + /// items near it. Only visible + cacheExtent items have real heights, so we + /// use proportion of maxScrollExtent (itself an estimate from built items' + /// avg height). Call [onJumped] on the next frame to ensureVisible/scroll + /// to the exact target. + void jumpToEstimatedOffset({ + required int unreadCount, + required int totalMessages, + required VoidCallback onJumped, + }) { + if (!hasClients || totalMessages == 0) return; + final maxExtent = position.maxScrollExtent; + final jumpOffset = maxExtent * (unreadCount / totalMessages); + if (jumpOffset > 100) { + jumpTo(jumpOffset); + } + WidgetsBinding.instance.addPostFrameCallback((_) => onJumped()); + } + void scrollToBottomIfAtBottom() { // Only scroll if jump button is NOT showing (i.e., already at bottom) if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) { diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index b37529ba..7a334b38 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -682,6 +682,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 86846e98..b5e5002f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -682,6 +682,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 dfe81683..29377648 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -817,6 +817,8 @@ } } }, + "chat_markAsUnread": "Mark as Unread", + "chat_newMessages": "New messages", "chat_openLink": "Open Link?", "chat_openLinkConfirmation": "Do you want to open this link in your browser?", "chat_open": "Open", @@ -896,6 +898,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 04497cbf..84e08460 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -682,6 +682,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 59ca46ac..f542bd6d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -682,6 +682,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 597faf62..4b6ca20d 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -861,6 +861,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 9db535dd..475dce9f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -682,6 +682,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 4a45f4cd..03b8f1bf 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -861,6 +861,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 69fd99f9..ac78b4ec 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -861,6 +861,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 d090bc13..72f2da09 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2824,6 +2824,18 @@ abstract class AppLocalizations { /// **'Unread: {count}'** String chat_unread(int count); + /// No description provided for @chat_markAsUnread. + /// + /// In en, this message translates to: + /// **'Mark as Unread'** + String get chat_markAsUnread; + + /// No description provided for @chat_newMessages. + /// + /// In en, this message translates to: + /// **'New messages'** + String get chat_newMessages; + /// No description provided for @chat_openLink. /// /// In en, this message translates to: @@ -3130,6 +3142,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 8ee18b67..ea3820d7 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1558,6 +1558,12 @@ class AppLocalizationsBg extends AppLocalizations { return 'Непрочетени: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Отваряне на връзката?'; @@ -1724,6 +1730,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 70d09b31..78f61bd1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1555,6 +1555,12 @@ class AppLocalizationsDe extends AppLocalizations { return 'Ungelesen: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Link öffnen?'; @@ -1721,6 +1727,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 eeb9091b..bef26acf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1525,6 +1525,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'Unread: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Open Link?'; @@ -1690,6 +1696,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 824daaba..2a89b2e2 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1554,6 +1554,12 @@ class AppLocalizationsEs extends AppLocalizations { return 'Sin leer: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => '¿Abrir enlace?'; @@ -1720,6 +1726,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 1ad69397..478937dc 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1563,6 +1563,12 @@ class AppLocalizationsFr extends AppLocalizations { return 'Non lu : $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Ouvrir le lien ?'; @@ -1730,6 +1736,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 34c25bd5..c9dd5ce4 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1565,6 +1565,12 @@ class AppLocalizationsHu extends AppLocalizations { return 'Olvasatlan: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Nyisd meg a linket?'; @@ -1733,6 +1739,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 21f9e52b..a7cd6ec9 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1556,6 +1556,12 @@ class AppLocalizationsIt extends AppLocalizations { return 'Non letti: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Aprire il link?'; @@ -1721,6 +1727,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 d2d0511d..1534aaf9 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1487,6 +1487,12 @@ class AppLocalizationsJa extends AppLocalizations { return '未読: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'リンクを開く?'; @@ -1648,6 +1654,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 b2bd9131..04a637bf 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1483,6 +1483,12 @@ class AppLocalizationsKo extends AppLocalizations { return '읽지 않음: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => '링크를 열기?'; @@ -1644,6 +1650,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 fadc7b1e..0f084455 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1543,6 +1543,12 @@ class AppLocalizationsNl extends AppLocalizations { return 'Nieuw: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Link openen?'; @@ -1709,6 +1715,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 cdfef437..f97fb49f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1567,6 +1567,12 @@ class AppLocalizationsPl extends AppLocalizations { return 'Nieprzeczytane: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Otworzyć link?'; @@ -1733,6 +1739,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 2b7d1109..c3f76711 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1554,6 +1554,12 @@ class AppLocalizationsPt extends AppLocalizations { return 'Não lido: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Abrir link?'; @@ -1721,6 +1727,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 76cf8e34..9aa737db 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1558,6 +1558,12 @@ class AppLocalizationsRu extends AppLocalizations { return 'Непрочитанных: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Открыть ссылку?'; @@ -1724,6 +1730,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 10407978..c1420171 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1544,6 +1544,12 @@ class AppLocalizationsSk extends AppLocalizations { return 'Nezriadené: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Otvoriť odkaz?'; @@ -1710,6 +1716,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 157dce80..0831672e 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1539,6 +1539,12 @@ class AppLocalizationsSl extends AppLocalizations { return 'Nerešeno: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Odpreti povezavo?'; @@ -1705,6 +1711,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 f3259c5f..1a75a920 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1533,6 +1533,12 @@ class AppLocalizationsSv extends AppLocalizations { return 'Olästa: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Öppna länk?'; @@ -1699,6 +1705,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 8f83263d..49b8de0e 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1552,6 +1552,12 @@ class AppLocalizationsUk extends AppLocalizations { return 'Непрочитано: $count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => 'Відкрити посилання?'; @@ -1719,6 +1725,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 4cef6064..072be844 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1455,6 +1455,12 @@ class AppLocalizationsZh extends AppLocalizations { return '未读:$count'; } + @override + String get chat_markAsUnread => 'Mark as Unread'; + + @override + String get chat_newMessages => 'New messages'; + @override String get chat_openLink => '打开链接?'; @@ -1616,6 +1622,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 fa8bc465..4c780565 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -682,6 +682,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 2c971bed..0a4fe655 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -692,6 +692,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 2b8efabe..8c3c4e4e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -682,6 +682,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 9ab27fec..ca7abdd6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -397,6 +397,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 daddb249..aed0271d 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -682,6 +682,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 5969b918..fa679eb4 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -682,6 +682,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 5ed2561a..cbc165e2 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -682,6 +682,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 1bf2b584..6b9b79bc 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -683,6 +683,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 ee8285c0..8480b62b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -709,6 +709,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 b203cbbe..c0b0717e 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -4,7 +4,6 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -32,13 +31,19 @@ import '../widgets/message_translation_button.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/radio_stats_entry.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}); + const ChannelChatScreen({ + super.key, + required this.channel, + this.initialUnreadCount = 0, + }); @override State createState() => _ChannelChatScreenState(); @@ -55,32 +60,42 @@ class _ChannelChatScreenState extends State { MeshCoreConnector? _connector; DateTime? _lastChannelSendAt; bool _channelSkipNextBottomSnap = false; + String? _unreadDividerMessageId; @override void initState() { super.initState(); _textFieldFocusNode.addListener(_onTextFieldFocusChange); _scrollController.onScrollNearTop = _loadOlderMessages; + _scrollController.showJumpToBottom.addListener(_clearDividerAtBottom); 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); + final unread = widget.initialUnreadCount; + final messages = connector.getChannelMessages(widget.channel); ChannelMessage? anchor; - if (settings.jumpToOldestUnread && unread > 0) { - anchor = _findOldestUnreadChannelAnchor( - connector.getChannelMessages(widget.channel), - unread, - ); + if (unread > 0) { + anchor = _findOldestUnreadChannelAnchor(messages, unread); } + setState(() { + if (anchor != null) _unreadDividerMessageId = anchor.messageId; + }); connector.setActiveChannel(idx); _connector = connector; - if (anchor != null) { + if (anchor != null && settings.jumpToOldestUnread) { _channelSkipNextBottomSnap = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _scrollToMessage(anchor!.messageId); + _scrollController.jumpToEstimatedOffset( + unreadCount: unread, + totalMessages: messages.length, + onJumped: () { + if (!mounted) return; + _scrollToMessage(anchor!.messageId); + }, + ); }); } }); @@ -102,6 +117,13 @@ class _ChannelChatScreenState extends State { return oldest; } + void _clearDividerAtBottom() { + if (!_scrollController.showJumpToBottom.value && + _unreadDividerMessageId != null) { + setState(() => _unreadDividerMessageId = null); + } + } + void _onTextFieldFocusChange() { if (_textFieldFocusNode.hasFocus && mounted) { _scrollController.handleKeyboardOpen(); @@ -123,6 +145,7 @@ class _ChannelChatScreenState extends State { @override void dispose() { _connector?.setActiveChannel(null); + _scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom); _textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.dispose(); _textController.dispose(); @@ -321,6 +344,9 @@ class _ChannelChatScreenState extends State { if (!_messageKeys.containsKey(message.messageId)) { _messageKeys[message.messageId] = GlobalKey(); } + final isUnreadAnchor = + _unreadDividerMessageId != null && + message.messageId == _unreadDividerMessageId; return Container( key: _messageKeys[message.messageId]!, child: Builder( @@ -329,10 +355,17 @@ class _ChannelChatScreenState extends State { .select( (service) => service.scale, ); - return _buildMessageBubble( + final bubble = _buildMessageBubble( message, textScale, ); + if (isUnreadAnchor) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [const UnreadDivider(), bubble], + ); + } + return bubble; }, ), ); @@ -352,12 +385,24 @@ class _ChannelChatScreenState extends State { ); } + 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 = _parsePoiMessage(message.text); + final poi = parseMarkerText(message.text); final translatedDisplayText = message.translatedText != null && message.translatedText!.trim().isNotEmpty @@ -445,6 +490,7 @@ class _ChannelChatScreenState extends State { poi, isOutgoing, textScale, + message.senderName, trailing: (!enableTracing && isOutgoing) ? Padding( padding: const EdgeInsets.only(bottom: 2), @@ -701,7 +747,7 @@ class _ChannelChatScreenState extends State { final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7); final gifId = GifHelper.parseGif(replyText); - final poi = _parsePoiMessage(replyText); + final poi = parseMarkerText(replyText); Widget contentPreview; if (gifId != null) { @@ -812,24 +858,12 @@ class _ChannelChatScreenState extends State { ); } - _PoiInfo? _parsePoiMessage(String text) { - final trimmed = text.trim(); - final match = RegExp( - r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|', - ).firstMatch(trimmed); - if (match == null) return null; - final lat = double.tryParse(match.group(1) ?? ''); - final lon = double.tryParse(match.group(2) ?? ''); - if (lat == null || lon == null) return null; - final label = match.group(3) ?? ''; - return _PoiInfo(lat: lat, lon: lon, label: label); - } - Widget _buildPoiMessage( BuildContext context, - _PoiInfo poi, + MarkerPayload poi, bool isOutgoing, - double textScale, { + double textScale, + String senderName, { Widget? trailing, }) { final colorScheme = Theme.of(context).colorScheme; @@ -849,12 +883,22 @@ class _ChannelChatScreenState extends State { 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: LatLng(poi.lat, poi.lon), + highlightPosition: poi.position, highlightLabel: poi.label, + highlightMarkerKey: key, ), ), ); @@ -1288,6 +1332,15 @@ class _ChannelChatScreenState extends State { _copyMessageText(message.text); }, ), + 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), @@ -1507,11 +1560,3 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> { ); } } - -class _PoiInfo { - final double lat; - final double lon; - final String label; - - const _PoiInfo({required this.lat, required this.lon, required this.label}); -} diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 44c7a69c..9faccf8d 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -492,13 +492,18 @@ class _ChannelsScreenState extends State ], ), onTap: () async { + final unread = + connector.getUnreadCountForChannelIndex(channel.index); connector.markChannelRead(channel.index); await Future.delayed(const Duration(milliseconds: 50)); if (context.mounted) { Navigator.push( context, MaterialPageRoute( - builder: (context) => ChannelChatScreen(channel: channel), + builder: (context) => ChannelChatScreen( + channel: channel, + initialUnreadCount: unread, + ), ), ); } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index ffa8344b..6b10366c 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -9,7 +9,6 @@ import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; import '../utils/platform_info.dart'; -import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; @@ -44,12 +43,18 @@ import '../widgets/translated_message_content.dart'; import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; import '../helpers/snack_bar_builder.dart'; +import '../widgets/unread_divider.dart'; import 'telemetry_screen.dart'; class ChatScreen extends StatefulWidget { final Contact contact; + final int initialUnreadCount; - const ChatScreen({super.key, required this.contact}); + const ChatScreen({ + super.key, + required this.contact, + this.initialUnreadCount = 0, + }); @override State createState() => _ChatScreenState(); @@ -63,6 +68,7 @@ class _ChatScreenState extends State { bool _isLoadingOlder = false; MeshCoreConnector? _connector; Message? _pendingUnreadScrollTarget; + String? _unreadDividerMessageId; DateTime? _lastTextSendAt; @override @@ -70,34 +76,47 @@ class _ChatScreenState extends State { super.initState(); _textFieldFocusNode.addListener(_onTextFieldFocusChange); _scrollController.onScrollNearTop = _loadOlderMessages; + _scrollController.showJumpToBottom.addListener(_clearDividerAtBottom); SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final connector = context.read(); final settings = context.read().settings; final keyHex = widget.contact.publicKeyHex; - final unread = connector.getUnreadCountForContactKey(keyHex); + final unread = widget.initialUnreadCount; + final messages = connector.getMessages(widget.contact); Message? anchor; - if (settings.jumpToOldestUnread && unread > 0) { - anchor = _findOldestUnreadAnchor( - connector.getMessages(widget.contact), - unread, - ); + if (unread > 0) { + anchor = _findOldestUnreadAnchor(messages, unread); } + setState(() { + if (anchor != null) _unreadDividerMessageId = anchor.messageId; + if (anchor != null && settings.jumpToOldestUnread) { + _pendingUnreadScrollTarget = anchor; + } + }); connector.setActiveContact(keyHex); _connector = connector; - if (anchor != null) { - setState(() => _pendingUnreadScrollTarget = anchor); + if (anchor != null && settings.jumpToOldestUnread) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final ctx = _unreadScrollKey.currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - ctx, - duration: const Duration(milliseconds: 350), - alignment: 0.15, - ); - } - setState(() => _pendingUnreadScrollTarget = null); + _scrollController.jumpToEstimatedOffset( + unreadCount: unread, + totalMessages: messages.length, + onJumped: () async { + if (!mounted) return; + final ctx = _unreadScrollKey.currentContext; + if (ctx != null) { + await Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 350), + alignment: 0.15, + ); + } + if (mounted) { + setState(() => _pendingUnreadScrollTarget = null); + } + }, + ); }); } }); @@ -116,6 +135,13 @@ class _ChatScreenState extends State { return oldest; } + void _clearDividerAtBottom() { + if (!_scrollController.showJumpToBottom.value && + _unreadDividerMessageId != null) { + setState(() => _unreadDividerMessageId = null); + } + } + void _onTextFieldFocusChange() { if (_textFieldFocusNode.hasFocus && mounted) { _scrollController.handleKeyboardOpen(); @@ -137,6 +163,7 @@ class _ChatScreenState extends State { @override void dispose() { _connector?.setActiveContact(null); + _scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom); _textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.dispose(); _textController.dispose(); @@ -479,6 +506,7 @@ class _ChatScreenState extends State { senderName: resolvedContact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name, + sourceId: widget.contact.publicKeyHex, isRoomServer: resolvedContact.type == advTypeRoom, textScale: textScale, onTap: () => _openMessagePath(message, contact), @@ -486,10 +514,19 @@ class _ChatScreenState extends State { onRetryReaction: (msg, emoji) => _sendReaction(msg, contact, emoji), ); + final isUnreadAnchor = + _unreadDividerMessageId != null && + message.messageId == _unreadDividerMessageId; + final child = isUnreadAnchor + ? Column( + mainAxisSize: MainAxisSize.min, + children: [const UnreadDivider(), bubble], + ) + : bubble; if (identical(message, _pendingUnreadScrollTarget)) { - return KeyedSubtree(key: _unreadScrollKey, child: bubble); + return KeyedSubtree(key: _unreadScrollKey, child: child); } - return bubble; + return child; }, ); }, @@ -497,6 +534,18 @@ class _ChatScreenState extends State { ); } + void _markAsUnread(Message message) { + final connector = context.read(); + final messages = connector.getMessages(widget.contact); + var count = 0; + var found = false; + for (final m in messages) { + if (m.messageId == message.messageId) found = true; + if (found && !m.isOutgoing && !m.isCli) count++; + } + connector.setContactUnreadCount(widget.contact.publicKeyHex, count); + } + Widget _buildInputBar(MeshCoreConnector connector) { final maxBytes = maxContactMessageBytes(); final colorScheme = Theme.of(context).colorScheme; @@ -1320,11 +1369,15 @@ class _ChatScreenState extends State { } void _openChat(BuildContext context, Contact contact) { - // Check if this is a repeater - context.read().markContactRead(contact.publicKeyHex); + final connector = context.read(); + final unread = connector.getUnreadCountForContactKey(contact.publicKeyHex); + connector.markContactRead(contact.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + MaterialPageRoute( + builder: (context) => + ChatScreen(contact: contact, initialUnreadCount: unread), + ), ); } @@ -1461,6 +1514,15 @@ class _ChatScreenState extends State { _copyMessageText(message.text); }, ), + 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), @@ -1568,10 +1630,12 @@ class _MessageBubble extends StatelessWidget { final VoidCallback? onLongPress; final void Function(Message message, String emoji)? onRetryReaction; final double textScale; + final String sourceId; const _MessageBubble({ required this.message, required this.senderName, + required this.sourceId, required this.isRoomServer, required this.textScale, this.onTap, @@ -1586,7 +1650,7 @@ class _MessageBubble extends StatelessWidget { final isOutgoing = message.isOutgoing; final colorScheme = Theme.of(context).colorScheme; final gifId = GifHelper.parseGif(message.text); - final poi = _parsePoiMessage(message.text); + final poi = parseMarkerText(message.text); final isFailed = message.status == MessageStatus.failed; final bubbleColor = isFailed ? colorScheme.errorContainer @@ -1678,6 +1742,7 @@ class _MessageBubble extends StatelessWidget { textColor, metaColor, textScale, + senderName, trailing: (!enableTracing && isOutgoing) ? Padding( padding: const EdgeInsets.only(bottom: 2), @@ -1859,25 +1924,13 @@ class _MessageBubble extends StatelessWidget { ); } - _PoiInfo? _parsePoiMessage(String text) { - final trimmed = text.trim(); - final match = RegExp( - r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$', - ).firstMatch(trimmed); - if (match == null) return null; - final lat = double.tryParse(match.group(1) ?? ''); - final lon = double.tryParse(match.group(2) ?? ''); - if (lat == null || lon == null) return null; - final label = match.group(3) ?? ''; - return _PoiInfo(lat: lat, lon: lon, label: label); - } - Widget _buildPoiMessage( BuildContext context, - _PoiInfo poi, + MarkerPayload poi, Color textColor, Color metaColor, - double textScale, { + double textScale, + String senderName, { Widget? trailing, }) { return Row( @@ -1887,13 +1940,23 @@ class _MessageBubble extends StatelessWidget { icon: Icon(Icons.location_on_outlined, color: textColor), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () { + onPressed: () async { + final selfName = context.read().selfName ?? 'Me'; + final fromName = message.isOutgoing ? selfName : senderName; + final key = buildSharedMarkerKey( + sourceId: sourceId, + label: poi.label, + fromName: fromName, + flags: poi.flags, + isChannel: false, + ); Navigator.push( context, MaterialPageRoute( builder: (context) => MapScreen( - highlightPosition: LatLng(poi.lat, poi.lon), + highlightPosition: poi.position, highlightLabel: poi.label, + highlightMarkerKey: key, ), ), ); @@ -2074,11 +2137,3 @@ class _MessageBubble extends StatelessWidget { return '$hour:$minute'; } } - -class _PoiInfo { - final double lat; - final double lon; - final String label; - - const _PoiInfo({required this.lat, required this.lon, required this.label}); -} diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 54d32990..601d44ea 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -930,10 +930,18 @@ class _ContactsScreenState extends State } else if (contact.type == advTypeRoom) { _showRoomLogin(context, contact, RoomLoginDestination.chat); } else { - context.read().markContactRead(contact.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(contact.publicKeyHex); + connector.markContactRead(contact.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + MaterialPageRoute( + builder: (context) => ChatScreen( + contact: contact, + initialUnreadCount: unread, + ), + ), ); } } @@ -988,7 +996,10 @@ class _ContactsScreenState extends State builder: (context) => RoomLoginDialog( room: room, onLogin: (password, isAdmin) { - context.read().markContactRead(room.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(room.publicKeyHex); + connector.markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( @@ -999,7 +1010,10 @@ class _ContactsScreenState extends State password: password, isAdmin: isAdmin, ) - : ChatScreen(contact: room), + : ChatScreen( + contact: room, + initialUnreadCount: unread, + ), ), ); }, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 6a8acda7..bd5e88b8 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -37,6 +37,7 @@ import 'line_of_sight_map_screen.dart'; class MapScreen extends StatefulWidget { final LatLng? highlightPosition; final String? highlightLabel; + final String? highlightMarkerKey; final double highlightZoom; final bool hideBackButton; @@ -44,6 +45,7 @@ class MapScreen extends StatefulWidget { super.key, this.highlightPosition, this.highlightLabel, + this.highlightMarkerKey, this.highlightZoom = 15.0, this.hideBackButton = false, }); @@ -94,6 +96,19 @@ class _MapScreenState extends State { _removedMarkerIds = ids; _removedMarkersLoaded = true; }); + // If this screen was opened to highlight a marker, and that marker + // was previously removed, re-enable it now that we've loaded the saved + // removed IDs. + if (widget.highlightMarkerKey != null && + _removedMarkerIds.contains(widget.highlightMarkerKey)) { + final updated = Set.from(_removedMarkerIds); + updated.remove(widget.highlightMarkerKey); + if (!mounted) return; + setState(() { + _removedMarkerIds = updated; + }); + await _markerService.saveRemovedIds(updated); + } } bool _checkLocationPlausibility(double lat, double lon) { @@ -229,6 +244,24 @@ class _MapScreenState extends State { : [], ); + // Collect polylines for shared markers' history with dashed lines + final List sharedMarkerPolylines = []; + for (final marker in sharedMarkers) { + if (marker.history.isNotEmpty) { + final points = List.from(marker.history); + points.add(marker.position); + sharedMarkerPolylines.add( + Polyline( + points: points, + color: marker.isChannel + ? (marker.isPublicChannel ? Colors.orange : Colors.purple) + : Colors.blue, + strokeWidth: 3, + ), + ); + } + } + // Calculate center and zoom of all nodes, or default to (0, 0) LatLng center = const LatLng(0, 0); double initialZoom = 10.0; @@ -475,6 +508,8 @@ class _MapScreenState extends State { ), if (_polylines.isNotEmpty && _isBuildingPathTrace) PolylineLayer(polylines: _polylines), + if (sharedMarkerPolylines.isNotEmpty) + PolylineLayer(polylines: sharedMarkerPolylines), MarkerLayer( markers: [ if (highlightPosition != null) @@ -1239,28 +1274,39 @@ class _MapScreenState extends State { } List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) { - final markers = <_SharedMarker>[]; + // Build a _SharedMarker per message (history empty), grouped by dedupe key. + // Afterwards pick the latest per key and fill its history from older ones. + final updatesByKey = >{}; final selfName = connector.selfName ?? 'Me'; + void addUpdate(_SharedMarker update) { + (updatesByKey[update.id] ??= <_SharedMarker>[]).add(update); + } + for (final contact in connector.contacts) { final messages = connector.getMessages(contact); for (final message in messages) { - final payload = _parseMarkerText(message.text); + 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, - timestamp: message.timestamp, - text: message.text, + label: payload.label, + fromName: fromName, + flags: payload.flags, + isChannel: false, ); - markers.add( + addUpdate( _SharedMarker( - id: id, + id: key, position: payload.position, - label: payload.label, + label: payload.label.isEmpty + ? context.l10n.map_sharedPin + : payload.label, flags: payload.flags, fromName: fromName, sourceLabel: contact.name, + timestamp: message.timestamp, isChannel: false, isPublicChannel: false, ), @@ -1272,23 +1318,28 @@ class _MapScreenState extends State { final isPublic = _isPublicChannel(channel); final messages = connector.getChannelMessages(channel); for (final message in messages) { - final payload = _parseMarkerText(message.text); + final payload = parseMarkerText(message.text); if (payload == null) continue; - final id = _buildMarkerId( + final key = buildSharedMarkerKey( sourceId: 'channel:${channel.index}', - timestamp: message.timestamp, - text: message.text, + label: payload.label, + fromName: message.senderName, + flags: payload.flags, + isChannel: true, ); - markers.add( + addUpdate( _SharedMarker( - id: id, + id: key, position: payload.position, - label: payload.label, + label: payload.label.isEmpty + ? context.l10n.map_sharedPin + : payload.label, flags: payload.flags, fromName: message.senderName, sourceLabel: channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name, + timestamp: message.timestamp, isChannel: true, isPublicChannel: isPublic, ), @@ -1296,38 +1347,27 @@ class _MapScreenState extends State { } } + final markers = <_SharedMarker>[]; + updatesByKey.forEach((_, updates) { + updates.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final latest = updates.last; + // History: older positions, drop consecutive duplicates at same position. + final history = []; + for (var i = 0; i < updates.length - 1; i++) { + final p = updates[i].position; + if (history.isEmpty || + history.last.latitude != p.latitude || + history.last.longitude != p.longitude) { + history.add(p); + } + } + markers.add(latest.copyWithHistory(history)); + }); + + markers.sort((a, b) => b.timestamp.compareTo(a.timestamp)); return markers; } - _MarkerPayload? _parseMarkerText(String text) { - final trimmed = text.trim(); - if (!trimmed.startsWith('m:')) return null; - - final parts = trimmed.substring(2).split('|'); - if (parts.isEmpty) return null; - final coords = parts[0].split(','); - if (coords.length != 2) return null; - final lat = double.tryParse(coords[0].trim()); - final lon = double.tryParse(coords[1].trim()); - if (lat == null || lon == null) return null; - - final label = parts.length > 1 ? parts[1].trim() : ''; - final flags = parts.length > 2 ? parts[2].trim() : ''; - return _MarkerPayload( - position: LatLng(lat, lon), - label: label.isEmpty ? context.l10n.map_sharedPin : label, - flags: flags, - ); - } - - String _buildMarkerId({ - required String sourceId, - required DateTime timestamp, - required String text, - }) { - return '$sourceId|${timestamp.millisecondsSinceEpoch}|$text'; - } - Marker _buildSharedMarker(_SharedMarker marker) { final markerColor = marker.isChannel ? (marker.isPublicChannel ? Colors.orange : Colors.purple) @@ -1337,7 +1377,15 @@ class _MapScreenState extends State { width: 60, height: 60, child: GestureDetector( - onTap: () => _showMarkerInfo(marker), + onTap: () async { + if (_removedMarkerIds.contains(marker.id)) { + setState(() { + _removedMarkerIds.remove(marker.id); + }); + await _markerService.saveRemovedIds(_removedMarkerIds); + } + _showMarkerInfo(marker); + }, child: Column( children: [ Container( @@ -1391,11 +1439,17 @@ class _MapScreenState extends State { room: room, // onLogin(password, isAdmin) isAdmin not used for room caht screen onLogin: (password, _) { - // Navigate to chat screen after successful login - context.read().markContactRead(room.publicKeyHex); + final connector = context.read(); + final unread = connector.getUnreadCountForContactKey( + room.publicKeyHex, + ); + connector.markContactRead(room.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: room)), + MaterialPageRoute( + builder: (context) => + ChatScreen(contact: room, initialUnreadCount: unread), + ), ); }, ), @@ -1456,11 +1510,17 @@ class _MapScreenState extends State { if (!contact.isActive) { connector.importDiscoveredContact(contact); } + final unread = connector.getUnreadCountForContactKey( + contact.publicKeyHex, + ); Navigator.pop(dialogContext); Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(contact: contact), + builder: (context) => ChatScreen( + contact: contact, + initialUnreadCount: unread, + ), ), ); }, @@ -1542,13 +1602,19 @@ 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, 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)}', @@ -1715,6 +1781,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( @@ -2310,18 +2380,50 @@ class _GuessedLocation { }); } -class _MarkerPayload { +class MarkerPayload { final LatLng position; final String label; final String flags; - _MarkerPayload({ + MarkerPayload({ required this.position, required this.label, required this.flags, }); } +/// Parse a shared marker text message of the form +/// `m:,|