Merge branch 'dev' into enhancement/los-obstruction-pinning-411

This commit is contained in:
zjs81
2026-04-27 13:13:19 -07:00
committed by GitHub
48 changed files with 1103 additions and 146 deletions
+21
View File
@@ -658,6 +658,27 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
void setContactUnreadCount(String contactKeyHex, int count) {
_contactUnreadCount[contactKeyHex] = count;
_unreadStore.saveContactUnreadCount(
Map<String, int>.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) {
+19
View File
@@ -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) {
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Покажи споделени маркери",
"map_lastSeenTime": "Последна видяна дата",
"map_sharedPin": "Споделено копие",
"map_sharedAt": "Споделено",
"map_joinRoom": "Присъедини се към стаята",
"map_manageRepeater": "Управление на Повтарящ се Елемент",
"mapCache_title": "Кеш на офлайн карти",
+1
View File
@@ -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",
+3
View File
@@ -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.",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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.",
+1
View File
@@ -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",
+1
View File
@@ -861,6 +861,7 @@
"map_guessedLocation": "推測された場所",
"map_lastSeenTime": "最後に確認された時間",
"map_sharedPin": "共有パスワード",
"map_sharedAt": "共有済み",
"map_joinRoom": "部屋に参加する",
"map_manageRepeater": "リピーターの管理",
"map_tapToAdd": "ノードをクリックして、パスに追加します。",
+1
View File
@@ -861,6 +861,7 @@
"map_guessedLocation": "추측된 위치",
"map_lastSeenTime": "마지막으로 확인된 시간",
"map_sharedPin": "공유 비밀번호",
"map_sharedAt": "공유됨",
"map_joinRoom": "방에 참여",
"map_manageRepeater": "리피터 관리",
"map_tapToAdd": "노드에 클릭하여 경로에 추가합니다.",
+18
View File
@@ -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:
+9
View File
@@ -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 => 'Присъедини се към стаята';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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 => '部屋に参加する';
+9
View File
@@ -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 => '방에 참여';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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 => 'Присоединиться к комнате';
+9
View File
@@ -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ť';
+9
View File
@@ -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';
+9
View File
@@ -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';
+9
View File
@@ -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 => 'Приєднатися до кімнати';
+9
View File
@@ -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 => '加入房间';
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -397,6 +397,7 @@
"map_showSharedMarkers": "Показывать общие метки",
"map_lastSeenTime": "Время последнего появления",
"map_sharedPin": "Общая метка",
"map_sharedAt": "Поделено",
"map_joinRoom": "Присоединиться к комнате",
"map_manageRepeater": "Управление репитером",
"mapCache_title": "Кэш офлайн-карты",
+1
View File
@@ -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äť",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -683,6 +683,7 @@
"map_showSharedMarkers": "Показувати спільні маркери",
"map_lastSeenTime": "Час останньої активності",
"map_sharedPin": "Спільний пін",
"map_sharedAt": "Поділено",
"map_joinRoom": "Приєднатися до кімнати",
"map_manageRepeater": "Керувати ретранслятором",
"mapCache_title": "Офлайн-кеш карти",
+1
View File
@@ -709,6 +709,7 @@
"map_showSharedMarkers": "显示共享标记",
"map_lastSeenTime": "最后在线时间",
"map_sharedPin": "共享标记",
"map_sharedAt": "已分享",
"map_joinRoom": "加入房间",
"map_manageRepeater": "管理转发节点",
"mapCache_title": "离线地图缓存",
+82 -37
View File
@@ -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<ChannelChatScreen> createState() => _ChannelChatScreenState();
@@ -55,32 +60,42 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().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<ChannelChatScreen> {
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<ChannelChatScreen> {
@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<ChannelChatScreen> {
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<ChannelChatScreen> {
.select<ChatTextScaleService, double>(
(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<ChannelChatScreen> {
);
}
void _markAsUnread(ChannelMessage message) {
final connector = context.read<MeshCoreConnector>();
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<AppSettingsService>();
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<ChannelChatScreen> {
poi,
isOutgoing,
textScale,
message.senderName,
trailing: (!enableTracing && isOutgoing)
? Padding(
padding: const EdgeInsets.only(bottom: 2),
@@ -701,7 +747,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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<ChannelChatScreen> {
);
}
_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<ChannelChatScreen> {
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () {
final selfName = context.read<MeshCoreConnector>().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<ChannelChatScreen> {
_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});
}
+6 -1
View File
@@ -492,13 +492,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
],
),
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,
),
),
);
}
+105 -50
View File
@@ -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<ChatScreen> createState() => _ChatScreenState();
@@ -63,6 +68,7 @@ class _ChatScreenState extends State<ChatScreen> {
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
Message? _pendingUnreadScrollTarget;
String? _unreadDividerMessageId;
DateTime? _lastTextSendAt;
@override
@@ -70,34 +76,47 @@ class _ChatScreenState extends State<ChatScreen> {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().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<ChatScreen> {
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<ChatScreen> {
@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<ChatScreen> {
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<ChatScreen> {
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<ChatScreen> {
);
}
void _markAsUnread(Message message) {
final connector = context.read<MeshCoreConnector>();
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<ChatScreen> {
}
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
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<ChatScreen> {
_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<MeshCoreConnector>().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});
}
+18 -4
View File
@@ -930,10 +930,18 @@ class _ContactsScreenState extends State<ContactsScreen>
} else if (contact.type == advTypeRoom) {
_showRoomLogin(context, contact, RoomLoginDestination.chat);
} else {
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
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<ContactsScreen>
builder: (context) => RoomLoginDialog(
room: room,
onLogin: (password, isAdmin) {
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
final unread =
connector.getUnreadCountForContactKey(room.publicKeyHex);
connector.markContactRead(room.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(
@@ -999,7 +1010,10 @@ class _ContactsScreenState extends State<ContactsScreen>
password: password,
isAdmin: isAdmin,
)
: ChatScreen(contact: room),
: ChatScreen(
contact: room,
initialUnreadCount: unread,
),
),
);
},
+173 -52
View File
@@ -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<MapScreen> {
_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<String>.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<MapScreen> {
: <Polyline>[],
);
// Collect polylines for shared markers' history with dashed lines
final List<Polyline> sharedMarkerPolylines = [];
for (final marker in sharedMarkers) {
if (marker.history.isNotEmpty) {
final points = List<LatLng>.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<MapScreen> {
),
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<MapScreen> {
}
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 = <String, List<_SharedMarker>>{};
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<MapScreen> {
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<MapScreen> {
}
}
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 = <LatLng>[];
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<MapScreen> {
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<MapScreen> {
room: room,
// onLogin(password, isAdmin) isAdmin not used for room caht screen
onLogin: (password, _) {
// Navigate to chat screen after successful login
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
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<MapScreen> {
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<MapScreen> {
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<MapScreen> {
String defaultLabel,
) async {
final controller = TextEditingController(text: defaultLabel);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
return showDialog<String>(
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:<lat>,<lon>|<label>|<flags>` and return a [MarkerPayload].
MarkerPayload? parseMarkerText(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) ?? '').trim();
final flags = (match.group(4) ?? '').trim();
return MarkerPayload(position: LatLng(lat, lon), label: label, flags: flags);
}
/// Build a normalized dedupe key for shared markers.
/// Keeps the same algorithm previously present in both chat and map screens.
String buildSharedMarkerKey({
required String sourceId,
required String label,
required String fromName,
required String flags,
required bool isChannel,
}) {
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';
}
class _SharedMarker {
final String id;
final LatLng position;
@@ -2329,8 +2431,10 @@ class _SharedMarker {
final String flags;
final String fromName;
final String sourceLabel;
final DateTime timestamp;
final bool isChannel;
final bool isPublicChannel;
final List<LatLng> history;
_SharedMarker({
required this.id,
@@ -2339,7 +2443,24 @@ class _SharedMarker {
required this.flags,
required this.fromName,
required this.sourceLabel,
required this.timestamp,
required this.isChannel,
required this.isPublicChannel,
this.history = const [],
});
_SharedMarker copyWithHistory(List<LatLng> newHistory) {
return _SharedMarker(
id: id,
position: position,
label: label,
flags: flags,
fromName: fromName,
sourceLabel: sourceLabel,
timestamp: timestamp,
isChannel: isChannel,
isPublicChannel: isPublicChannel,
history: newHistory,
);
}
}
+447
View File
@@ -0,0 +1,447 @@
import 'package:flutter/material.dart';
/// MeshCore redesign palette warm field-journal dark theme with
/// phosphor-green signal accents. Mirrors values from the redesign spec.
class MeshPalette {
MeshPalette._();
// Surfaces (warm near-black, olive undertone)
static const bg = Color(0xFF0F1412);
static const bg1 = Color(0xFF161C19);
static const bg2 = Color(0xFF1D2521);
static const bg3 = Color(0xFF28322D);
static const bg4 = Color(0xFF34403A);
// Lines
static const line = Color(0xFF232C28);
static const line2 = Color(0xFF34403A);
static const line3 = Color(0xFF48564F);
// Ink
static const ink = Color(0xFFEFF3E8);
static const ink2 = Color(0xFFBAC4B5);
static const ink3 = Color(0xFF7C8B82);
static const ink4 = Color(0xFF55635B);
// Signal (phosphor)
static const signal = Color(0xFF7BEFA8);
static const signalDim = Color(0xFF4DC580);
static const signalBg = Color(0x177BEFA8); // ~9% alpha
static const signalLine = Color(0x427BEFA8); // ~26%
static const signalGlow = Color(0x597BEFA8); // ~35%
// Warn (ember)
static const warn = Color(0xFFFFA552);
static const warnDim = Color(0xFFC27E3C);
static const warnBg = Color(0x1CFFA552);
static const warnLine = Color(0x4DFFA552);
// Alert (coral)
static const alert = Color(0xFFFF6A5C);
static const alertBg = Color(0x1CFF6A5C);
static const alertLine = Color(0x52FF6A5C);
// Blue (dusk sky)
static const blue = Color(0xFF7FCBF5);
static const blueBg = Color(0x1C7FCBF5);
static const blueLine = Color(0x477FCBF5);
// Magenta
static const magenta = Color(0xFFDE7FDB);
static const magentaBg = Color(0x1CDE7FDB);
static const magentaLine = Color(0x47DE7FDB);
// Me bubble (mossy)
static const me = Color(0xFF1E3527);
static const meBorder = Color(0xFF2D5039);
static const meInk = Color(0xFFDEF0DC);
// Light variant (used when user explicitly picks light theme)
static const lightBg = Color(0xFFF5F3EC);
static const lightBg1 = Color(0xFFECE9DF);
static const lightBg2 = Color(0xFFE2DED2);
static const lightLine = Color(0xFFCAC5B4);
static const lightInk = Color(0xFF0F1410);
static const lightInk2 = Color(0xFF3D463E);
static const lightInk3 = Color(0xFF6A756D);
static const lightSignal = Color(0xFF1A7A44);
}
/// Named font stacks Flutter falls back to system fonts when the named
/// family isn't installed, keeping things working without bundled assets.
class MeshFonts {
MeshFonts._();
static const sans = 'Inter';
static const mono = 'JetBrains Mono';
static const display = 'Instrument Serif';
static const List<String> sansFallback = [
'system-ui',
'-apple-system',
'Roboto',
'Noto Sans',
'sans-serif',
];
static const List<String> monoFallback = [
'SF Mono',
'Menlo',
'Consolas',
'Roboto Mono',
'monospace',
];
static const List<String> displayFallback = [
'Cormorant Garamond',
'Georgia',
'Times New Roman',
'serif',
];
}
/// Radii used consistently across the app.
class MeshRadii {
MeshRadii._();
static const xs = 6.0;
static const sm = 10.0;
static const md = 14.0;
static const lg = 18.0;
static const xl = 24.0;
static const pill = 999.0;
}
/// Shared helpers exposed via [MeshTheme.of].
class MeshTheme {
MeshTheme._();
static ThemeData dark() {
const scheme = ColorScheme.dark(
primary: MeshPalette.signal,
onPrimary: Color(0xFF0A1810),
primaryContainer: MeshPalette.signalBg,
onPrimaryContainer: MeshPalette.signal,
secondary: MeshPalette.blue,
onSecondary: Color(0xFF0A1520),
tertiary: MeshPalette.magenta,
onTertiary: Color(0xFF201020),
error: MeshPalette.alert,
onError: Color(0xFF1A0A08),
errorContainer: MeshPalette.alertBg,
onErrorContainer: MeshPalette.alert,
surface: MeshPalette.bg,
onSurface: MeshPalette.ink,
surfaceContainerLowest: MeshPalette.bg,
surfaceContainerLow: MeshPalette.bg1,
surfaceContainer: MeshPalette.bg1,
surfaceContainerHigh: MeshPalette.bg2,
surfaceContainerHighest: MeshPalette.bg3,
onSurfaceVariant: MeshPalette.ink2,
outline: MeshPalette.line2,
outlineVariant: MeshPalette.line,
shadow: Colors.black,
scrim: Colors.black54,
inverseSurface: MeshPalette.ink,
onInverseSurface: MeshPalette.bg,
inversePrimary: MeshPalette.signalDim,
);
return _build(scheme, Brightness.dark);
}
static ThemeData light() {
const scheme = ColorScheme.light(
primary: MeshPalette.lightSignal,
onPrimary: Colors.white,
primaryContainer: Color(0xFFD4E8D8),
onPrimaryContainer: MeshPalette.lightSignal,
secondary: Color(0xFF2F6EA8),
onSecondary: Colors.white,
tertiary: Color(0xFF8C4A8A),
onTertiary: Colors.white,
error: Color(0xFFB53D2F),
onError: Colors.white,
surface: MeshPalette.lightBg,
onSurface: MeshPalette.lightInk,
surfaceContainerLowest: MeshPalette.lightBg,
surfaceContainerLow: MeshPalette.lightBg1,
surfaceContainer: MeshPalette.lightBg1,
surfaceContainerHigh: MeshPalette.lightBg2,
surfaceContainerHighest: Color(0xFFD5D0C0),
onSurfaceVariant: MeshPalette.lightInk2,
outline: MeshPalette.lightLine,
outlineVariant: Color(0xFFDBD6C6),
);
return _build(scheme, Brightness.light);
}
static ThemeData _build(ColorScheme scheme, Brightness brightness) {
final baseText =
Typography.material2021(
platform: TargetPlatform.android,
colorScheme: scheme,
).black.apply(
bodyColor: scheme.onSurface,
displayColor: scheme.onSurface,
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
canvasColor: scheme.surface,
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
textTheme: baseText,
dividerColor: scheme.outlineVariant,
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
),
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
color: scheme.onSurface,
),
iconTheme: IconThemeData(color: scheme.onSurface),
shape: Border(
bottom: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
cardTheme: CardThemeData(
color: scheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
side: BorderSide(color: scheme.outlineVariant, width: 1),
),
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
),
listTileTheme: ListTileThemeData(
iconColor: scheme.onSurfaceVariant,
textColor: scheme.onSurface,
tileColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
elevation: 0,
focusElevation: 0,
hoverElevation: 0,
highlightElevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
extendedTextStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
textStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: scheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHigh,
hintStyle: TextStyle(color: scheme.onSurfaceVariant),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
borderSide: BorderSide(color: scheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
),
chipTheme: ChipThemeData(
backgroundColor: scheme.surfaceContainerLow,
side: BorderSide(color: scheme.outlineVariant),
labelStyle: TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: scheme.onSurfaceVariant,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
indicatorColor: scheme.primary.withValues(alpha: 0.14),
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return TextStyle(
fontFamily: MeshFonts.mono,
fontFamilyFallback: MeshFonts.monoFallback,
fontSize: 10,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: 0.1,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? scheme.primary : scheme.onSurfaceVariant,
size: 22,
);
}),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
modalBackgroundColor: scheme.surfaceContainerLow,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(MeshRadii.lg),
),
),
),
dialogTheme: DialogThemeData(
backgroundColor: scheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.lg),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.surfaceContainerHigh,
contentTextStyle: TextStyle(color: scheme.onSurface),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
),
),
popupMenuTheme: PopupMenuThemeData(
color: scheme.surfaceContainerHigh,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
),
),
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22),
splashFactory: InkSparkle.splashFactory,
);
}
/// Mono text style sizes default to the body size Inter is using.
static TextStyle mono({
double? fontSize,
FontWeight? fontWeight,
Color? color,
double? letterSpacing,
}) {
return TextStyle(
fontFamily: MeshFonts.mono,
fontFamilyFallback: MeshFonts.monoFallback,
fontSize: fontSize,
fontWeight: fontWeight,
color: color,
letterSpacing: letterSpacing ?? 0.2,
fontFeatures: const [FontFeature.tabularFigures()],
);
}
/// Serif display style.
static TextStyle display({
double? fontSize,
FontWeight? fontWeight,
Color? color,
double? letterSpacing,
}) {
return TextStyle(
fontFamily: MeshFonts.display,
fontFamilyFallback: MeshFonts.displayFallback,
fontSize: fontSize,
fontWeight: fontWeight ?? FontWeight.w400,
color: color,
letterSpacing: letterSpacing ?? -0.2,
);
}
/// Small-caps mono label used for section accents and chip labels.
static TextStyle accentLabel({Color? color, double? fontSize}) {
return TextStyle(
fontFamily: MeshFonts.mono,
fontFamilyFallback: MeshFonts.monoFallback,
fontSize: fontSize ?? 9.5,
fontWeight: FontWeight.w600,
letterSpacing: 1.8,
color: color,
);
}
/// Color-code an SNR value for consistency across the app.
static Color snrColor(num? snr, {required bool blocked}) {
if (blocked) return MeshPalette.alert;
if (snr == null) return MeshPalette.ink3;
if (snr > -5) return MeshPalette.signal;
if (snr > -12) return MeshPalette.warn;
return MeshPalette.alert;
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class UnreadDivider extends StatelessWidget {
const UnreadDivider({super.key});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(child: Divider(color: color)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
context.l10n.chat_newMessages,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
Expanded(child: Divider(color: color)),
],
),
);
}
}
-1
View File
@@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
-1
View File
@@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial
flutter_local_notifications_windows
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)