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) { void markChannelRead(int channelIndex) {
final channel = _findChannelByIndex(channelIndex); final channel = _findChannelByIndex(channelIndex);
if (channel != null && channel.unreadCount > 0) { 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() { void scrollToBottomIfAtBottom() {
// Only scroll if jump button is NOT showing (i.e., already at bottom) // Only scroll if jump button is NOT showing (i.e., already at bottom)
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) { if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Покажи споделени маркери", "map_showSharedMarkers": "Покажи споделени маркери",
"map_lastSeenTime": "Последна видяна дата", "map_lastSeenTime": "Последна видяна дата",
"map_sharedPin": "Споделено копие", "map_sharedPin": "Споделено копие",
"map_sharedAt": "Споделено",
"map_joinRoom": "Присъедини се към стаята", "map_joinRoom": "Присъедини се към стаята",
"map_manageRepeater": "Управление на Повтарящ се Елемент", "map_manageRepeater": "Управление на Повтарящ се Елемент",
"mapCache_title": "Кеш на офлайн карти", "mapCache_title": "Кеш на офлайн карти",
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Zeige gemeinsam genutzte Marker", "map_showSharedMarkers": "Zeige gemeinsam genutzte Marker",
"map_lastSeenTime": "Letzte Sichtung", "map_lastSeenTime": "Letzte Sichtung",
"map_sharedPin": "Gemeinsames Passwort", "map_sharedPin": "Gemeinsames Passwort",
"map_sharedAt": "Geteilt",
"map_joinRoom": "Beitreten Sie dem Raum", "map_joinRoom": "Beitreten Sie dem Raum",
"map_manageRepeater": "Repeater verwalten", "map_manageRepeater": "Repeater verwalten",
"mapCache_title": "Offline-Karten-Cache", "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_openLink": "Open Link?",
"chat_openLinkConfirmation": "Do you want to open this link in your browser?", "chat_openLinkConfirmation": "Do you want to open this link in your browser?",
"chat_open": "Open", "chat_open": "Open",
@@ -896,6 +898,7 @@
"map_guessedLocation": "Guessed location", "map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time", "map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin", "map_sharedPin": "Shared pin",
"map_sharedAt": "Shared",
"map_joinRoom": "Join Room", "map_joinRoom": "Join Room",
"map_manageRepeater": "Manage Repeater", "map_manageRepeater": "Manage Repeater",
"map_tapToAdd": "Tap on nodes to add them to the path.", "map_tapToAdd": "Tap on nodes to add them to the path.",
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Mostrar marcadores compartidos", "map_showSharedMarkers": "Mostrar marcadores compartidos",
"map_lastSeenTime": "Última vez que se vio", "map_lastSeenTime": "Última vez que se vio",
"map_sharedPin": "Pin compartido", "map_sharedPin": "Pin compartido",
"map_sharedAt": "Compartido",
"map_joinRoom": "Únete a la sala", "map_joinRoom": "Únete a la sala",
"map_manageRepeater": "Gestionar Repetidor", "map_manageRepeater": "Gestionar Repetidor",
"mapCache_title": "Caché de Mapa Offline", "mapCache_title": "Caché de Mapa Offline",
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Afficher les marqueurs partagés", "map_showSharedMarkers": "Afficher les marqueurs partagés",
"map_lastSeenTime": "Dernière fois vu", "map_lastSeenTime": "Dernière fois vu",
"map_sharedPin": "Clé partagée", "map_sharedPin": "Clé partagée",
"map_sharedAt": "Partagé",
"map_joinRoom": "Rejoindre le room server", "map_joinRoom": "Rejoindre le room server",
"map_manageRepeater": "Gérer le répéteur", "map_manageRepeater": "Gérer le répéteur",
"mapCache_title": "Cache de Carte Hors Ligne", "mapCache_title": "Cache de Carte Hors Ligne",
+1
View File
@@ -861,6 +861,7 @@
"map_guessedLocation": "Tippolt hely", "map_guessedLocation": "Tippolt hely",
"map_lastSeenTime": "Utoljára megjelent idő", "map_lastSeenTime": "Utoljára megjelent idő",
"map_sharedPin": "Gemeinsames PIN-kód", "map_sharedPin": "Gemeinsames PIN-kód",
"map_sharedAt": "Megosztva",
"map_joinRoom": "Csatlakozás a szobához", "map_joinRoom": "Csatlakozás a szobához",
"map_manageRepeater": "Ellenőriző eszköz kezelése", "map_manageRepeater": "Ellenőriző eszköz kezelése",
"map_tapToAdd": "Nyomj meg a csomópontokhoz, hogy hozzáadd őket az útvonalhoz.", "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_showSharedMarkers": "Mostra i segnaposto condivisi",
"map_lastSeenTime": "Ultimo Tempo di Visualizzazione", "map_lastSeenTime": "Ultimo Tempo di Visualizzazione",
"map_sharedPin": "Condividi PIN", "map_sharedPin": "Condividi PIN",
"map_sharedAt": "Condiviso",
"map_joinRoom": "Unisciti alla stanza", "map_joinRoom": "Unisciti alla stanza",
"map_manageRepeater": "Gestisci Ripetitore", "map_manageRepeater": "Gestisci Ripetitore",
"mapCache_title": "Cache Mappa Offline", "mapCache_title": "Cache Mappa Offline",
+1
View File
@@ -861,6 +861,7 @@
"map_guessedLocation": "推測された場所", "map_guessedLocation": "推測された場所",
"map_lastSeenTime": "最後に確認された時間", "map_lastSeenTime": "最後に確認された時間",
"map_sharedPin": "共有パスワード", "map_sharedPin": "共有パスワード",
"map_sharedAt": "共有済み",
"map_joinRoom": "部屋に参加する", "map_joinRoom": "部屋に参加する",
"map_manageRepeater": "リピーターの管理", "map_manageRepeater": "リピーターの管理",
"map_tapToAdd": "ノードをクリックして、パスに追加します。", "map_tapToAdd": "ノードをクリックして、パスに追加します。",
+1
View File
@@ -861,6 +861,7 @@
"map_guessedLocation": "추측된 위치", "map_guessedLocation": "추측된 위치",
"map_lastSeenTime": "마지막으로 확인된 시간", "map_lastSeenTime": "마지막으로 확인된 시간",
"map_sharedPin": "공유 비밀번호", "map_sharedPin": "공유 비밀번호",
"map_sharedAt": "공유됨",
"map_joinRoom": "방에 참여", "map_joinRoom": "방에 참여",
"map_manageRepeater": "리피터 관리", "map_manageRepeater": "리피터 관리",
"map_tapToAdd": "노드에 클릭하여 경로에 추가합니다.", "map_tapToAdd": "노드에 클릭하여 경로에 추가합니다.",
+18
View File
@@ -2824,6 +2824,18 @@ abstract class AppLocalizations {
/// **'Unread: {count}'** /// **'Unread: {count}'**
String chat_unread(int 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. /// No description provided for @chat_openLink.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3130,6 +3142,12 @@ abstract class AppLocalizations {
/// **'Shared pin'** /// **'Shared pin'**
String get map_sharedPin; 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. /// No description provided for @map_joinRoom.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+9
View File
@@ -1558,6 +1558,12 @@ class AppLocalizationsBg extends AppLocalizations {
return 'Непрочетени: $count'; return 'Непрочетени: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Отваряне на връзката?'; String get chat_openLink => 'Отваряне на връзката?';
@@ -1724,6 +1730,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_sharedPin => 'Споделено копие'; String get map_sharedPin => 'Споделено копие';
@override
String get map_sharedAt => 'Споделено';
@override @override
String get map_joinRoom => 'Присъедини се към стаята'; String get map_joinRoom => 'Присъедини се към стаята';
+9
View File
@@ -1555,6 +1555,12 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Ungelesen: $count'; return 'Ungelesen: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Link öffnen?'; String get chat_openLink => 'Link öffnen?';
@@ -1721,6 +1727,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_sharedPin => 'Gemeinsames Passwort'; String get map_sharedPin => 'Gemeinsames Passwort';
@override
String get map_sharedAt => 'Geteilt';
@override @override
String get map_joinRoom => 'Beitreten Sie dem Raum'; String get map_joinRoom => 'Beitreten Sie dem Raum';
+9
View File
@@ -1525,6 +1525,12 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Unread: $count'; return 'Unread: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Open Link?'; String get chat_openLink => 'Open Link?';
@@ -1690,6 +1696,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_sharedPin => 'Shared pin'; String get map_sharedPin => 'Shared pin';
@override
String get map_sharedAt => 'Shared';
@override @override
String get map_joinRoom => 'Join Room'; String get map_joinRoom => 'Join Room';
+9
View File
@@ -1554,6 +1554,12 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Sin leer: $count'; return 'Sin leer: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => '¿Abrir enlace?'; String get chat_openLink => '¿Abrir enlace?';
@@ -1720,6 +1726,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_sharedPin => 'Pin compartido'; String get map_sharedPin => 'Pin compartido';
@override
String get map_sharedAt => 'Compartido';
@override @override
String get map_joinRoom => 'Únete a la sala'; String get map_joinRoom => 'Únete a la sala';
+9
View File
@@ -1563,6 +1563,12 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Non lu : $count'; return 'Non lu : $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Ouvrir le lien ?'; String get chat_openLink => 'Ouvrir le lien ?';
@@ -1730,6 +1736,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_sharedPin => 'Clé partagée'; String get map_sharedPin => 'Clé partagée';
@override
String get map_sharedAt => 'Partagé';
@override @override
String get map_joinRoom => 'Rejoindre le room server'; String get map_joinRoom => 'Rejoindre le room server';
+9
View File
@@ -1565,6 +1565,12 @@ class AppLocalizationsHu extends AppLocalizations {
return 'Olvasatlan: $count'; return 'Olvasatlan: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Nyisd meg a linket?'; String get chat_openLink => 'Nyisd meg a linket?';
@@ -1733,6 +1739,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get map_sharedPin => 'Gemeinsames PIN-kód'; String get map_sharedPin => 'Gemeinsames PIN-kód';
@override
String get map_sharedAt => 'Megosztva';
@override @override
String get map_joinRoom => 'Csatlakozás a szobához'; String get map_joinRoom => 'Csatlakozás a szobához';
+9
View File
@@ -1556,6 +1556,12 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Non letti: $count'; return 'Non letti: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Aprire il link?'; String get chat_openLink => 'Aprire il link?';
@@ -1721,6 +1727,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_sharedPin => 'Condividi PIN'; String get map_sharedPin => 'Condividi PIN';
@override
String get map_sharedAt => 'Condiviso';
@override @override
String get map_joinRoom => 'Unisciti alla stanza'; String get map_joinRoom => 'Unisciti alla stanza';
+9
View File
@@ -1487,6 +1487,12 @@ class AppLocalizationsJa extends AppLocalizations {
return '未読: $count'; return '未読: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'リンクを開く?'; String get chat_openLink => 'リンクを開く?';
@@ -1648,6 +1654,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get map_sharedPin => '共有パスワード'; String get map_sharedPin => '共有パスワード';
@override
String get map_sharedAt => '共有済み';
@override @override
String get map_joinRoom => '部屋に参加する'; String get map_joinRoom => '部屋に参加する';
+9
View File
@@ -1483,6 +1483,12 @@ class AppLocalizationsKo extends AppLocalizations {
return '읽지 않음: $count'; return '읽지 않음: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => '링크를 열기?'; String get chat_openLink => '링크를 열기?';
@@ -1644,6 +1650,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get map_sharedPin => '공유 비밀번호'; String get map_sharedPin => '공유 비밀번호';
@override
String get map_sharedAt => '공유됨';
@override @override
String get map_joinRoom => '방에 참여'; String get map_joinRoom => '방에 참여';
+9
View File
@@ -1543,6 +1543,12 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Nieuw: $count'; return 'Nieuw: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Link openen?'; String get chat_openLink => 'Link openen?';
@@ -1709,6 +1715,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_sharedPin => 'Gedeelde pin'; String get map_sharedPin => 'Gedeelde pin';
@override
String get map_sharedAt => 'Gedeeld';
@override @override
String get map_joinRoom => 'Kamer Toetreden'; String get map_joinRoom => 'Kamer Toetreden';
+9
View File
@@ -1567,6 +1567,12 @@ class AppLocalizationsPl extends AppLocalizations {
return 'Nieprzeczytane: $count'; return 'Nieprzeczytane: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Otworzyć link?'; String get chat_openLink => 'Otworzyć link?';
@@ -1733,6 +1739,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_sharedPin => 'Udostępniona pinezka'; String get map_sharedPin => 'Udostępniona pinezka';
@override
String get map_sharedAt => 'Udostępnione';
@override @override
String get map_joinRoom => 'Dołącz do pokoju'; String get map_joinRoom => 'Dołącz do pokoju';
+9
View File
@@ -1554,6 +1554,12 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Não lido: $count'; return 'Não lido: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Abrir link?'; String get chat_openLink => 'Abrir link?';
@@ -1721,6 +1727,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_sharedPin => 'Pin compartilhado'; String get map_sharedPin => 'Pin compartilhado';
@override
String get map_sharedAt => 'Compartilhado';
@override @override
String get map_joinRoom => 'Junte-se à Sala'; String get map_joinRoom => 'Junte-se à Sala';
+9
View File
@@ -1558,6 +1558,12 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Непрочитанных: $count'; return 'Непрочитанных: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Открыть ссылку?'; String get chat_openLink => 'Открыть ссылку?';
@@ -1724,6 +1730,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_sharedPin => 'Общая метка'; String get map_sharedPin => 'Общая метка';
@override
String get map_sharedAt => 'Поделено';
@override @override
String get map_joinRoom => 'Присоединиться к комнате'; String get map_joinRoom => 'Присоединиться к комнате';
+9
View File
@@ -1544,6 +1544,12 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Nezriadené: $count'; return 'Nezriadené: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Otvoriť odkaz?'; String get chat_openLink => 'Otvoriť odkaz?';
@@ -1710,6 +1716,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_sharedPin => 'Zdieľaný PIN'; String get map_sharedPin => 'Zdieľaný PIN';
@override
String get map_sharedAt => 'Zdieľané';
@override @override
String get map_joinRoom => 'Pripojiť miestnosť'; String get map_joinRoom => 'Pripojiť miestnosť';
+9
View File
@@ -1539,6 +1539,12 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Nerešeno: $count'; return 'Nerešeno: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Odpreti povezavo?'; String get chat_openLink => 'Odpreti povezavo?';
@@ -1705,6 +1711,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_sharedPin => 'Deljeno naslovno geslo'; String get map_sharedPin => 'Deljeno naslovno geslo';
@override
String get map_sharedAt => 'Deljeno';
@override @override
String get map_joinRoom => 'Pridružiti sobo'; String get map_joinRoom => 'Pridružiti sobo';
+9
View File
@@ -1533,6 +1533,12 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Olästa: $count'; return 'Olästa: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Öppna länk?'; String get chat_openLink => 'Öppna länk?';
@@ -1699,6 +1705,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_sharedPin => 'Delad PIN'; String get map_sharedPin => 'Delad PIN';
@override
String get map_sharedAt => 'Delad';
@override @override
String get map_joinRoom => 'Gå med i rum'; String get map_joinRoom => 'Gå med i rum';
+9
View File
@@ -1552,6 +1552,12 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Непрочитано: $count'; return 'Непрочитано: $count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => 'Відкрити посилання?'; String get chat_openLink => 'Відкрити посилання?';
@@ -1719,6 +1725,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_sharedPin => 'Спільний пін'; String get map_sharedPin => 'Спільний пін';
@override
String get map_sharedAt => 'Поділено';
@override @override
String get map_joinRoom => 'Приєднатися до кімнати'; String get map_joinRoom => 'Приєднатися до кімнати';
+9
View File
@@ -1455,6 +1455,12 @@ class AppLocalizationsZh extends AppLocalizations {
return '未读:$count'; return '未读:$count';
} }
@override
String get chat_markAsUnread => 'Mark as Unread';
@override
String get chat_newMessages => 'New messages';
@override @override
String get chat_openLink => '打开链接?'; String get chat_openLink => '打开链接?';
@@ -1616,6 +1622,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_sharedPin => '共享标记'; String get map_sharedPin => '共享标记';
@override
String get map_sharedAt => '已分享';
@override @override
String get map_joinRoom => '加入房间'; String get map_joinRoom => '加入房间';
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Toon gedeelde markeringen", "map_showSharedMarkers": "Toon gedeelde markeringen",
"map_lastSeenTime": "Laatste Bekeken Tijd", "map_lastSeenTime": "Laatste Bekeken Tijd",
"map_sharedPin": "Gedeelde pin", "map_sharedPin": "Gedeelde pin",
"map_sharedAt": "Gedeeld",
"map_joinRoom": "Kamer Toetreden", "map_joinRoom": "Kamer Toetreden",
"map_manageRepeater": "Beheer Repeater", "map_manageRepeater": "Beheer Repeater",
"mapCache_title": "Offline Kaarten Cache", "mapCache_title": "Offline Kaarten Cache",
+1
View File
@@ -692,6 +692,7 @@
"map_showSharedMarkers": "Pokaż udostępnione znaczniki.", "map_showSharedMarkers": "Pokaż udostępnione znaczniki.",
"map_lastSeenTime": "Ostatni raz widziany", "map_lastSeenTime": "Ostatni raz widziany",
"map_sharedPin": "Udostępniona pinezka", "map_sharedPin": "Udostępniona pinezka",
"map_sharedAt": "Udostępnione",
"map_joinRoom": "Dołącz do pokoju", "map_joinRoom": "Dołącz do pokoju",
"map_manageRepeater": "Zarządzaj przekaźnikiem", "map_manageRepeater": "Zarządzaj przekaźnikiem",
"mapCache_title": "Pamięć podręczna map offline", "mapCache_title": "Pamięć podręczna map offline",
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Mostrar marcadores compartilhados", "map_showSharedMarkers": "Mostrar marcadores compartilhados",
"map_lastSeenTime": "Último Tempo de Visualização", "map_lastSeenTime": "Último Tempo de Visualização",
"map_sharedPin": "Pin compartilhado", "map_sharedPin": "Pin compartilhado",
"map_sharedAt": "Compartilhado",
"map_joinRoom": "Junte-se à Sala", "map_joinRoom": "Junte-se à Sala",
"map_manageRepeater": "Gerenciar Repetidor", "map_manageRepeater": "Gerenciar Repetidor",
"mapCache_title": "Cache de Mapa Offline", "mapCache_title": "Cache de Mapa Offline",
+1
View File
@@ -397,6 +397,7 @@
"map_showSharedMarkers": "Показывать общие метки", "map_showSharedMarkers": "Показывать общие метки",
"map_lastSeenTime": "Время последнего появления", "map_lastSeenTime": "Время последнего появления",
"map_sharedPin": "Общая метка", "map_sharedPin": "Общая метка",
"map_sharedAt": "Поделено",
"map_joinRoom": "Присоединиться к комнате", "map_joinRoom": "Присоединиться к комнате",
"map_manageRepeater": "Управление репитером", "map_manageRepeater": "Управление репитером",
"mapCache_title": "Кэш офлайн-карты", "mapCache_title": "Кэш офлайн-карты",
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Zobraziť zdieľané značky", "map_showSharedMarkers": "Zobraziť zdieľané značky",
"map_lastSeenTime": "Posledný čas sledovania", "map_lastSeenTime": "Posledný čas sledovania",
"map_sharedPin": "Zdieľaný PIN", "map_sharedPin": "Zdieľaný PIN",
"map_sharedAt": "Zdieľané",
"map_joinRoom": "Pripojiť miestnosť", "map_joinRoom": "Pripojiť miestnosť",
"map_manageRepeater": "Spravovať Opakovanie", "map_manageRepeater": "Spravovať Opakovanie",
"mapCache_title": "Offline Mapa Pamäť", "mapCache_title": "Offline Mapa Pamäť",
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Pokaži skupno označenja", "map_showSharedMarkers": "Pokaži skupno označenja",
"map_lastSeenTime": "Datum zadnjega vpogleda", "map_lastSeenTime": "Datum zadnjega vpogleda",
"map_sharedPin": "Deljeno naslovno geslo", "map_sharedPin": "Deljeno naslovno geslo",
"map_sharedAt": "Deljeno",
"map_joinRoom": "Pridružiti sobo", "map_joinRoom": "Pridružiti sobo",
"map_manageRepeater": "Upravljajte Ponovitve", "map_manageRepeater": "Upravljajte Ponovitve",
"mapCache_title": "Omrezni predpomnilnik zemljeških zemljejevskih slik", "mapCache_title": "Omrezni predpomnilnik zemljeških zemljejevskih slik",
+1
View File
@@ -682,6 +682,7 @@
"map_showSharedMarkers": "Visa delade markörer", "map_showSharedMarkers": "Visa delade markörer",
"map_lastSeenTime": "Senaste Visats Tid", "map_lastSeenTime": "Senaste Visats Tid",
"map_sharedPin": "Delad PIN", "map_sharedPin": "Delad PIN",
"map_sharedAt": "Delad",
"map_joinRoom": "Gå med i rum", "map_joinRoom": "Gå med i rum",
"map_manageRepeater": "Hantera Upprepare", "map_manageRepeater": "Hantera Upprepare",
"mapCache_title": "Offline Kartcache", "mapCache_title": "Offline Kartcache",
+1
View File
@@ -683,6 +683,7 @@
"map_showSharedMarkers": "Показувати спільні маркери", "map_showSharedMarkers": "Показувати спільні маркери",
"map_lastSeenTime": "Час останньої активності", "map_lastSeenTime": "Час останньої активності",
"map_sharedPin": "Спільний пін", "map_sharedPin": "Спільний пін",
"map_sharedAt": "Поділено",
"map_joinRoom": "Приєднатися до кімнати", "map_joinRoom": "Приєднатися до кімнати",
"map_manageRepeater": "Керувати ретранслятором", "map_manageRepeater": "Керувати ретранслятором",
"mapCache_title": "Офлайн-кеш карти", "mapCache_title": "Офлайн-кеш карти",
+1
View File
@@ -709,6 +709,7 @@
"map_showSharedMarkers": "显示共享标记", "map_showSharedMarkers": "显示共享标记",
"map_lastSeenTime": "最后在线时间", "map_lastSeenTime": "最后在线时间",
"map_sharedPin": "共享标记", "map_sharedPin": "共享标记",
"map_sharedAt": "已分享",
"map_joinRoom": "加入房间", "map_joinRoom": "加入房间",
"map_manageRepeater": "管理转发节点", "map_manageRepeater": "管理转发节点",
"mapCache_title": "离线地图缓存", "mapCache_title": "离线地图缓存",
+82 -37
View File
@@ -4,7 +4,6 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -32,13 +31,19 @@ import '../widgets/message_translation_button.dart';
import '../widgets/message_status_icon.dart'; import '../widgets/message_status_icon.dart';
import '../widgets/radio_stats_entry.dart'; import '../widgets/radio_stats_entry.dart';
import '../widgets/translated_message_content.dart'; import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
final Channel channel; final Channel channel;
final int initialUnreadCount;
const ChannelChatScreen({super.key, required this.channel}); const ChannelChatScreen({
super.key,
required this.channel,
this.initialUnreadCount = 0,
});
@override @override
State<ChannelChatScreen> createState() => _ChannelChatScreenState(); State<ChannelChatScreen> createState() => _ChannelChatScreenState();
@@ -55,32 +60,42 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt; DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false; bool _channelSkipNextBottomSnap = false;
String? _unreadDividerMessageId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange); _textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().settings; final settings = context.read<AppSettingsService>().settings;
final idx = widget.channel.index; final idx = widget.channel.index;
final unread = connector.getUnreadCountForChannelIndex(idx); final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel);
ChannelMessage? anchor; ChannelMessage? anchor;
if (settings.jumpToOldestUnread && unread > 0) { if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor( anchor = _findOldestUnreadChannelAnchor(messages, unread);
connector.getChannelMessages(widget.channel),
unread,
);
} }
setState(() {
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
});
connector.setActiveChannel(idx); connector.setActiveChannel(idx);
_connector = connector; _connector = connector;
if (anchor != null) { if (anchor != null && settings.jumpToOldestUnread) {
_channelSkipNextBottomSnap = true; _channelSkipNextBottomSnap = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; 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; return oldest;
} }
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() { void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) { if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen(); _scrollController.handleKeyboardOpen();
@@ -123,6 +145,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override @override
void dispose() { void dispose() {
_connector?.setActiveChannel(null); _connector?.setActiveChannel(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
@@ -321,6 +344,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (!_messageKeys.containsKey(message.messageId)) { if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey(); _messageKeys[message.messageId] = GlobalKey();
} }
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
return Container( return Container(
key: _messageKeys[message.messageId]!, key: _messageKeys[message.messageId]!,
child: Builder( child: Builder(
@@ -329,10 +355,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.select<ChatTextScaleService, double>( .select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
return _buildMessageBubble( final bubble = _buildMessageBubble(
message, message,
textScale, 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) { Widget _buildMessageBubble(ChannelMessage message, double textScale) {
final settingsService = context.watch<AppSettingsService>(); final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing; final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing; final isOutgoing = message.isOutgoing;
final gifId = GifHelper.parseGif(message.text); final gifId = GifHelper.parseGif(message.text);
final poi = _parsePoiMessage(message.text); final poi = parseMarkerText(message.text);
final translatedDisplayText = final translatedDisplayText =
message.translatedText != null && message.translatedText != null &&
message.translatedText!.trim().isNotEmpty message.translatedText!.trim().isNotEmpty
@@ -445,6 +490,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
poi, poi,
isOutgoing, isOutgoing,
textScale, textScale,
message.senderName,
trailing: (!enableTracing && isOutgoing) trailing: (!enableTracing && isOutgoing)
? Padding( ? Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
@@ -701,7 +747,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7); final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7);
final gifId = GifHelper.parseGif(replyText); final gifId = GifHelper.parseGif(replyText);
final poi = _parsePoiMessage(replyText); final poi = parseMarkerText(replyText);
Widget contentPreview; Widget contentPreview;
if (gifId != null) { 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( Widget _buildPoiMessage(
BuildContext context, BuildContext context,
_PoiInfo poi, MarkerPayload poi,
bool isOutgoing, bool isOutgoing,
double textScale, { double textScale,
String senderName, {
Widget? trailing, Widget? trailing,
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -849,12 +883,22 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () { 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( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MapScreen( builder: (context) => MapScreen(
highlightPosition: LatLng(poi.lat, poi.lon), highlightPosition: poi.position,
highlightLabel: poi.label, highlightLabel: poi.label,
highlightMarkerKey: key,
), ),
), ),
); );
@@ -1288,6 +1332,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_copyMessageText(message.text); _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( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete), 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 { onTap: () async {
final unread =
connector.getUnreadCountForChannelIndex(channel.index);
connector.markChannelRead(channel.index); connector.markChannelRead(channel.index);
await Future.delayed(const Duration(milliseconds: 50)); await Future.delayed(const Duration(milliseconds: 50));
if (context.mounted) { if (context.mounted) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( 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 'package:provider/provider.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -44,12 +43,18 @@ import '../widgets/translated_message_content.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import '../widgets/unread_divider.dart';
import 'telemetry_screen.dart'; import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final Contact contact; final Contact contact;
final int initialUnreadCount;
const ChatScreen({super.key, required this.contact}); const ChatScreen({
super.key,
required this.contact,
this.initialUnreadCount = 0,
});
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
@@ -63,6 +68,7 @@ class _ChatScreenState extends State<ChatScreen> {
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
Message? _pendingUnreadScrollTarget; Message? _pendingUnreadScrollTarget;
String? _unreadDividerMessageId;
DateTime? _lastTextSendAt; DateTime? _lastTextSendAt;
@override @override
@@ -70,34 +76,47 @@ class _ChatScreenState extends State<ChatScreen> {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange); _textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().settings; final settings = context.read<AppSettingsService>().settings;
final keyHex = widget.contact.publicKeyHex; final keyHex = widget.contact.publicKeyHex;
final unread = connector.getUnreadCountForContactKey(keyHex); final unread = widget.initialUnreadCount;
final messages = connector.getMessages(widget.contact);
Message? anchor; Message? anchor;
if (settings.jumpToOldestUnread && unread > 0) { if (unread > 0) {
anchor = _findOldestUnreadAnchor( anchor = _findOldestUnreadAnchor(messages, unread);
connector.getMessages(widget.contact),
unread,
);
} }
setState(() {
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
if (anchor != null && settings.jumpToOldestUnread) {
_pendingUnreadScrollTarget = anchor;
}
});
connector.setActiveContact(keyHex); connector.setActiveContact(keyHex);
_connector = connector; _connector = connector;
if (anchor != null) { if (anchor != null && settings.jumpToOldestUnread) {
setState(() => _pendingUnreadScrollTarget = anchor);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final ctx = _unreadScrollKey.currentContext; _scrollController.jumpToEstimatedOffset(
if (ctx != null) { unreadCount: unread,
Scrollable.ensureVisible( totalMessages: messages.length,
ctx, onJumped: () async {
duration: const Duration(milliseconds: 350), if (!mounted) return;
alignment: 0.15, final ctx = _unreadScrollKey.currentContext;
); if (ctx != null) {
} await Scrollable.ensureVisible(
setState(() => _pendingUnreadScrollTarget = null); 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; return oldest;
} }
void _clearDividerAtBottom() {
if (!_scrollController.showJumpToBottom.value &&
_unreadDividerMessageId != null) {
setState(() => _unreadDividerMessageId = null);
}
}
void _onTextFieldFocusChange() { void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) { if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen(); _scrollController.handleKeyboardOpen();
@@ -137,6 +163,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void dispose() { void dispose() {
_connector?.setActiveContact(null); _connector?.setActiveContact(null);
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
@@ -479,6 +506,7 @@ class _ChatScreenState extends State<ChatScreen> {
senderName: resolvedContact.type == advTypeRoom senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
sourceId: widget.contact.publicKeyHex,
isRoomServer: resolvedContact.type == advTypeRoom, isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
@@ -486,10 +514,19 @@ class _ChatScreenState extends State<ChatScreen> {
onRetryReaction: (msg, emoji) => onRetryReaction: (msg, emoji) =>
_sendReaction(msg, contact, 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)) { 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) { Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes(); final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -1320,11 +1369,15 @@ class _ChatScreenState extends State<ChatScreen> {
} }
void _openChat(BuildContext context, Contact contact) { void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater final connector = context.read<MeshCoreConnector>();
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex); final unread = connector.getUnreadCountForContactKey(contact.publicKeyHex);
connector.markContactRead(contact.publicKeyHex);
Navigator.push( Navigator.push(
context, 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); _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( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete), title: Text(context.l10n.common_delete),
@@ -1568,10 +1630,12 @@ class _MessageBubble extends StatelessWidget {
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final void Function(Message message, String emoji)? onRetryReaction; final void Function(Message message, String emoji)? onRetryReaction;
final double textScale; final double textScale;
final String sourceId;
const _MessageBubble({ const _MessageBubble({
required this.message, required this.message,
required this.senderName, required this.senderName,
required this.sourceId,
required this.isRoomServer, required this.isRoomServer,
required this.textScale, required this.textScale,
this.onTap, this.onTap,
@@ -1586,7 +1650,7 @@ class _MessageBubble extends StatelessWidget {
final isOutgoing = message.isOutgoing; final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text); final gifId = GifHelper.parseGif(message.text);
final poi = _parsePoiMessage(message.text); final poi = parseMarkerText(message.text);
final isFailed = message.status == MessageStatus.failed; final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed final bubbleColor = isFailed
? colorScheme.errorContainer ? colorScheme.errorContainer
@@ -1678,6 +1742,7 @@ class _MessageBubble extends StatelessWidget {
textColor, textColor,
metaColor, metaColor,
textScale, textScale,
senderName,
trailing: (!enableTracing && isOutgoing) trailing: (!enableTracing && isOutgoing)
? Padding( ? Padding(
padding: const EdgeInsets.only(bottom: 2), 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( Widget _buildPoiMessage(
BuildContext context, BuildContext context,
_PoiInfo poi, MarkerPayload poi,
Color textColor, Color textColor,
Color metaColor, Color metaColor,
double textScale, { double textScale,
String senderName, {
Widget? trailing, Widget? trailing,
}) { }) {
return Row( return Row(
@@ -1887,13 +1940,23 @@ class _MessageBubble extends StatelessWidget {
icon: Icon(Icons.location_on_outlined, color: textColor), icon: Icon(Icons.location_on_outlined, color: textColor),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), 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( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MapScreen( builder: (context) => MapScreen(
highlightPosition: LatLng(poi.lat, poi.lon), highlightPosition: poi.position,
highlightLabel: poi.label, highlightLabel: poi.label,
highlightMarkerKey: key,
), ),
), ),
); );
@@ -2074,11 +2137,3 @@ class _MessageBubble extends StatelessWidget {
return '$hour:$minute'; 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) { } else if (contact.type == advTypeRoom) {
_showRoomLogin(context, contact, RoomLoginDestination.chat); _showRoomLogin(context, contact, RoomLoginDestination.chat);
} else { } else {
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex); final connector = context.read<MeshCoreConnector>();
final unread =
connector.getUnreadCountForContactKey(contact.publicKeyHex);
connector.markContactRead(contact.publicKeyHex);
Navigator.push( Navigator.push(
context, 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( builder: (context) => RoomLoginDialog(
room: room, room: room,
onLogin: (password, isAdmin) { 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( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -999,7 +1010,10 @@ class _ContactsScreenState extends State<ContactsScreen>
password: password, password: password,
isAdmin: isAdmin, 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 { class MapScreen extends StatefulWidget {
final LatLng? highlightPosition; final LatLng? highlightPosition;
final String? highlightLabel; final String? highlightLabel;
final String? highlightMarkerKey;
final double highlightZoom; final double highlightZoom;
final bool hideBackButton; final bool hideBackButton;
@@ -44,6 +45,7 @@ class MapScreen extends StatefulWidget {
super.key, super.key,
this.highlightPosition, this.highlightPosition,
this.highlightLabel, this.highlightLabel,
this.highlightMarkerKey,
this.highlightZoom = 15.0, this.highlightZoom = 15.0,
this.hideBackButton = false, this.hideBackButton = false,
}); });
@@ -94,6 +96,19 @@ class _MapScreenState extends State<MapScreen> {
_removedMarkerIds = ids; _removedMarkerIds = ids;
_removedMarkersLoaded = true; _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) { bool _checkLocationPlausibility(double lat, double lon) {
@@ -229,6 +244,24 @@ class _MapScreenState extends State<MapScreen> {
: <Polyline>[], : <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) // Calculate center and zoom of all nodes, or default to (0, 0)
LatLng center = const LatLng(0, 0); LatLng center = const LatLng(0, 0);
double initialZoom = 10.0; double initialZoom = 10.0;
@@ -475,6 +508,8 @@ class _MapScreenState extends State<MapScreen> {
), ),
if (_polylines.isNotEmpty && _isBuildingPathTrace) if (_polylines.isNotEmpty && _isBuildingPathTrace)
PolylineLayer(polylines: _polylines), PolylineLayer(polylines: _polylines),
if (sharedMarkerPolylines.isNotEmpty)
PolylineLayer(polylines: sharedMarkerPolylines),
MarkerLayer( MarkerLayer(
markers: [ markers: [
if (highlightPosition != null) if (highlightPosition != null)
@@ -1239,28 +1274,39 @@ class _MapScreenState extends State<MapScreen> {
} }
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) { 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'; final selfName = connector.selfName ?? 'Me';
void addUpdate(_SharedMarker update) {
(updatesByKey[update.id] ??= <_SharedMarker>[]).add(update);
}
for (final contact in connector.contacts) { for (final contact in connector.contacts) {
final messages = connector.getMessages(contact); final messages = connector.getMessages(contact);
for (final message in messages) { for (final message in messages) {
final payload = _parseMarkerText(message.text); final payload = parseMarkerText(message.text);
if (payload == null) continue; if (payload == null) continue;
final fromName = message.isOutgoing ? selfName : contact.name; final fromName = message.isOutgoing ? selfName : contact.name;
final id = _buildMarkerId( final key = buildSharedMarkerKey(
sourceId: contact.publicKeyHex, sourceId: contact.publicKeyHex,
timestamp: message.timestamp, label: payload.label,
text: message.text, fromName: fromName,
flags: payload.flags,
isChannel: false,
); );
markers.add( addUpdate(
_SharedMarker( _SharedMarker(
id: id, id: key,
position: payload.position, position: payload.position,
label: payload.label, label: payload.label.isEmpty
? context.l10n.map_sharedPin
: payload.label,
flags: payload.flags, flags: payload.flags,
fromName: fromName, fromName: fromName,
sourceLabel: contact.name, sourceLabel: contact.name,
timestamp: message.timestamp,
isChannel: false, isChannel: false,
isPublicChannel: false, isPublicChannel: false,
), ),
@@ -1272,23 +1318,28 @@ class _MapScreenState extends State<MapScreen> {
final isPublic = _isPublicChannel(channel); final isPublic = _isPublicChannel(channel);
final messages = connector.getChannelMessages(channel); final messages = connector.getChannelMessages(channel);
for (final message in messages) { for (final message in messages) {
final payload = _parseMarkerText(message.text); final payload = parseMarkerText(message.text);
if (payload == null) continue; if (payload == null) continue;
final id = _buildMarkerId( final key = buildSharedMarkerKey(
sourceId: 'channel:${channel.index}', sourceId: 'channel:${channel.index}',
timestamp: message.timestamp, label: payload.label,
text: message.text, fromName: message.senderName,
flags: payload.flags,
isChannel: true,
); );
markers.add( addUpdate(
_SharedMarker( _SharedMarker(
id: id, id: key,
position: payload.position, position: payload.position,
label: payload.label, label: payload.label.isEmpty
? context.l10n.map_sharedPin
: payload.label,
flags: payload.flags, flags: payload.flags,
fromName: message.senderName, fromName: message.senderName,
sourceLabel: channel.name.isEmpty sourceLabel: channel.name.isEmpty
? 'Channel ${channel.index}' ? 'Channel ${channel.index}'
: channel.name, : channel.name,
timestamp: message.timestamp,
isChannel: true, isChannel: true,
isPublicChannel: isPublic, 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; 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) { Marker _buildSharedMarker(_SharedMarker marker) {
final markerColor = marker.isChannel final markerColor = marker.isChannel
? (marker.isPublicChannel ? Colors.orange : Colors.purple) ? (marker.isPublicChannel ? Colors.orange : Colors.purple)
@@ -1337,7 +1377,15 @@ class _MapScreenState extends State<MapScreen> {
width: 60, width: 60,
height: 60, height: 60,
child: GestureDetector( 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( child: Column(
children: [ children: [
Container( Container(
@@ -1391,11 +1439,17 @@ class _MapScreenState extends State<MapScreen> {
room: room, room: room,
// onLogin(password, isAdmin) isAdmin not used for room caht screen // onLogin(password, isAdmin) isAdmin not used for room caht screen
onLogin: (password, _) { onLogin: (password, _) {
// Navigate to chat screen after successful login final connector = context.read<MeshCoreConnector>();
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex); final unread = connector.getUnreadCountForContactKey(
room.publicKeyHex,
);
connector.markContactRead(room.publicKeyHex);
Navigator.push( Navigator.push(
context, 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) { if (!contact.isActive) {
connector.importDiscoveredContact(contact); connector.importDiscoveredContact(contact);
} }
final unread = connector.getUnreadCountForContactKey(
contact.publicKeyHex,
);
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ChatScreen(contact: contact), builder: (context) => ChatScreen(
contact: contact,
initialUnreadCount: unread,
),
), ),
); );
}, },
@@ -1542,13 +1602,19 @@ class _MapScreenState extends State<MapScreen> {
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: Text(marker.label), title: Text(
marker.label.isEmpty ? context.l10n.map_sharedPin : marker.label,
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildInfoRow(context.l10n.map_from, marker.fromName), _buildInfoRow(context.l10n.map_from, marker.fromName),
_buildInfoRow(context.l10n.map_source, marker.sourceLabel), _buildInfoRow(context.l10n.map_source, marker.sourceLabel),
_buildInfoRow(
context.l10n.map_sharedAt,
_formatLastSeen(marker.timestamp),
),
_buildInfoRow( _buildInfoRow(
'Location', 'Location',
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}', '${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
@@ -1715,6 +1781,10 @@ class _MapScreenState extends State<MapScreen> {
String defaultLabel, String defaultLabel,
) async { ) async {
final controller = TextEditingController(text: defaultLabel); final controller = TextEditingController(text: defaultLabel);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
return showDialog<String>( return showDialog<String>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -2310,18 +2380,50 @@ class _GuessedLocation {
}); });
} }
class _MarkerPayload { class MarkerPayload {
final LatLng position; final LatLng position;
final String label; final String label;
final String flags; final String flags;
_MarkerPayload({ MarkerPayload({
required this.position, required this.position,
required this.label, required this.label,
required this.flags, 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 { class _SharedMarker {
final String id; final String id;
final LatLng position; final LatLng position;
@@ -2329,8 +2431,10 @@ class _SharedMarker {
final String flags; final String flags;
final String fromName; final String fromName;
final String sourceLabel; final String sourceLabel;
final DateTime timestamp;
final bool isChannel; final bool isChannel;
final bool isPublicChannel; final bool isPublicChannel;
final List<LatLng> history;
_SharedMarker({ _SharedMarker({
required this.id, required this.id,
@@ -2339,7 +2443,24 @@ class _SharedMarker {
required this.flags, required this.flags,
required this.fromName, required this.fromName,
required this.sourceLabel, required this.sourceLabel,
required this.timestamp,
required this.isChannel, required this.isChannel,
required this.isPublicChannel, 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 list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial flserial
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
-1
View File
@@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial flserial
flutter_local_notifications_windows flutter_local_notifications_windows
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)