From 8668564464d577ba14958f45fc1031ddc4793c9b Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Mon, 9 Feb 2026 12:56:38 +0200 Subject: [PATCH 01/38] Correct unread badges for tabs; people first contacts sort option --- lib/connector/meshcore_connector.dart | 19 ++++++++-- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_bg.dart | 3 ++ lib/l10n/app_localizations_de.dart | 3 ++ lib/l10n/app_localizations_en.dart | 3 ++ lib/l10n/app_localizations_es.dart | 3 ++ lib/l10n/app_localizations_fr.dart | 3 ++ lib/l10n/app_localizations_it.dart | 3 ++ lib/l10n/app_localizations_nl.dart | 3 ++ lib/l10n/app_localizations_pl.dart | 3 ++ lib/l10n/app_localizations_pt.dart | 3 ++ lib/l10n/app_localizations_ru.dart | 3 ++ lib/l10n/app_localizations_sk.dart | 3 ++ lib/l10n/app_localizations_sl.dart | 3 ++ lib/l10n/app_localizations_sv.dart | 3 ++ lib/l10n/app_localizations_uk.dart | 11 ++++-- lib/l10n/app_localizations_zh.dart | 3 ++ lib/l10n/app_uk.arb | 9 +++-- lib/screens/channels_screen.dart | 2 + lib/screens/contacts_screen.dart | 37 +++++++++++++++--- lib/screens/device_screen.dart | 6 ++- lib/screens/map_screen.dart | 2 + lib/widgets/list_filter_widget.dart | 13 +++++++ lib/widgets/quick_switch_bar.dart | 35 ++++++++++++++++- untranslated.json | 54 ++++++++++++++++++++++++++- 26 files changed, 215 insertions(+), 22 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 2c56c373..b96aef98 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -336,15 +336,26 @@ class MeshCoreConnector extends ChangeNotifier { } int getTotalUnreadCount() { + if (!_unreadStateLoaded) return 0; + return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount(); + } + + int getTotalContactsUnreadCount() { if (!_unreadStateLoaded) return 0; var total = 0; - // Count unread contact messages for (final contact in _contacts) { total += getUnreadCountForContact(contact); } - // Count unread channel messages - for (final channelIndex in _channelMessages.keys) { - total += getUnreadCountForChannelIndex(channelIndex); + return total; + } + + int getTotalChannelsUnreadCount() { + if (!_unreadStateLoaded) return 0; + var total = 0; + // Check all channels (from _channels or _cachedChannels) + final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels; + for (final channel in allChannels) { + total += channel.unreadCount; } return total; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ee5cf7d6..c91345d7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1304,6 +1304,7 @@ "listFilter_latestMessages": "Latest messages", "listFilter_heardRecently": "Heard recently", "listFilter_az": "A-Z", + "listFilter_usersFirst": "Users first", "listFilter_filters": "Filters", "listFilter_all": "All", "listFilter_users": "Users", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 055667fa..33aa4032 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4658,6 +4658,12 @@ abstract class AppLocalizations { /// **'A-Z'** String get listFilter_az; + /// No description provided for @listFilter_usersFirst. + /// + /// In en, this message translates to: + /// **'Users first'** + String get listFilter_usersFirst; + /// No description provided for @listFilter_filters. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 701429e6..e373a156 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2662,6 +2662,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Филтри'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 514a7a19..779a5344 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2666,6 +2666,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtere'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 040d8090..29f83919 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2622,6 +2622,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filters'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e65cbcd3..7f61ee25 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2661,6 +2661,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtros'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 4496fc84..d858934e 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2678,6 +2678,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get listFilter_az => 'A à Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtres'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 02345c4e..9786262d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2661,6 +2661,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtri'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 292181fc..74f2abae 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2652,6 +2652,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filters'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 08323295..13bb1bf4 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2660,6 +2660,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtry'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index eadea3bc..c555be3b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2663,6 +2663,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtros'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index ec0f1ba3..6e3a9d7c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2665,6 +2665,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get listFilter_az => 'По алфавиту'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Фильтры'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 346047b0..65e41fcd 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -2648,6 +2648,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtre'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index ed711224..c3c46aed 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2651,6 +2651,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filtri'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 97b849fa..f8652ae2 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -2636,6 +2636,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => 'Filteralternativ'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 899d540d..cfb88d58 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2672,6 +2672,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get listFilter_az => 'А-Я'; + @override + String get listFilter_usersFirst => 'Спочатку користувачі'; + @override String get listFilter_filters => 'Фільтри'; @@ -2703,7 +2706,7 @@ class AppLocalizationsUk extends AppLocalizations { String get pathTrace_notAvailable => 'Трасування шляху недоступне.'; @override - String get pathTrace_refreshTooltip => 'Оновити Path Trace'; + String get pathTrace_refreshTooltip => 'Оновити трасування шляху'; @override String get contacts_pathTrace => 'Трасування шляхів'; @@ -2744,10 +2747,10 @@ class AppLocalizationsUk extends AppLocalizations { String get contacts_contactImportFailed => 'Контакт не вдалося імпортувати'; @override - String get contacts_zeroHopAdvert => 'Реклама без перехоплення'; + String get contacts_zeroHopAdvert => 'Оголошення без ретрансляції'; @override - String get contacts_floodAdvert => 'Залив реклами'; + String get contacts_floodAdvert => 'Оголошення з ретрансляцією'; @override String get contacts_copyAdvertToClipboard => @@ -2774,7 +2777,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_contactAdvertCopied => - 'Рекламу скопійовано до буфера обміну.'; + 'Оголошення скопійовано до буфера обміну.'; @override String get contacts_contactAdvertCopyFailed => diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 77467924..d6dc2ff4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2521,6 +2521,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get listFilter_az => 'A 到 Z'; + @override + String get listFilter_usersFirst => 'Users first'; + @override String get listFilter_filters => '过滤器'; diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 3362d409..35edeee3 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1343,6 +1343,7 @@ "listFilter_latestMessages": "Останні повідомлення", "listFilter_heardRecently": "Нещодавно чули", "listFilter_az": "А-Я", + "listFilter_usersFirst": "Спочатку користувачі", "listFilter_filters": "Фільтри", "listFilter_all": "Все", "listFilter_users": "Користувачі", @@ -1545,7 +1546,7 @@ "pathTrace_you": "Ви", "pathTrace_failed": "Відстеження шляху не вдалося.", "pathTrace_notAvailable": "Трасування шляху недоступне.", - "pathTrace_refreshTooltip": "Оновити Path Trace", + "pathTrace_refreshTooltip": "Оновити трасування шляху", "contacts_pathTrace": "Трасування шляхів", "contacts_ping": "Пінгувати", "contacts_repeaterPathTrace": "Трасування шляху до повторювача", @@ -1557,14 +1558,14 @@ "contacts_invalidAdvertFormat": "Недійсні контактні дані", "contacts_contactImported": "Контакт було імпортовано.", "contacts_contactImportFailed": "Контакт не вдалося імпортувати", - "contacts_zeroHopAdvert": "Реклама без перехоплення", - "contacts_floodAdvert": "Залив реклами", + "contacts_zeroHopAdvert": "Оголошення без ретрансляції", + "contacts_floodAdvert": "Оголошення з ретрансляцією", "contacts_copyAdvertToClipboard": "Копіювати оголошення в буфер обміну", "contacts_clipboardEmpty": "Буфер обміну порожній", "appSettings_languageRu": "Російська", "contacts_ShareContact": "Копіювати контакт у буфер обміну", "contacts_zeroHopContactAdvertFailed": "Не вдалося надіслати контакт.", - "contacts_contactAdvertCopied": "Рекламу скопійовано до буфера обміну.", + "contacts_contactAdvertCopied": "Оголошення скопійовано до буфера обміну.", "contacts_contactAdvertCopyFailed": "Копіювання оголошення в буфер обміну завершилося невдало", "contacts_zeroHopContactAdvertSent": "Відправлено контакт за оголошенням", "contacts_addContactFromClipboard": "Додати контакт з буфера обміну", diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 6b8b92d4..6d8ef038 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -343,6 +343,8 @@ class _ChannelsScreenState extends State selectedIndex: 1, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index f04bc502..ac157356 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -48,6 +48,7 @@ class _ContactsScreenState extends State String _searchQuery = ''; ContactSortOption _sortOption = ContactSortOption.lastSeen; bool _showUnreadOnly = false; + bool _prioritizePeople = true; ContactTypeFilter _typeFilter = ContactTypeFilter.all; final ContactGroupStore _groupStore = ContactGroupStore(); List _groups = []; @@ -332,6 +333,8 @@ class _ContactsScreenState extends State selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), @@ -350,6 +353,7 @@ class _ContactsScreenState extends State sortOption: _sortOption, typeFilter: _typeFilter, showUnreadOnly: _showUnreadOnly, + prioritizePeople: _prioritizePeople, onSortChanged: (value) { setState(() { _sortOption = value; @@ -365,6 +369,11 @@ class _ContactsScreenState extends State _showUnreadOnly = value; }); }, + onPrioritizePeopleChanged: (value) { + setState(() { + _prioritizePeople = value; + }); + }, onNewGroup: () => _showGroupEditor(context, connector.contacts), ); } @@ -545,14 +554,34 @@ class _ContactsScreenState extends State }).toList(); } + // Apply sorting within groups if prioritizing people + if (_prioritizePeople) { + // Separate people (advTypeChat) from others + final people = filtered.where((c) => c.type == advTypeChat).toList(); + final others = filtered.where((c) => c.type != advTypeChat).toList(); + + // Sort each group separately + _applySorting(people, connector); + _applySorting(others, connector); + + // Combine: people first, then others + filtered = [...people, ...others]; + } else { + _applySorting(filtered, connector); + } + + return filtered; + } + + void _applySorting(List contacts, MeshCoreConnector connector) { switch (_sortOption) { case ContactSortOption.lastSeen: - filtered.sort( + contacts.sort( (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), ); break; case ContactSortOption.recentMessages: - filtered.sort((a, b) { + contacts.sort((a, b) { final aMessages = connector.getMessages(a); final bMessages = connector.getMessages(b); final aLastMsg = aMessages.isEmpty @@ -565,13 +594,11 @@ class _ContactsScreenState extends State }); break; case ContactSortOption.name: - filtered.sort( + contacts.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); break; } - - return filtered; } bool _matchesTypeFilter(Contact contact) { diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart index c5967cf4..c4a5310b 100644 --- a/lib/screens/device_screen.dart +++ b/lib/screens/device_screen.dart @@ -71,7 +71,7 @@ class _DeviceScreenState extends State const SizedBox(height: 16), _buildSectionLabel(theme, context.l10n.device_quickSwitch), const SizedBox(height: 12), - _buildQuickSwitchBar(context), + _buildQuickSwitchBar(context, connector), ], ), ), @@ -196,12 +196,14 @@ class _DeviceScreenState extends State ); } - Widget _buildQuickSwitchBar(BuildContext context) { + Widget _buildQuickSwitchBar(BuildContext context, MeshCoreConnector connector) { return QuickSwitchBar( selectedIndex: _quickIndex, onDestinationSelected: (index) { _openQuickDestination(index, context); }, + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 0da99601..4253391f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -362,6 +362,8 @@ class _MapScreenState extends State { selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), floatingActionButton: FloatingActionButton( diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index e9c0d9e8..6075fecb 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -99,14 +99,17 @@ const int _actionFilterRepeaters = 6; const int _actionFilterRooms = 7; const int _actionToggleUnreadOnly = 8; const int _actionNewGroup = 9; +const int _actionTogglePrioritizePeople = 10; class ContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; final ContactTypeFilter typeFilter; final bool showUnreadOnly; + final bool prioritizePeople; final ValueChanged onSortChanged; final ValueChanged onTypeFilterChanged; final ValueChanged onUnreadOnlyChanged; + final ValueChanged onPrioritizePeopleChanged; final VoidCallback onNewGroup; const ContactsFilterMenu({ @@ -114,9 +117,11 @@ class ContactsFilterMenu extends StatelessWidget { required this.sortOption, required this.typeFilter, required this.showUnreadOnly, + required this.prioritizePeople, required this.onSortChanged, required this.onTypeFilterChanged, required this.onUnreadOnlyChanged, + required this.onPrioritizePeopleChanged, required this.onNewGroup, }); @@ -144,6 +149,11 @@ class ContactsFilterMenu extends StatelessWidget { label: l10n.listFilter_az, checked: sortOption == ContactSortOption.name, ), + SortFilterMenuOption( + value: _actionTogglePrioritizePeople, + label: l10n.listFilter_usersFirst, + checked: prioritizePeople, + ), ], ), SortFilterMenuSection( @@ -192,6 +202,9 @@ class ContactsFilterMenu extends StatelessWidget { case _actionSortLastSeen: onSortChanged(ContactSortOption.lastSeen); break; + case _actionTogglePrioritizePeople: + onPrioritizePeopleChanged(!prioritizePeople); + break; case _actionFilterAll: onTypeFilterChanged(ContactTypeFilter.all); break; diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index 134091ff..78874fdf 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -6,11 +6,15 @@ import '../l10n/l10n.dart'; class QuickSwitchBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; + final int contactsUnreadCount; + final int channelsUnreadCount; const QuickSwitchBar({ super.key, required this.selectedIndex, required this.onDestinationSelected, + this.contactsUnreadCount = 0, + this.channelsUnreadCount = 0, }); @override @@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget { onDestinationSelected: onDestinationSelected, destinations: [ NavigationDestination( - icon: const Icon(Icons.people_outline), + icon: _buildIconWithBadge( + const Icon(Icons.people_outline), + contactsUnreadCount, + ), + selectedIcon: _buildIconWithBadge( + const Icon(Icons.people), + contactsUnreadCount, + ), label: context.l10n.nav_contacts, ), NavigationDestination( - icon: const Icon(Icons.tag), + icon: _buildIconWithBadge( + const Icon(Icons.tag), + channelsUnreadCount, + ), + selectedIcon: _buildIconWithBadge( + const Icon(Icons.tag), + channelsUnreadCount, + ), label: context.l10n.nav_channels, ), NavigationDestination( icon: const Icon(Icons.map_outlined), + selectedIcon: const Icon(Icons.map), label: context.l10n.nav_map, ), ], @@ -81,4 +100,16 @@ class QuickSwitchBar extends StatelessWidget { ), ); } + + Widget _buildIconWithBadge(Icon icon, int count) { + if (count <= 0) return icon; + + return Badge( + label: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle(fontSize: 10), + ), + child: icon, + ); + } } diff --git a/untranslated.json b/untranslated.json index 9e26dfee..29112d61 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,53 @@ -{} \ No newline at end of file +{ + "bg": [ + "listFilter_usersFirst" + ], + + "de": [ + "listFilter_usersFirst" + ], + + "es": [ + "listFilter_usersFirst" + ], + + "fr": [ + "listFilter_usersFirst" + ], + + "it": [ + "listFilter_usersFirst" + ], + + "nl": [ + "listFilter_usersFirst" + ], + + "pl": [ + "listFilter_usersFirst" + ], + + "pt": [ + "listFilter_usersFirst" + ], + + "ru": [ + "listFilter_usersFirst" + ], + + "sk": [ + "listFilter_usersFirst" + ], + + "sl": [ + "listFilter_usersFirst" + ], + + "sv": [ + "listFilter_usersFirst" + ], + + "zh": [ + "listFilter_usersFirst" + ] +} From fd305fd55bcaf0b562c21b2b8b9bc773bf07e319 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Mon, 9 Feb 2026 13:19:31 +0200 Subject: [PATCH 02/38] Update generated plugin registrants after merge --- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 50 +------------------ .../flutter/generated_plugin_registrant.cc | 3 -- windows/flutter/generated_plugins.cmake | 1 - 4 files changed, 1 insertion(+), 55 deletions(-) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d2ea57e9..b4a41dd1 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import flutter_blue_plus_darwin import flutter_local_notifications import mobile_scanner import package_info_plus -import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos @@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index fc116566..1e275d4f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,14 +121,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" - url: "https://pub.dev" - source: hosted - version: "0.3.5+2" crypto: dependency: "direct main" description: @@ -349,14 +341,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - gpx: - dependency: "direct main" - description: - name: gpx - sha256: f5b12b86402c639079243600ee2b3afd85cd08d26117fc8885cf48efce471d8e - url: "https://pub.dev" - source: hosted - version: "2.3.0" hooks: dependency: transitive description: @@ -517,14 +501,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" mobile_scanner: dependency: "direct main" description: @@ -590,7 +566,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: "direct main" + dependency: transitive description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -725,14 +701,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" rxdart: dependency: transitive description: @@ -741,22 +709,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - share_plus: - dependency: "direct main" - description: - name: share_plus - sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" - url: "https://pub.dev" - source: hosted - version: "12.0.1" - share_plus_platform_interface: - dependency: transitive - description: - name: share_plus_platform_interface - sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" - url: "https://pub.dev" - source: hosted - version: "6.1.0" shared_preferences: dependency: "direct main" description: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index cd4fc19b..eeb548fa 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,14 +7,11 @@ #include "generated_plugin_registrant.h" #include -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterBluePlusPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); - SharePlusWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 571addb8..68825d8b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,7 +4,6 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_blue_plus_winrt - share_plus url_launcher_windows ) From 2bce14224d9d756df8571054d48d657b543bc913 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Mon, 9 Feb 2026 16:27:53 +0200 Subject: [PATCH 03/38] Update generated plugin registrants after merge --- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 50 ++++++++++++++++++- .../flutter/generated_plugin_registrant.cc | 3 ++ windows/flutter/generated_plugins.cmake | 1 + 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b4a41dd1..d2ea57e9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import flutter_blue_plus_darwin import flutter_local_notifications import mobile_scanner import package_info_plus +import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 1e275d4f..fc116566 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: "direct main" description: @@ -341,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + gpx: + dependency: "direct main" + description: + name: gpx + sha256: f5b12b86402c639079243600ee2b3afd85cd08d26117fc8885cf48efce471d8e + url: "https://pub.dev" + source: hosted + version: "2.3.0" hooks: dependency: transitive description: @@ -501,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" mobile_scanner: dependency: "direct main" description: @@ -566,7 +590,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -701,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" rxdart: dependency: transitive description: @@ -709,6 +741,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" + url: "https://pub.dev" + source: hosted + version: "12.0.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: "direct main" description: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index eeb548fa..cd4fc19b 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterBluePlusPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 68825d8b..571addb8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_blue_plus_winrt + share_plus url_launcher_windows ) From c4f5c7b1717265b430530dbee8f2782a9790cdb5 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Mon, 9 Feb 2026 17:18:21 +0200 Subject: [PATCH 04/38] Cache for unread total --- lib/connector/meshcore_connector.dart | 37 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b96aef98..9483a202 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -150,6 +150,8 @@ class MeshCoreConnector extends ChangeNotifier { final Set _knownContactKeys = {}; final Map _contactUnreadCount = {}; bool _unreadStateLoaded = false; + int _cachedContactsUnreadTotal = 0; + int _cachedChannelsUnreadTotal = 0; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; int? _activeChannelIndex; @@ -342,22 +344,28 @@ class MeshCoreConnector extends ChangeNotifier { int getTotalContactsUnreadCount() { if (!_unreadStateLoaded) return 0; - var total = 0; - for (final contact in _contacts) { - total += getUnreadCountForContact(contact); - } - return total; + return _cachedContactsUnreadTotal; } int getTotalChannelsUnreadCount() { if (!_unreadStateLoaded) return 0; - var total = 0; - // Check all channels (from _channels or _cachedChannels) + return _cachedChannelsUnreadTotal; + } + + /// Recalculates both cached unread totals from scratch. + /// Called when unread state is first loaded. + void _recalculateCachedUnreadTotals() { + _recalculateCachedContactsUnreadTotal(); + _recalculateCachedChannelsUnreadTotal(); + } + + void _recalculateCachedContactsUnreadTotal() { + _cachedContactsUnreadTotal = _contactUnreadCount.values.fold(0, (a, b) => a + b); + } + + void _recalculateCachedChannelsUnreadTotal() { final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels; - for (final channel in allChannels) { - total += channel.unreadCount; - } - return total; + _cachedChannelsUnreadTotal = allChannels.fold(0, (total, ch) => total + ch.unreadCount); } bool isChannelSmazEnabled(int channelIndex) { @@ -377,11 +385,13 @@ class MeshCoreConnector extends ChangeNotifier { ..clear() ..addAll(await _unreadStore.loadContactUnreadCount()); _unreadStateLoaded = true; + _recalculateCachedUnreadTotals(); notifyListeners(); } Future loadCachedChannels() async { _cachedChannels = await _channelStore.loadChannels(); + _recalculateCachedChannelsUnreadTotal(); } void setActiveContact(String? contactKeyHex) { @@ -408,6 +418,7 @@ class MeshCoreConnector extends ChangeNotifier { final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; if (previousCount > 0) { _contactUnreadCount[contactKeyHex] = 0; + _cachedContactsUnreadTotal -= previousCount; _appDebugLogService?.info( 'Contact $contactKeyHex marked as read (was $previousCount unread)', tag: 'Unread', @@ -424,6 +435,7 @@ class MeshCoreConnector extends ChangeNotifier { if (channel != null && channel.unreadCount > 0) { final previousCount = channel.unreadCount; channel.unreadCount = 0; + _cachedChannelsUnreadTotal -= previousCount; _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', tag: 'Unread', @@ -1656,6 +1668,7 @@ class MeshCoreConnector extends ChangeNotifier { if (completed) { _hasLoadedChannels = true; _previousChannelsCache.clear(); + _recalculateCachedChannelsUnreadTotal(); } // Keep cache on failure/disconnection for future attempts } @@ -2703,6 +2716,7 @@ class MeshCoreConnector extends ChangeNotifier { final channel = _findChannelByIndex(channelIndex); if (channel != null) { channel.unreadCount++; + _cachedChannelsUnreadTotal++; _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}', tag: 'Unread', @@ -2747,6 +2761,7 @@ class MeshCoreConnector extends ChangeNotifier { final currentCount = _contactUnreadCount[contactKey] ?? 0; _contactUnreadCount[contactKey] = currentCount + 1; + _cachedContactsUnreadTotal++; _appDebugLogService?.info( 'Contact $contactKey unread count incremented to ${currentCount + 1}', tag: 'Unread', From 68bb031bb68c0bd2cf25d7ce85263e243e464bcd Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Mon, 9 Feb 2026 17:34:18 +0200 Subject: [PATCH 05/38] "Users first" instead of "People first" everywhere --- lib/screens/contacts_screen.dart | 23 ++++++++++++----------- lib/widgets/list_filter_widget.dart | 18 +++++++++--------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 394970f9..97e0d513 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -48,7 +48,7 @@ class _ContactsScreenState extends State String _searchQuery = ''; ContactSortOption _sortOption = ContactSortOption.lastSeen; bool _showUnreadOnly = false; - bool _prioritizePeople = true; + bool _prioritizeUsers = true; ContactTypeFilter _typeFilter = ContactTypeFilter.all; final ContactGroupStore _groupStore = ContactGroupStore(); List _groups = []; @@ -353,7 +353,7 @@ class _ContactsScreenState extends State sortOption: _sortOption, typeFilter: _typeFilter, showUnreadOnly: _showUnreadOnly, - prioritizePeople: _prioritizePeople, + prioritizeUsers: _prioritizeUsers, onSortChanged: (value) { setState(() { _sortOption = value; @@ -369,9 +369,9 @@ class _ContactsScreenState extends State _showUnreadOnly = value; }); }, - onPrioritizePeopleChanged: (value) { + onPrioritizeUsersChanged: (value) { setState(() { - _prioritizePeople = value; + _prioritizeUsers = value; }); }, onNewGroup: () => _showGroupEditor(context, connector.contacts), @@ -554,18 +554,19 @@ class _ContactsScreenState extends State }).toList(); } - // Apply sorting within groups if prioritizing people - if (_prioritizePeople) { - // Separate people (advTypeChat) from others - final people = filtered.where((c) => c.type == advTypeChat).toList(); + // Apply "users first" partitioning: separate users from other node types, + // sort each partition, then combine with users on top + if (_prioritizeUsers) { + // Separate users (advTypeChat) from others + final users = filtered.where((c) => c.type == advTypeChat).toList(); final others = filtered.where((c) => c.type != advTypeChat).toList(); // Sort each group separately - _applySorting(people, connector); + _applySorting(users, connector); _applySorting(others, connector); - // Combine: people first, then others - filtered = [...people, ...others]; + // Combine: users first, then others + filtered = [...users, ...others]; } else { _applySorting(filtered, connector); } diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index 6075fecb..4b233708 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -99,17 +99,17 @@ const int _actionFilterRepeaters = 6; const int _actionFilterRooms = 7; const int _actionToggleUnreadOnly = 8; const int _actionNewGroup = 9; -const int _actionTogglePrioritizePeople = 10; +const int _actionTogglePrioritizeUsers = 10; class ContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; final ContactTypeFilter typeFilter; final bool showUnreadOnly; - final bool prioritizePeople; + final bool prioritizeUsers; final ValueChanged onSortChanged; final ValueChanged onTypeFilterChanged; final ValueChanged onUnreadOnlyChanged; - final ValueChanged onPrioritizePeopleChanged; + final ValueChanged onPrioritizeUsersChanged; final VoidCallback onNewGroup; const ContactsFilterMenu({ @@ -117,11 +117,11 @@ class ContactsFilterMenu extends StatelessWidget { required this.sortOption, required this.typeFilter, required this.showUnreadOnly, - required this.prioritizePeople, + required this.prioritizeUsers, required this.onSortChanged, required this.onTypeFilterChanged, required this.onUnreadOnlyChanged, - required this.onPrioritizePeopleChanged, + required this.onPrioritizeUsersChanged, required this.onNewGroup, }); @@ -150,9 +150,9 @@ class ContactsFilterMenu extends StatelessWidget { checked: sortOption == ContactSortOption.name, ), SortFilterMenuOption( - value: _actionTogglePrioritizePeople, + value: _actionTogglePrioritizeUsers, label: l10n.listFilter_usersFirst, - checked: prioritizePeople, + checked: prioritizeUsers, ), ], ), @@ -202,8 +202,8 @@ class ContactsFilterMenu extends StatelessWidget { case _actionSortLastSeen: onSortChanged(ContactSortOption.lastSeen); break; - case _actionTogglePrioritizePeople: - onPrioritizePeopleChanged(!prioritizePeople); + case _actionTogglePrioritizeUsers: + onPrioritizeUsersChanged(!prioritizeUsers); break; case _actionFilterAll: onTypeFilterChanged(ContactTypeFilter.all); From 87bcb6a6a37c1fc6f7b7a653b06432103386c358 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Mon, 9 Feb 2026 17:40:56 +0200 Subject: [PATCH 06/38] Proper formatting --- lib/widgets/quick_switch_bar.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index 78874fdf..bbac8d3b 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -103,7 +103,7 @@ class QuickSwitchBar extends StatelessWidget { Widget _buildIconWithBadge(Icon icon, int count) { if (count <= 0) return icon; - + return Badge( label: Text( count > 99 ? '99+' : count.toString(), From afcc4db4054a5d4886b003dfafb7909ba4757e05 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Wed, 18 Feb 2026 20:15:29 +0200 Subject: [PATCH 07/38] fix: clamp cached unread totals to prevent negative badge counts Clamp both _cachedContactsUnreadTotal and _cachedChannelsUnreadTotal to >= 0 after decrementing in markContactRead() and markChannelRead(). This prevents the totals from going negative if the cache drifts out-of-sync, which could cause UI badges to display incorrect values. --- lib/connector/meshcore_connector.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 9483a202..a426df34 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -418,7 +418,7 @@ class MeshCoreConnector extends ChangeNotifier { final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; if (previousCount > 0) { _contactUnreadCount[contactKeyHex] = 0; - _cachedContactsUnreadTotal -= previousCount; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount).clamp(0, _cachedContactsUnreadTotal); _appDebugLogService?.info( 'Contact $contactKeyHex marked as read (was $previousCount unread)', tag: 'Unread', @@ -435,7 +435,7 @@ class MeshCoreConnector extends ChangeNotifier { if (channel != null && channel.unreadCount > 0) { final previousCount = channel.unreadCount; channel.unreadCount = 0; - _cachedChannelsUnreadTotal -= previousCount; + _cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount).clamp(0, _cachedChannelsUnreadTotal); _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', tag: 'Unread', From a30fc439f3b5e7553c3cb41dda0e2bf96de84200 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Fri, 27 Feb 2026 12:22:26 +0200 Subject: [PATCH 08/38] refactor: use UnreadBadge widget in QuickSwitchBar for consistent badge styling --- lib/widgets/quick_switch_bar.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index bbac8d3b..5a157a02 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; +import 'unread_badge.dart'; class QuickSwitchBar extends StatelessWidget { final int selectedIndex; @@ -104,12 +105,16 @@ class QuickSwitchBar extends StatelessWidget { Widget _buildIconWithBadge(Icon icon, int count) { if (count <= 0) return icon; - return Badge( - label: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle(fontSize: 10), - ), - child: icon, + return Stack( + clipBehavior: Clip.none, + children: [ + icon, + Positioned( + right: -6, + top: -4, + child: UnreadBadge(count: count), + ), + ], ); } } From c47a4cb622b715fe1a506848f01291a3e442237f Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Fri, 27 Feb 2026 12:28:57 +0200 Subject: [PATCH 09/38] fix: filter by _shouldTrackUnreadForContactKey when recalculating cached contacts unread total --- lib/connector/meshcore_connector.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index a426df34..34ab744d 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -360,7 +360,13 @@ class MeshCoreConnector extends ChangeNotifier { } void _recalculateCachedContactsUnreadTotal() { - _cachedContactsUnreadTotal = _contactUnreadCount.values.fold(0, (a, b) => a + b); + int total = 0; + _contactUnreadCount.forEach((contactKeyHex, count) { + if (_shouldTrackUnreadForContactKey(contactKeyHex)) { + total += count; + } + }); + _cachedContactsUnreadTotal = total; } void _recalculateCachedChannelsUnreadTotal() { From 87b25655d02b65a520135a5aac52f3ebe41a7f2a Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Fri, 27 Feb 2026 12:43:21 +0200 Subject: [PATCH 10/38] Package updates from main --- pubspec.lock | 210 +++++++++++++++--------- windows/flutter/generated_plugins.cmake | 1 + 2 files changed, 130 insertions(+), 81 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index fc116566..2e3329c4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -153,14 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" fake_async: dependency: transitive description: @@ -173,10 +181,10 @@ packages: dependency: transitive description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" file: dependency: transitive description: @@ -202,58 +210,58 @@ packages: dependency: "direct main" description: name: flutter_blue_plus - sha256: "399b3dbc15562ef59749f04e43a99ccbb91540022380d5f269aff3c2787534e4" + sha256: "4fba86c513feab2c5cdb9497da0910ed5b50c0fa8d6cec4a26ffb1a558a24eb8" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.1" flutter_blue_plus_android: dependency: transitive description: name: flutter_blue_plus_android - sha256: "5010b0960cce533a8fa71401573f044362c3e2e111dc6eb4898c92e85f85f50c" + sha256: "2a73e264685574d1d29dcdd565bad9ecfdf237630237c508ae8b47f5cc791f1d" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.2.1" flutter_blue_plus_darwin: dependency: transitive description: name: flutter_blue_plus_darwin - sha256: d160a8128e3a016fa58dd65ab6dac05cbc73e0fa799a1f24211d041641ed63ba + sha256: cfef171db550670cf8110f6eb25baf15d9bc8bad2af29550f9bbc0d8fceaf285 url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.2.1" flutter_blue_plus_linux: dependency: transitive description: name: flutter_blue_plus_linux - sha256: f5b02244d89465ba82c8c512686c66362fbb01f52fa03d645ed353ebf3883242 + sha256: "5add6c14d2f90672c5e3ded1455b9ca8e6fe44adf9b53cdc60eb3417d38f34fe" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.2.1" flutter_blue_plus_platform_interface: dependency: transitive description: name: flutter_blue_plus_platform_interface - sha256: "6e0fc04b77491dbfdbcd46c1a021b12f2f5fc5d6e01777f93a38a8431989b7f0" + sha256: "226fb6753a74a407e3b9975c0fc00de02c490ae655b31c6508cb5790ad30965d" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.2.1" flutter_blue_plus_web: dependency: transitive description: name: flutter_blue_plus_web - sha256: "376aad9595ee389c7cd56e0c373e78abcaa790c821ece9cb81f0969ec94c5bca" + sha256: "10a7465ccfc50138280abf32c8ab314f5029aa19039628ad9b4d0ed786e0021f" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.2.1" flutter_blue_plus_winrt: dependency: transitive description: name: flutter_blue_plus_winrt - sha256: "34be2d8e23d5881b46accebb0e71025f7d52869d72ea98b5082c20764e06aa80" + sha256: ed894f0ab341f4cece8fa33edc381d46424a7c5bfd0e841d933d0f8c34c86521 url: "https://pub.dev" source: hosted - version: "0.0.16" + version: "0.0.18" flutter_cache_manager: dependency: "direct main" description: @@ -266,18 +274,18 @@ packages: dependency: "direct main" description: name: flutter_foreground_task - sha256: "6cf10a27f5e344cd2ecad0752d3a5f4ec32846d82fda8753b3fe2480ebb832a3" + sha256: "48ea45056155a99fb30b15f14f4039a044d925bc85f381ed0b2d3b00a60b99de" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "9.2.0" flutter_launcher_icons: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.4" flutter_linkify: dependency: "direct main" description: @@ -290,34 +298,42 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" url: "https://pub.dev" source: hosted - version: "18.0.1" + version: "20.1.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "7.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "10.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a + url: "https://pub.dev" + source: hosted + version: "2.0.1" flutter_localizations: dependency: "direct main" description: flutter @@ -327,10 +343,18 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "8.2.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -361,10 +385,10 @@ packages: dependency: transitive description: name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" http: dependency: "direct main" description: @@ -385,10 +409,10 @@ packages: dependency: transitive description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.8.0" intl: dependency: "direct main" description: @@ -397,22 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: transitive description: name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.11.0" latlong2: dependency: "direct main" description: @@ -457,10 +473,10 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.1.0" lists: dependency: transitive description: @@ -501,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082 + url: "https://pub.dev" + source: hosted + version: "4.2906.0" meta: dependency: transitive description: @@ -529,10 +553,10 @@ packages: dependency: "direct main" description: name: mobile_scanner - sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173" + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce url: "https://pub.dev" source: hosted - version: "6.0.11" + version: "7.2.0" native_toolchain_c: dependency: transitive description: @@ -553,10 +577,10 @@ packages: dependency: transitive description: name: objective_c - sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" url: "https://pub.dev" source: hosted - version: "9.2.5" + version: "9.3.0" octo_image: dependency: transitive description: @@ -569,10 +593,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -589,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -641,10 +673,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -665,26 +697,18 @@ packages: dependency: "direct main" description: name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.9.1" - polylabel: - dependency: transitive - description: - name: polylabel - sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" - url: "https://pub.dev" - source: hosted - version: "1.0.1" + version: "4.0.0" posix: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" proj4dart: dependency: transitive description: @@ -769,10 +793,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" url: "https://pub.dev" source: hosted - version: "2.4.20" + version: "2.4.21" shared_preferences_foundation: dependency: transitive description: @@ -822,10 +846,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqflite: dependency: transitive description: @@ -958,10 +982,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -1006,10 +1030,34 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: @@ -1030,10 +1078,10 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.4.0" wakelock_plus_platform_interface: dependency: transitive description: @@ -1043,7 +1091,7 @@ packages: source: hosted version: "1.3.0" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 571addb8..4c358e7f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) From 3ae14781f0c07650a0d6bfa84830cc1cec941ccf Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Fri, 27 Feb 2026 12:58:32 +0200 Subject: [PATCH 11/38] AI translations for "Users first" --- lib/l10n/app_bg.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_localizations_bg.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_es.dart | 2 +- lib/l10n/app_localizations_fr.dart | 2 +- lib/l10n/app_localizations_it.dart | 2 +- lib/l10n/app_localizations_nl.dart | 2 +- lib/l10n/app_localizations_pl.dart | 2 +- lib/l10n/app_localizations_pt.dart | 2 +- lib/l10n/app_localizations_ru.dart | 2 +- lib/l10n/app_localizations_sk.dart | 2 +- lib/l10n/app_localizations_sl.dart | 2 +- lib/l10n/app_localizations_sv.dart | 2 +- lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_sk.arb | 1 + lib/l10n/app_sl.arb | 1 + lib/l10n/app_sv.arb | 1 + lib/l10n/app_zh.arb | 1 + 25 files changed, 25 insertions(+), 12 deletions(-) diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 975e0671..daa2b11b 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Потребители", "listFilter_repeaters": "Повторители", "listFilter_roomServers": "Сървъри на стая", + "listFilter_usersFirst": "Първо потребители", "listFilter_unreadOnly": "Само непрочетените", "listFilter_newGroup": "Нова група", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 74b6c05a..f39c3420 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1355,6 +1355,7 @@ "listFilter_users": "Benutzer", "listFilter_repeaters": "Repeater", "listFilter_roomServers": "Raumserver", + "listFilter_usersFirst": "Benutzer zuerst", "listFilter_unreadOnly": "Nicht gelesen", "listFilter_newGroup": "Neue Gruppe", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 74339ff7..8824ddb1 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Usuarios", "listFilter_repeaters": "Repetidores", "listFilter_roomServers": "Servidores de la sala", + "listFilter_usersFirst": "Usuarios primero", "listFilter_unreadOnly": "Solo sin leer", "listFilter_newGroup": "Nuevo grupo", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0697aee7..9b1c79fb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Utilisateurs", "listFilter_repeaters": "Répéteurs", "listFilter_roomServers": "Room servers", + "listFilter_usersFirst": "Utilisateurs en premier", "listFilter_unreadOnly": "Messages non lus seulement", "listFilter_newGroup": "Nouveau groupe", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 4798d263..16a1e770 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Utenti", "listFilter_repeaters": "Ripetitori", "listFilter_roomServers": "Server della stanza", + "listFilter_usersFirst": "Utenti per primi", "listFilter_unreadOnly": "Solo non letto", "listFilter_newGroup": "Nuovo gruppo", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 123d01c2..0221945c 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2751,7 +2751,7 @@ class AppLocalizationsBg extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Първо потребители'; @override String get listFilter_filters => 'Филтри'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 18a9aec3..afcb7c76 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2756,7 +2756,7 @@ class AppLocalizationsDe extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Benutzer zuerst'; @override String get listFilter_filters => 'Filtere'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 66fe8237..1f00a634 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2749,7 +2749,7 @@ class AppLocalizationsEs extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Usuarios primero'; @override String get listFilter_filters => 'Filtros'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 903ef299..1515a99d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2765,7 +2765,7 @@ class AppLocalizationsFr extends AppLocalizations { String get listFilter_az => 'A à Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Utilisateurs en premier'; @override String get listFilter_filters => 'Filtres'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 67c6c925..ce25bdf7 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2749,7 +2749,7 @@ class AppLocalizationsIt extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Utenti per primi'; @override String get listFilter_filters => 'Filtri'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 313d37f7..634c7717 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2740,7 +2740,7 @@ class AppLocalizationsNl extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Gebruikers eerst'; @override String get listFilter_filters => 'Filters'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 77137f2e..91e26df7 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2747,7 +2747,7 @@ class AppLocalizationsPl extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Najpierw użytkownicy'; @override String get listFilter_filters => 'Filtry'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 0606f1c6..e781514c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2750,7 +2750,7 @@ class AppLocalizationsPt extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Utilizadores primeiro'; @override String get listFilter_filters => 'Filtros'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 1c05f651..f2784f7c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2753,7 +2753,7 @@ class AppLocalizationsRu extends AppLocalizations { String get listFilter_az => 'По алфавиту'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Сначала пользователи'; @override String get listFilter_filters => 'Фильтры'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index f71695b7..640fbc4c 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -2735,7 +2735,7 @@ class AppLocalizationsSk extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Najskôr používatelia'; @override String get listFilter_filters => 'Filtre'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 62de2713..2cbf9b40 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2738,7 +2738,7 @@ class AppLocalizationsSl extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Najprej uporabniki'; @override String get listFilter_filters => 'Filtri'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 49ee78e0..63e27e51 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -2723,7 +2723,7 @@ class AppLocalizationsSv extends AppLocalizations { String get listFilter_az => 'A-Z'; @override - String get listFilter_usersFirst => 'Users first'; + String get listFilter_usersFirst => 'Användare först'; @override String get listFilter_filters => 'Filteralternativ'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 69ebecce..0cf24fa0 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Gebruikers", "listFilter_repeaters": "Repeaters", "listFilter_roomServers": "Roomservers", + "listFilter_usersFirst": "Gebruikers eerst", "listFilter_unreadOnly": "Alleen ongelezen", "listFilter_newGroup": "Nieuwe groep", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 75e1d348..34543eb1 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Użytkownicy", "listFilter_repeaters": "Powtarzacze", "listFilter_roomServers": "Serwery pokoju", + "listFilter_usersFirst": "Najpierw użytkownicy", "listFilter_unreadOnly": "Tylko nieprzeczytane", "listFilter_newGroup": "Nowa grupa", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index f6ada195..38c7ed69 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Usuários", "listFilter_repeaters": "Repetidores", "listFilter_roomServers": "Servidores de sala", + "listFilter_usersFirst": "Utilizadores primeiro", "listFilter_unreadOnly": "Apenas não lido", "listFilter_newGroup": "Novo grupo", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9aef298e..a87fe09a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -758,6 +758,7 @@ "listFilter_users": "Пользователи", "listFilter_repeaters": "Репитеры", "listFilter_roomServers": "Серверы комнат", + "listFilter_usersFirst": "Сначала пользователи", "listFilter_unreadOnly": "Только непрочитанные", "listFilter_newGroup": "Новая группа", "@chat_couldNotOpenLink": { diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 672b7d74..34b4c86a 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Používatelia", "listFilter_repeaters": "Opakovadlá", "listFilter_roomServers": "Servéry miestnosti", + "listFilter_usersFirst": "Najskôr používatelia", "listFilter_unreadOnly": "Nezaregistrované len", "listFilter_newGroup": "Nová skupina", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 09359a13..ff16b953 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Uporabniki", "listFilter_repeaters": "Ponovitve", "listFilter_roomServers": "Smeti za prostore", + "listFilter_usersFirst": "Najprej uporabniki", "listFilter_unreadOnly": "Nezbrani samo", "listFilter_newGroup": "Nova skupina", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index a923cc9c..45541a15 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1352,6 +1352,7 @@ "listFilter_users": "Användare", "listFilter_repeaters": "Upprepare", "listFilter_roomServers": "Rumservrar", + "listFilter_usersFirst": "Användare först", "listFilter_unreadOnly": "Endast oinlästa", "listFilter_newGroup": "Ny grupp", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 63c02a57..68bb5252 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1546,6 +1546,7 @@ "listFilter_users": "用户", "listFilter_repeaters": "转发节点", "listFilter_roomServers": "房间服务器", + "listFilter_usersFirst": "用户优先", "listFilter_unreadOnly": "仅显示未读", "listFilter_newGroup": "新建群聊", "pathTrace_you": "我自己", From 1b94442ab657ee70b02329c0858d0a28e27cde90 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Fri, 27 Feb 2026 21:19:13 +0200 Subject: [PATCH 12/38] Fix action constant collision: change _actionTogglePrioritizeUsers from 10 to 11 --- lib/widgets/list_filter_widget.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index 63ab5807..fa7e67e2 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -100,7 +100,7 @@ const int _actionFilterRepeaters = 7; const int _actionFilterRooms = 8; const int _actionToggleUnreadOnly = 9; const int _actionNewGroup = 10; -const int _actionTogglePrioritizeUsers = 10; +const int _actionTogglePrioritizeUsers = 11; class ContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; From 297516fc805516a7e026f744228f6ecb85db7c14 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Tue, 21 Apr 2026 16:31:09 +0300 Subject: [PATCH 13/38] Update cached unread total when removing contact unread entries When contacts are removed in removeContact, _handleContact, or _handleContactAdvert, subtract their unread count from _cachedContactsUnreadTotal immediately so badge counts reflect the true total without waiting for a full reload. --- lib/connector/meshcore_connector.dart | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index e1740461..63a8639e 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -431,7 +431,10 @@ class MeshCoreConnector extends ChangeNotifier { void _recalculateCachedChannelsUnreadTotal() { final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels; - _cachedChannelsUnreadTotal = allChannels.fold(0, (total, ch) => total + ch.unreadCount); + _cachedChannelsUnreadTotal = allChannels.fold( + 0, + (total, ch) => total + ch.unreadCount, + ); } bool isChannelSmazEnabled(int channelIndex) { @@ -484,7 +487,8 @@ class MeshCoreConnector extends ChangeNotifier { final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; if (previousCount > 0) { _contactUnreadCount[contactKeyHex] = 0; - _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount).clamp(0, _cachedContactsUnreadTotal); + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount) + .clamp(0, _cachedContactsUnreadTotal); _appDebugLogService?.info( 'Contact $contactKeyHex marked as read (was $previousCount unread)', tag: 'Unread', @@ -501,7 +505,8 @@ class MeshCoreConnector extends ChangeNotifier { if (channel != null && channel.unreadCount > 0) { final previousCount = channel.unreadCount; channel.unreadCount = 0; - _cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount).clamp(0, _cachedChannelsUnreadTotal); + _cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount) + .clamp(0, _cachedChannelsUnreadTotal); _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', tag: 'Unread', @@ -1524,6 +1529,9 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(_persistContacts()); _conversations.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex); + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -2158,6 +2166,9 @@ class MeshCoreConnector extends ChangeNotifier { final contact = Contact.fromFrame(frame); if (contact != null) { if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -2233,6 +2244,9 @@ class MeshCoreConnector extends ChangeNotifier { } if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), From 8611adab1fcd2a438e6f47e4d33dab02f876c826 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Tue, 21 Apr 2026 16:51:10 +0300 Subject: [PATCH 14/38] Run dart format and verify analyze --- lib/widgets/quick_switch_bar.dart | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index 5a157a02..4f014bd3 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -109,11 +109,7 @@ class QuickSwitchBar extends StatelessWidget { clipBehavior: Clip.none, children: [ icon, - Positioned( - right: -6, - top: -4, - child: UnreadBadge(count: count), - ), + Positioned(right: -6, top: -4, child: UnreadBadge(count: count)), ], ); } From 9fe4a3710de2f720b4c2395d89bb2a4ac8e122fd Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Tue, 21 Apr 2026 17:01:51 +0300 Subject: [PATCH 15/38] Add missing users-first translations for hu/ja/ko and regen outputs --- lib/l10n/app_hu.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_localizations_hu.dart | 3 +++ lib/l10n/app_localizations_ja.dart | 3 +++ lib/l10n/app_localizations_ko.dart | 3 +++ linux/flutter/generated_plugins.cmake | 1 - windows/flutter/generated_plugins.cmake | 1 - 8 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 6f43463c..1985899c 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -1698,6 +1698,7 @@ "listFilter_latestMessages": "Legfrissebb üzenetek", "listFilter_heardRecently": "Úgy hallottam, hogy...", "listFilter_az": "A-Z", + "listFilter_usersFirst": "Felhasználók elöl", "listFilter_filters": "Szűrők", "listFilter_all": "Mind", "listFilter_favorites": "Kedvencek", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b63f146d..e6ac0151 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1698,6 +1698,7 @@ "listFilter_latestMessages": "最新のメッセージ", "listFilter_heardRecently": "最近、聞いた", "listFilter_az": "AからZ", + "listFilter_usersFirst": "ユーザー優先", "listFilter_filters": "フィルター", "listFilter_all": "すべて", "listFilter_favorites": "お気に入り", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 40721fe1..d3930a1b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1698,6 +1698,7 @@ "listFilter_latestMessages": "최신 메시지", "listFilter_heardRecently": "최근에 들었습니다", "listFilter_az": "A부터 Z까지", + "listFilter_usersFirst": "사용자 우선", "listFilter_filters": "필터", "listFilter_all": "모든", "listFilter_favorites": "관심 목록", diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 1ad8558c..9557c3cc 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -3081,6 +3081,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get listFilter_az => 'A-Z'; + @override + String get listFilter_usersFirst => 'Felhasználók elöl'; + @override String get listFilter_filters => 'Szűrők'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index afb8c29b..b838f9a1 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2929,6 +2929,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get listFilter_az => 'AからZ'; + @override + String get listFilter_usersFirst => 'ユーザー優先'; + @override String get listFilter_filters => 'フィルター'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index ff4bd261..3af9dd04 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2929,6 +2929,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get listFilter_az => 'A부터 Z까지'; + @override + String get listFilter_usersFirst => '사용자 우선'; + @override String get listFilter_filters => '필터'; diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93e46829..379e36fa 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 533a1712..f02857f4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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) From 16ce1359d7e0cc23d51427c8a0d1078da8d96800 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Tue, 21 Apr 2026 23:10:39 +0300 Subject: [PATCH 16/38] Remove unused 'Users first' translation key --- lib/l10n/app_bg.arb | 1 - lib/l10n/app_de.arb | 1 - lib/l10n/app_en.arb | 1 - lib/l10n/app_es.arb | 1 - lib/l10n/app_fr.arb | 1 - lib/l10n/app_hu.arb | 1 - lib/l10n/app_it.arb | 1 - lib/l10n/app_ja.arb | 1 - lib/l10n/app_ko.arb | 1 - lib/l10n/app_localizations.dart | 6 ------ lib/l10n/app_localizations_bg.dart | 3 --- lib/l10n/app_localizations_de.dart | 3 --- lib/l10n/app_localizations_en.dart | 3 --- lib/l10n/app_localizations_es.dart | 3 --- lib/l10n/app_localizations_fr.dart | 3 --- lib/l10n/app_localizations_hu.dart | 3 --- lib/l10n/app_localizations_it.dart | 3 --- lib/l10n/app_localizations_ja.dart | 3 --- lib/l10n/app_localizations_ko.dart | 3 --- lib/l10n/app_localizations_nl.dart | 3 --- lib/l10n/app_localizations_pl.dart | 3 --- lib/l10n/app_localizations_pt.dart | 3 --- lib/l10n/app_localizations_ru.dart | 3 --- lib/l10n/app_localizations_sk.dart | 3 --- lib/l10n/app_localizations_sl.dart | 3 --- lib/l10n/app_localizations_sv.dart | 3 --- lib/l10n/app_localizations_uk.dart | 3 --- lib/l10n/app_localizations_zh.dart | 3 --- lib/l10n/app_nl.arb | 1 - lib/l10n/app_pl.arb | 1 - lib/l10n/app_pt.arb | 1 - lib/l10n/app_ru.arb | 1 - lib/l10n/app_sk.arb | 1 - lib/l10n/app_sl.arb | 1 - lib/l10n/app_sv.arb | 1 - lib/l10n/app_uk.arb | 1 - lib/l10n/app_zh.arb | 1 - 37 files changed, 78 deletions(-) diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index bd87d03a..7ac54177 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Потребители", "listFilter_repeaters": "Повторители", "listFilter_roomServers": "Сървъри на стая", - "listFilter_usersFirst": "Първо потребители", "listFilter_unreadOnly": "Само непрочетените", "listFilter_newGroup": "Нова група", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0e63be20..46955052 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1356,7 +1356,6 @@ "listFilter_users": "Benutzer", "listFilter_repeaters": "Repeater", "listFilter_roomServers": "Raumserver", - "listFilter_usersFirst": "Benutzer zuerst", "listFilter_unreadOnly": "Nicht gelesen", "listFilter_newGroup": "Neue Gruppe", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6b845b23..8ad6bf37 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1754,7 +1754,6 @@ "listFilter_latestMessages": "Latest messages", "listFilter_heardRecently": "Heard recently", "listFilter_az": "A-Z", - "listFilter_usersFirst": "Users first", "listFilter_filters": "Filters", "listFilter_all": "All", "listFilter_favorites": "Favorites", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 6e7500ce..ac9527bd 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Usuarios", "listFilter_repeaters": "Repetidores", "listFilter_roomServers": "Servidores de la sala", - "listFilter_usersFirst": "Usuarios primero", "listFilter_unreadOnly": "Solo sin leer", "listFilter_newGroup": "Nuevo grupo", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7890c3de..a942aa2b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Utilisateurs", "listFilter_repeaters": "Répéteurs", "listFilter_roomServers": "Room servers", - "listFilter_usersFirst": "Utilisateurs en premier", "listFilter_unreadOnly": "Messages non lus seulement", "listFilter_newGroup": "Nouveau groupe", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 1985899c..6f43463c 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -1698,7 +1698,6 @@ "listFilter_latestMessages": "Legfrissebb üzenetek", "listFilter_heardRecently": "Úgy hallottam, hogy...", "listFilter_az": "A-Z", - "listFilter_usersFirst": "Felhasználók elöl", "listFilter_filters": "Szűrők", "listFilter_all": "Mind", "listFilter_favorites": "Kedvencek", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a14c5ca5..387c8cff 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Utenti", "listFilter_repeaters": "Ripetitori", "listFilter_roomServers": "Server della stanza", - "listFilter_usersFirst": "Utenti per primi", "listFilter_unreadOnly": "Solo non letto", "listFilter_newGroup": "Nuovo gruppo", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index e6ac0151..b63f146d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1698,7 +1698,6 @@ "listFilter_latestMessages": "最新のメッセージ", "listFilter_heardRecently": "最近、聞いた", "listFilter_az": "AからZ", - "listFilter_usersFirst": "ユーザー優先", "listFilter_filters": "フィルター", "listFilter_all": "すべて", "listFilter_favorites": "お気に入り", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d3930a1b..40721fe1 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1698,7 +1698,6 @@ "listFilter_latestMessages": "최신 메시지", "listFilter_heardRecently": "최근에 들었습니다", "listFilter_az": "A부터 Z까지", - "listFilter_usersFirst": "사용자 우선", "listFilter_filters": "필터", "listFilter_all": "모든", "listFilter_favorites": "관심 목록", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4c54dd39..2c1342d0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5354,12 +5354,6 @@ abstract class AppLocalizations { /// **'A-Z'** String get listFilter_az; - /// No description provided for @listFilter_usersFirst. - /// - /// In en, this message translates to: - /// **'Users first'** - String get listFilter_usersFirst; - /// No description provided for @listFilter_filters. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index c51bb6b9..b3e12799 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -3070,9 +3070,6 @@ class AppLocalizationsBg extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Първо потребители'; - @override String get listFilter_filters => 'Филтри'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7d69ebd3..d7c16914 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3074,9 +3074,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Benutzer zuerst'; - @override String get listFilter_filters => 'Filtere'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c36d11c0..a2a88b0d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3015,9 +3015,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Users first'; - @override String get listFilter_filters => 'Filters'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 326144cf..a1270124 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3067,9 +3067,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Usuarios primero'; - @override String get listFilter_filters => 'Filtros'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3bfc05d3..a0063914 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3086,9 +3086,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get listFilter_az => 'A à Z'; - @override - String get listFilter_usersFirst => 'Utilisateurs en premier'; - @override String get listFilter_filters => 'Filtres'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 9557c3cc..1ad8558c 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -3081,9 +3081,6 @@ class AppLocalizationsHu extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Felhasználók elöl'; - @override String get listFilter_filters => 'Szűrők'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index d077cc87..3a55559a 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -3070,9 +3070,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Utenti per primi'; - @override String get listFilter_filters => 'Filtri'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b838f9a1..afb8c29b 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2929,9 +2929,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get listFilter_az => 'AからZ'; - @override - String get listFilter_usersFirst => 'ユーザー優先'; - @override String get listFilter_filters => 'フィルター'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 3af9dd04..ff4bd261 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2929,9 +2929,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get listFilter_az => 'A부터 Z까지'; - @override - String get listFilter_usersFirst => '사용자 우선'; - @override String get listFilter_filters => '필터'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 28fe6dee..dd770e15 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3053,9 +3053,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Gebruikers eerst'; - @override String get listFilter_filters => 'Filters'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 4fab5c11..357dd7e3 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -3079,9 +3079,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Najpierw użytkownicy'; - @override String get listFilter_filters => 'Filtry'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index eeb20f3d..2dfcd8bd 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3068,9 +3068,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Utilizadores primeiro'; - @override String get listFilter_filters => 'Filtros'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 97f0d26e..4fac42ce 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3073,9 +3073,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get listFilter_az => 'По алфавиту'; - @override - String get listFilter_usersFirst => 'Сначала пользователи'; - @override String get listFilter_filters => 'Фильтры'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index d2787b69..c42e0249 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -3048,9 +3048,6 @@ class AppLocalizationsSk extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Najskôr používatelia'; - @override String get listFilter_filters => 'Filtre'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 0dd91eb8..2d89aa41 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -3049,9 +3049,6 @@ class AppLocalizationsSl extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Najprej uporabniki'; - @override String get listFilter_filters => 'Filtri'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a789c3c6..38e08939 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -3033,9 +3033,6 @@ class AppLocalizationsSv extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => 'Användare först'; - @override String get listFilter_filters => 'Filteralternativ'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 4bba7f7f..4cab52cd 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3075,9 +3075,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get listFilter_az => 'А-Я'; - @override - String get listFilter_usersFirst => 'Спочатку користувачі'; - @override String get listFilter_filters => 'Фільтри'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ec3182d3..4f38c64a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2854,9 +2854,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get listFilter_az => 'A-Z'; - @override - String get listFilter_usersFirst => '用户优先'; - @override String get listFilter_filters => '筛选'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a7509184..96bdb845 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Gebruikers", "listFilter_repeaters": "Repeaters", "listFilter_roomServers": "Roomservers", - "listFilter_usersFirst": "Gebruikers eerst", "listFilter_unreadOnly": "Alleen ongelezen", "listFilter_newGroup": "Nieuwe groep", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 2215f632..b62a78af 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1363,7 +1363,6 @@ "listFilter_users": "Użytkownicy", "listFilter_repeaters": "Przekaźniki", "listFilter_roomServers": "Serwery pokoju", - "listFilter_usersFirst": "Najpierw użytkownicy", "listFilter_unreadOnly": "Tylko nieprzeczytane", "listFilter_newGroup": "Nowa grupa", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index e6fe447c..bf3e8936 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Usuários", "listFilter_repeaters": "Repetidores", "listFilter_roomServers": "Servidores de sala", - "listFilter_usersFirst": "Utilizadores primeiro", "listFilter_unreadOnly": "Apenas não lido", "listFilter_newGroup": "Novo grupo", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a089c496..a83d1394 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -759,7 +759,6 @@ "listFilter_users": "Пользователи", "listFilter_repeaters": "Репитеры", "listFilter_roomServers": "Серверы комнат", - "listFilter_usersFirst": "Сначала пользователи", "listFilter_unreadOnly": "Только непрочитанные", "listFilter_newGroup": "Новая группа", "@chat_couldNotOpenLink": { diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index bed40af7..e4466c30 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Používatelia", "listFilter_repeaters": "Opakovadlá", "listFilter_roomServers": "Servéry miestnosti", - "listFilter_usersFirst": "Najskôr používatelia", "listFilter_unreadOnly": "Nezaregistrované len", "listFilter_newGroup": "Nová skupina", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 98bf60d1..f6a317ef 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Uporabniki", "listFilter_repeaters": "Ponovitve", "listFilter_roomServers": "Smeti za prostore", - "listFilter_usersFirst": "Najprej uporabniki", "listFilter_unreadOnly": "Nezbrani samo", "listFilter_newGroup": "Nova skupina", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 3fbf1ece..eab348c7 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1353,7 +1353,6 @@ "listFilter_users": "Användare", "listFilter_repeaters": "Upprepare", "listFilter_roomServers": "Rumservrar", - "listFilter_usersFirst": "Användare först", "listFilter_unreadOnly": "Endast oinlästa", "listFilter_newGroup": "Ny grupp", "@neighbors_errorLoading": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index f272d0ff..90f2f267 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1349,7 +1349,6 @@ "listFilter_latestMessages": "Останні повідомлення", "listFilter_heardRecently": "Нещодавно чули", "listFilter_az": "А-Я", - "listFilter_usersFirst": "Спочатку користувачі", "listFilter_filters": "Фільтри", "listFilter_all": "Все", "listFilter_users": "Користувачі", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f6937949..9dc2325c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1547,7 +1547,6 @@ "listFilter_users": "用户", "listFilter_repeaters": "转发节点", "listFilter_roomServers": "房间服务器", - "listFilter_usersFirst": "用户优先", "listFilter_unreadOnly": "仅显示未读", "listFilter_newGroup": "新建群聊", "pathTrace_you": "我自己", From d0d6a34fb547877f795126ddf8fb1127894185ea Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Tue, 21 Apr 2026 23:44:14 +0300 Subject: [PATCH 17/38] Restore jni to whatever is in main --- linux/flutter/generated_plugins.cmake | 1 + windows/flutter/generated_plugins.cmake | 1 + 2 files changed, 2 insertions(+) diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 379e36fa..93e46829 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f02857f4..533a1712 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES) From 0c1e163b889a292bc47be76ef5f359754540f4fa Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Tue, 21 Apr 2026 23:50:08 +0300 Subject: [PATCH 18/38] Reverted Ukrainian translations, will be in a separate PR --- lib/l10n/app_localizations_uk.dart | 8 ++++---- lib/l10n/app_uk.arb | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 4cab52cd..8ed4b9ff 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3115,7 +3115,7 @@ class AppLocalizationsUk extends AppLocalizations { String get pathTrace_notAvailable => 'Трасування шляху недоступне.'; @override - String get pathTrace_refreshTooltip => 'Оновити трасування шляху'; + String get pathTrace_refreshTooltip => 'Оновити Path Trace'; @override String get pathTrace_someHopsNoLocation => @@ -3302,10 +3302,10 @@ class AppLocalizationsUk extends AppLocalizations { String get contacts_contactImportFailed => 'Контакт не вдалося імпортувати'; @override - String get contacts_zeroHopAdvert => 'Оголошення без ретрансляції'; + String get contacts_zeroHopAdvert => 'Реклама без перехоплення'; @override - String get contacts_floodAdvert => 'Оголошення з ретрансляцією'; + String get contacts_floodAdvert => 'Залив реклами'; @override String get contacts_copyAdvertToClipboard => @@ -3332,7 +3332,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_contactAdvertCopied => - 'Оголошення скопійовано до буфера обміну.'; + 'Рекламу скопійовано до буфера обміну.'; @override String get contacts_contactAdvertCopyFailed => diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 90f2f267..a005b369 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1551,7 +1551,7 @@ "pathTrace_you": "Ви", "pathTrace_failed": "Відстеження шляху не вдалося.", "pathTrace_notAvailable": "Трасування шляху недоступне.", - "pathTrace_refreshTooltip": "Оновити трасування шляху", + "pathTrace_refreshTooltip": "Оновити Path Trace", "contacts_pathTrace": "Трасування шляхів", "contacts_ping": "Пінгувати", "contacts_repeaterPathTrace": "Трасування шляху до повторювача", @@ -1563,8 +1563,8 @@ "contacts_invalidAdvertFormat": "Недійсні контактні дані", "contacts_contactImported": "Контакт було імпортовано.", "contacts_contactImportFailed": "Контакт не вдалося імпортувати", - "contacts_zeroHopAdvert": "Оголошення без ретрансляції", - "contacts_floodAdvert": "Оголошення з ретрансляцією", + "contacts_zeroHopAdvert": "Реклама без перехоплення", + "contacts_floodAdvert": "Залив реклами", "contacts_copyAdvertToClipboard": "Копіювати оголошення в буфер обміну", "contacts_clipboardEmpty": "Буфер обміну порожній", "appSettings_languageRu": "Російська", @@ -1572,7 +1572,7 @@ "appSettings_enableMessageTracingSubtitle": "Показувати детальні метадані про маршрутизацію та час для повідомлень", "contacts_ShareContact": "Копіювати контакт у буфер обміну", "contacts_zeroHopContactAdvertFailed": "Не вдалося надіслати контакт.", - "contacts_contactAdvertCopied": "Оголошення скопійовано до буфера обміну.", + "contacts_contactAdvertCopied": "Рекламу скопійовано до буфера обміну.", "contacts_contactAdvertCopyFailed": "Копіювання оголошення в буфер обміну завершилося невдало", "contacts_zeroHopContactAdvertSent": "Відправлено контакт за оголошенням", "contacts_addContactFromClipboard": "Додати контакт з буфера обміну", From d3c7d8e43aa3660d95c1d862d3e07e76b48bed6d Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Wed, 22 Apr 2026 01:57:12 +0300 Subject: [PATCH 19/38] Red dot unread indicator in bottom tabs, keep numeric unreads only for the lists; fixed unread indicator wasn't on all screens --- lib/screens/contacts_screen.dart | 2 ++ lib/screens/line_of_sight_map_screen.dart | 6 ++++++ lib/widgets/quick_switch_bar.dart | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 54d32990..a034d04a 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -429,6 +429,8 @@ class _ContactsScreenState extends State selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index ec8a391f..1148edf0 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -431,6 +431,12 @@ class _LineOfSightMapScreenState extends State { child: QuickSwitchBar( selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: context + .watch() + .getTotalContactsUnreadCount(), + channelsUnreadCount: context + .watch() + .getTotalChannelsUnreadCount(), ), ), ); diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index 4f014bd3..40dcb59a 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -2,7 +2,6 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; -import 'unread_badge.dart'; class QuickSwitchBar extends StatelessWidget { final int selectedIndex; @@ -109,7 +108,18 @@ class QuickSwitchBar extends StatelessWidget { clipBehavior: Clip.none, children: [ icon, - Positioned(right: -6, top: -4, child: UnreadBadge(count: count)), + Positioned( + right: -2, + top: -2, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + ), + ), + ), ], ); } From d2a6fbe1821e0e80837799a62386c2fbd1b1c6ac Mon Sep 17 00:00:00 2001 From: ericz Date: Sun, 10 May 2026 11:09:25 +0200 Subject: [PATCH 20/38] translate notifications. --- lib/connector/meshcore_connector.dart | 99 +++++++++++++++++++++------ lib/services/translation_service.dart | 2 +- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 538ee38a..ba18d3b1 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4408,22 +4408,38 @@ class MeshCoreConnector extends ChangeNotifier { _appSettingsService != null) { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { + // Capture non-null local for async closure type promotion + final msg = message; if (contact?.type == advTypeChat) { - _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown', - message: message.text, - contactId: message.senderKeyHex, - badgeCount: getTotalUnreadCount(), - ); + unawaited(() async { + final resolvedText = await _maybeTranslateForNotification( + msg.text, + isCli: msg.isCli, + ); + await _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown', + message: resolvedText, + contactId: msg.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + }()); } else if (contact?.type == advTypeRoom) { - _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown Room', - message: message.text.length > 4 - ? message.text.substring(4) - : message.text, - contactId: message.senderKeyHex, - badgeCount: getTotalUnreadCount(), - ); + // Room server messages include a 4-char prefix; strip it for notifications + final bodyText = msg.text.length > 4 + ? msg.text.substring(4) + : msg.text; + unawaited(() async { + final resolvedText = await _maybeTranslateForNotification( + bodyText, + isCli: msg.isCli, + ); + await _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown Room', + message: resolvedText, + contactId: msg.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + }()); } } } @@ -4705,13 +4721,54 @@ class MeshCoreConnector extends ChangeNotifier { final label = channelName ?? _channelDisplayName(channelIndex); if (_appSettingsService!.isChannelMuted(label)) return; - _notificationService.showChannelMessageNotification( - channelName: label, - senderName: message.senderName, - message: message.text, - channelIndex: channelIndex, - badgeCount: getTotalUnreadCount(), - ); + // Translate channel notification text if enabled + unawaited(() async { + final resolvedText = await _maybeTranslateForNotification( + message.text, + isCli: false, + ); + await _notificationService.showChannelMessageNotification( + channelName: label, + senderName: message.senderName, + message: resolvedText, + channelIndex: message.channelIndex, + badgeCount: getTotalUnreadCount(), + ); + }()); + } + + Future _maybeTranslateForNotification( + String text, { + required bool isCli, + }) async { + final service = _translationService; + if (service == null) return text; + try { + if (!service.shouldTranslateIncoming( + text: text, + isCli: isCli, + isOutgoing: false, + )) { + return text; + } + final targetLanguageCode = service.resolvedIncomingLanguageCode( + _appSettingsService?.settings.languageOverride, + ); + if (targetLanguageCode == null || targetLanguageCode.isEmpty) { + return text; + } + final result = await service.translateIncomingText( + text: text, + targetLanguageCode: targetLanguageCode, + ); + final translated = result?.translatedText.trim(); + if (translated != null && translated.isNotEmpty) { + return translated; + } + } catch (_) { + // Fallback to original text on any error + } + return text; } void _handleIncomingChannelMessage(Uint8List frame) { diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 294a7848..7f2d4495 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -454,7 +454,7 @@ class TranslationService extends ChangeNotifier { final targetLabel = _languageLabel(targetLanguageCode); final instruction = targetLanguageCode == 'zh' ? '将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释:\n\n$text' - : 'Translate the following segment into $targetLabel, without additional explanation.\n\n$text'; + : 'Translate the following segment into $targetLabel, without additional explanation. If it is already in $targetLabel, then return the exact same String.\n\n$text'; try { return await _runExclusive(() async { final engine = await _ensureContext(model.localPath); From 1f6b2dacf95a8727b87f5ad60f4f402467a7508b Mon Sep 17 00:00:00 2001 From: ericz Date: Mon, 11 May 2026 07:15:39 +0200 Subject: [PATCH 21/38] changed translation prompt back to specific input. --- lib/services/translation_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 7f2d4495..294a7848 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -454,7 +454,7 @@ class TranslationService extends ChangeNotifier { final targetLabel = _languageLabel(targetLanguageCode); final instruction = targetLanguageCode == 'zh' ? '将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释:\n\n$text' - : 'Translate the following segment into $targetLabel, without additional explanation. If it is already in $targetLabel, then return the exact same String.\n\n$text'; + : 'Translate the following segment into $targetLabel, without additional explanation.\n\n$text'; try { return await _runExclusive(() async { final engine = await _ensureContext(model.localPath); From 77018dc35887cdbebb739a6c232efffbb5522c81 Mon Sep 17 00:00:00 2001 From: Serge Tarkovski Date: Tue, 12 May 2026 00:47:26 +0300 Subject: [PATCH 22/38] Recompute channels unread total after cachedChannels is updated --- lib/connector/meshcore_connector.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 78e02a7b..ead7fb36 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3442,6 +3442,7 @@ class MeshCoreConnector extends ChangeNotifier { // Cache channels for offline use _cachedChannels = List.from(_channels); unawaited(_channelStore.saveChannels(_channels)); + _recalculateCachedChannelsUnreadTotal(); // Apply ordering and notify UI _applyChannelOrder(); @@ -3460,7 +3461,6 @@ class MeshCoreConnector extends ChangeNotifier { if (completed) { _hasLoadedChannels = true; _previousChannelsCache.clear(); - _recalculateCachedChannelsUnreadTotal(); } // Fallback: if contact sync was deferred waiting for channel 0 but From 2763d83fe41ab42890e6ac43005693c35ca828fa Mon Sep 17 00:00:00 2001 From: Seth Golub Date: Mon, 11 May 2026 21:13:50 -0700 Subject: [PATCH 23/38] Use correct channel icons in channel chat screen At the top of the channel chat screen is an icon, indicating the channel type. Previously, the public icon was used correctly, but the hashtag icon was used for all other types. Now, consistent with the channels screen, we use the lock icon for private channels, and the composite icons for community public & community hashtag types. The fix for private channels was trivial, as we can identify hashtag channels by their name. Finding out whether a channel belongs to a community is much more involved. All the hard-working code was copied from channels_screen.dart. (I tried refactoring to reduce duplication, but my results were complex and not worth it.) Closes #432 --- lib/models/channel.dart | 34 ++++++++++++ lib/models/community.dart | 33 +++++++++++ lib/screens/channel_chat_screen.dart | 74 +++++++++++++++++++++++-- lib/screens/channels_screen.dart | 82 +++++++++------------------- 4 files changed, 164 insertions(+), 59 deletions(-) diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 4fdd6270..9baf6302 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import '../connector/meshcore_protocol.dart'; +import 'community.dart'; + +enum ChannelType { public, private, hashtag, communityPublic, communityHashtag } class Channel { final int index; @@ -111,5 +114,36 @@ class Channel { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } + static bool isCommunityChannel(ChannelType channelType) { + switch (channelType) { + case ChannelType.communityPublic: + case ChannelType.communityHashtag: + return true; + case ChannelType.public: + case ChannelType.private: + case ChannelType.hashtag: + return false; + } + } + + static ChannelType getChannelType( + Channel channel, + CommunityPskIndex communityIndex, + ) { + Community? community = communityIndex.getCommunityForChannel(channel); + if (community != null) { + if (Community.isCommunityPublicChannel(channel, community)) { + return ChannelType.communityPublic; + } + return ChannelType.communityHashtag; + } + if (channel.isPublicChannel) { + return ChannelType.public; + } else if (channel.name.startsWith('#')) { + return ChannelType.hashtag; + } + return ChannelType.private; + } + static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72'; } diff --git a/lib/models/community.dart b/lib/models/community.dart index c829f3d1..7261ddf9 100644 --- a/lib/models/community.dart +++ b/lib/models/community.dart @@ -4,6 +4,8 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; +import 'channel.dart'; + /// Represents a community with a shared secret for deriving channel PSKs. /// /// A Community is a namespace with a shared secret K (32 random bytes), @@ -162,6 +164,12 @@ class Community { return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim(); } + /// Returns true if this is the community's public channel + static bool isCommunityPublicChannel(Channel channel, Community community) { + final publicPsk = community.deriveCommunityPublicPsk(); + return channel.pskHex == Channel.formatPskHex(publicPsk); + } + /// Add a hashtag channel to this community's list Community addHashtagChannel(String hashtag) { final normalized = _normalizeCommunityHashtag(hashtag); @@ -237,3 +245,28 @@ class Community { @override int get hashCode => id.hashCode; } + +class CommunityPskIndex { + // Cache of PSK hex -> Community for quick lookup + final Map _pskToCommunity = {}; + + void initialize(List communities) { + _pskToCommunity.clear(); + for (final community in communities) { + // Map the community public channel PSK + final publicPsk = community.deriveCommunityPublicPsk(); + _pskToCommunity[Channel.formatPskHex(publicPsk)] = community; + + // Map all known hashtag channel PSKs + for (final hashtag in community.hashtagChannels) { + final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag); + _pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community; + } + } + } + + /// Returns the community this channel belongs to, or null if not a community channel + Community? getCommunityForChannel(Channel channel) { + return _pskToCommunity[channel.pskHex]; + } +} diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index fcd50861..be72eaa8 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -8,6 +8,8 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../models/community.dart'; +import '../storage/community_store.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; @@ -56,8 +58,11 @@ class _ChannelChatScreenState extends State { final ChatScrollController _scrollController = ChatScrollController(); final FocusNode _textFieldFocusNode = FocusNode(); ChannelMessage? _replyingToMessage; + final CommunityStore _communityStore = CommunityStore(); + final CommunityPskIndex _communityIndex = CommunityPskIndex(); final Map _messageKeys = {}; bool _isLoadingOlder = false; + bool _communitiesLoaded = false; MeshCoreConnector? _connector; DateTime? _lastChannelSendAt; @@ -81,6 +86,7 @@ class _ChannelChatScreenState extends State { final idx = widget.channel.index; final unread = widget.initialUnreadCount; final messages = connector.getChannelMessages(widget.channel); + _loadCommunities(); ChannelMessage? anchor; if (unread > 0) { anchor = _findOldestUnreadChannelAnchor(messages, unread); @@ -107,6 +113,19 @@ class _ChannelChatScreenState extends State { }); } + // TODO: Reload communities when returning from another screen + Future _loadCommunities() async { + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + final communities = await _communityStore.loadCommunities(); + if (mounted) { + setState(() { + _communityIndex.initialize(communities); + _communitiesLoaded = true; + }); + } + } + ChannelMessage? _findOldestUnreadChannelAnchor( List messages, int unreadCount, @@ -193,16 +212,63 @@ class _ChannelChatScreenState extends State { ); } + Widget _channelIcon(Channel channel) { + // Determine icon based on channel type + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + IconData icon; + switch (channelType) { + case ChannelType.communityPublic: + icon = Icons.groups; + case ChannelType.communityHashtag: + icon = Icons.tag; + case ChannelType.public: + icon = Icons.public; + case ChannelType.hashtag: + icon = Icons.tag; + case ChannelType.private: + icon = Icons.lock; + } + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: _communitiesLoaded + ? Icon(icon, size: 20) + : SizedBox.square(dimension: 20), + ), + if (isCommunityChannel) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: Colors.purple, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).cardColor, + width: 2, + ), + ), + child: const Icon(Icons.people, size: 8, color: Colors.white), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( children: [ - Icon( - widget.channel.isPublicChannel ? Icons.public : Icons.tag, - size: 20, - ), + _channelIcon(widget.channel), const SizedBox(width: 8), Expanded( child: Column( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 4613a8ee..e97705fc 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -44,11 +44,9 @@ class _ChannelsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); final CommunityStore _communityStore = CommunityStore(); - Timer? _searchDebounce; + final CommunityPskIndex _communityIndex = CommunityPskIndex(); List _communities = []; - - // Cache of PSK hex -> Community for quick lookup - final Map _pskToCommunity = {}; + Timer? _searchDebounce; ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); @@ -71,37 +69,11 @@ class _ChannelsScreenState extends State if (mounted) { setState(() { _communities = communities; - _buildPskCommunityMap(); + _communityIndex.initialize(communities); }); } } - void _buildPskCommunityMap() { - _pskToCommunity.clear(); - for (final community in _communities) { - // Map the community public channel PSK - final publicPsk = community.deriveCommunityPublicPsk(); - _pskToCommunity[Channel.formatPskHex(publicPsk)] = community; - - // Map all known hashtag channel PSKs - for (final hashtag in community.hashtagChannels) { - final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag); - _pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community; - } - } - } - - /// Returns the community this channel belongs to, or null if not a community channel - Community? _getCommunityForChannel(Channel channel) { - return _pskToCommunity[channel.pskHex]; - } - - /// Returns true if this is the community's public channel - bool _isCommunityPublicChannel(Channel channel, Community community) { - final publicPsk = community.deriveCommunityPublicPsk(); - return channel.pskHex == Channel.formatPskHex(publicPsk); - } - @override void dispose() { _searchDebounce?.cancel(); @@ -375,37 +347,37 @@ class _ChannelsScreenState extends State int? dragIndex, }) { final unreadCount = connector.getUnreadCountForChannel(channel); - final community = _getCommunityForChannel(channel); - final isCommunityChannel = community != null; - final isCommunityPublic = - isCommunityChannel && _isCommunityPublicChannel(channel, community); // Determine icon and colors based on channel type IconData icon; Color iconColor; Color bgColor; - - if (isCommunityChannel) { - // Community channel styling - iconColor = Colors.purple; - bgColor = Colors.purple.withValues(alpha: 0.2); - if (isCommunityPublic) { + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + switch (channelType) { + case ChannelType.communityPublic: icon = Icons.groups; - } else { + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.communityHashtag: icon = Icons.tag; - } - } else if (channel.isPublicChannel) { - icon = Icons.public; - iconColor = Colors.green; - bgColor = Colors.green.withValues(alpha: 0.2); - } else if (channel.name.startsWith('#')) { - icon = Icons.tag; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); - } else { - icon = Icons.lock; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.public: + icon = Icons.public; + iconColor = Colors.green; + bgColor = Colors.green.withValues(alpha: 0.2); + case ChannelType.hashtag: + icon = Icons.tag; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); + case ChannelType.private: + icon = Icons.lock; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); } return Card( From 75ae903b99b4f341d5a419ccd4aa813f46e492cf Mon Sep 17 00:00:00 2001 From: ZIER Date: Tue, 12 May 2026 10:24:01 +0200 Subject: [PATCH 24/38] implement flutter_langdetect --- lib/services/translation_service.dart | 86 ++++++--------------------- pubspec.yaml | 1 + 2 files changed, 19 insertions(+), 68 deletions(-) diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 294a7848..148650ca 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:llamadart/llamadart.dart'; +import 'package:flutter_langdetect/flutter_langdetect.dart'; import '../models/app_settings.dart'; import '../models/translation_support.dart'; @@ -41,7 +42,10 @@ class TranslationService extends ChangeNotifier { TranslationService( this._appSettingsService, { TranslationFileStore? fileStore, - }) : _fileStore = fileStore ?? TranslationFileStore(); + }) : _fileStore = fileStore ?? TranslationFileStore() { + // Initialize langdetect once at service construction. + _langDetectInit = initLangDetect(); + } bool _isBusy = false; bool _isDownloading = false; @@ -51,6 +55,7 @@ class TranslationService extends ChangeNotifier { LlamaEngine? _engine; String? _loadedModelPath; String? _failedModelPath; + Future? _langDetectInit; int _downloadedBytes = 0; int? _downloadTotalBytes; String? _downloadFileName; @@ -436,7 +441,18 @@ class TranslationService extends ChangeNotifier { } Future detectLanguage(String text) async { - return _heuristicLanguageCode(text); + try { + // Ensure the detector is initialized (constructor starts init). + await (_langDetectInit ??= initLangDetect()); + final code = detect(text); + if (code.isEmpty) return null; + return code; + } catch (error) { + _lastError = error.toString(); + appLogger.warn('Language detection failed: $error'); + notifyListeners(); + return null; + } } Future _translateText({ @@ -518,72 +534,6 @@ class TranslationService extends ChangeNotifier { trimmed.startsWith('r:')); } - String? _heuristicLanguageCode(String text) { - final trimmed = text.trim(); - if (trimmed.isEmpty) { - return null; - } - - if (RegExp(r'[ぁ-んァ-ン]').hasMatch(text)) { - return 'ja'; - } - if (RegExp(r'[가-힣]').hasMatch(text)) { - return 'ko'; - } - if (RegExp(r'[\u4e00-\u9fff]').hasMatch(text)) { - return 'zh'; - } - - final lower = trimmed.toLowerCase(); - final patterns = { - 'uk': r'\b(привіт|дякую|будь|ласка|як|де|не|так|це|є|най|ще|може|для)\b', - 'ru': - r'\b(что|это|как|не|да|нет|он|она|они|быть|есть|для|сегодня|если|уже|может)\b', - 'bg': r'\b(ще|няма|благодаря|моля|това|какво|тук|ние|вие|не|със|за)\b', - 'de': - r'\b(der|die|das|und|ist|nicht|ein|eine|ich|für|mit|auf|zu|auch|als|an|im|am|es|dem|den|sich|von)\b', - 'en': - r'\b(the|and|is|you|for|with|from|not|that|this|have|be|are|was|were|but|can|will|your|what|when|how|they)\b', - 'es': - r'\b(el|la|los|las|es|que|de|en|con|por|para|no|un|una|se|como|su|al|del|está)\b', - 'fr': - r'\b(le|la|les|un|une|et|est|que|qui|pour|dans|pas|avec|sur|ne|vous|il|elle|des|ce|cette|je|tu|nous|vous)\b', - 'it': - r'\b(il|la|lo|un|una|che|di|da|in|per|con|non|si|mi|ti|noi|voi|lui|lei)\b', - 'pt': - r'\b(os|as|que|de|do|da|em|para|com|por|não|uma|um|se|você|também)\b', - 'nl': - r'\b(de|het|een|en|is|niet|dat|wat|je|ik|op|aan|voor|met|als|nog|zijn)\b', - 'sv': - r'\b(och|är|det|att|som|en|på|inte|har|var|men|du|jag|vi|ni|den|detta)\b', - 'pl': - r'\b(na|się|nie|jest|to|że|do|od|dla|czy|tak|ale|ma|jak|on|ona|my)\b', - 'sk': r'\b(je|na|so|že|do|od|za|si|to|ten|tá|tí|ako|má|nie|som|sa)\b', - 'sl': r'\b(in|je|na|se|da|za|od|ne|to|ta|so|kako|bo|sem|si)\b', - 'hu': - r'\b(az|és|nem|van|volt|hogy|mit|mire|ki|mi|ez|azért|is|de|ha|te|ő|mi|itt)\b', - }; - - final scores = {}; - for (final entry in patterns.entries) { - scores[entry.key] = RegExp( - entry.value, - caseSensitive: false, - ).allMatches(lower).length; - } - - final sorted = scores.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - if (sorted.isEmpty || sorted.first.value == 0) { - return null; - } - if (sorted.length > 1 && sorted.first.value == sorted[1].value) { - return null; - } - - return sorted.first.key; - } - String _languageLabel(String code) { for (final option in supportedTranslationLanguages) { if (option.code == code) { diff --git a/pubspec.yaml b/pubspec.yaml index 7b43dded..72cca35f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: ml_algo: ^16.0.0 ml_dataframe: ^1.0.0 llamadart: '>=0.6.8 <0.7.0' + flutter_langdetect: ^0.0.1 hooks: user_defines: From 5f9259e41f457cdc6c0bbb9108b1da23683a8ad8 Mon Sep 17 00:00:00 2001 From: Stempit Date: Wed, 13 May 2026 01:33:05 +0300 Subject: [PATCH 25/38] Add Russian regional presets --- lib/models/radio_settings.dart | 280 +++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/lib/models/radio_settings.dart b/lib/models/radio_settings.dart index 099d9201..81a65cd1 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -181,6 +181,286 @@ class RadioSettings { txPowerDbm: 14, ), ), + ( + 'Russia Biysk (BSK)', + RadioSettings( + frequencyMHz: 869.000, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Cherepovets (CEE)', + RadioSettings( + frequencyMHz: 868.570, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Chelyabinsk (CEK)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Nizhny Novgorod (GOJ)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Saratov (GSV)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia St. Petersburg (LED)', + RadioSettings( + frequencyMHz: 868.856, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Izhevsk (IJK)', + RadioSettings( + frequencyMHz: 868.732, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Irkutsk (IKT)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ivanovo (IWA)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Khabarovsk (KHV)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tver (KLD)', + RadioSettings( + frequencyMHz: 869.169, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kaluga (KLF)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Samara (KUF)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kirov (KVX)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kazan (KZN)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Lipetsk (LPK)', + RadioSettings( + frequencyMHz: 868.950, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Moscow (MOW)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Novosibirsk (OVB)', + RadioSettings( + frequencyMHz: 869.000, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Rostov-on-Don (ROV)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ryazan (RZN)', + RadioSettings( + frequencyMHz: 868.880, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Yekaterinburg (SVX)', + RadioSettings( + frequencyMHz: 869.046, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tambov (TBW)', + RadioSettings( + frequencyMHz: 868.950, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf10, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tula (TYA)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ufa (UFA)', + RadioSettings( + frequencyMHz: 868.732, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Volgograd (VOG)', + RadioSettings( + frequencyMHz: 869.525, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Voronezh (VOZ)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Lugansk (VSG)', + RadioSettings( + frequencyMHz: 868.825, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Artyom (VVO)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), ( 'Switzerland', RadioSettings( From 352a6c427eb3ffff3c8dbfafaafe8b12df6628ab Mon Sep 17 00:00:00 2001 From: Stempit <3481898+Stempit@users.noreply.github.com> Date: Wed, 13 May 2026 01:49:51 +0300 Subject: [PATCH 26/38] Reorder alphabetically --- lib/models/radio_settings.dart | 150 +++++++++++++++------------------ 1 file changed, 70 insertions(+), 80 deletions(-) diff --git a/lib/models/radio_settings.dart b/lib/models/radio_settings.dart index 81a65cd1..e74cf209 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -181,6 +181,16 @@ class RadioSettings { txPowerDbm: 14, ), ), + ( + 'Russia Artyom (VVO)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), ( 'Russia Biysk (BSK)', RadioSettings( @@ -191,16 +201,6 @@ class RadioSettings { txPowerDbm: 20, ), ), - ( - 'Russia Cherepovets (CEE)', - RadioSettings( - frequencyMHz: 868.570, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), ( 'Russia Chelyabinsk (CEK)', RadioSettings( @@ -212,41 +212,11 @@ class RadioSettings { ), ), ( - 'Russia Nizhny Novgorod (GOJ)', + 'Russia Cherepovets (CEE)', RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Saratov (GSV)', - RadioSettings( - frequencyMHz: 864.281, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia St. Petersburg (LED)', - RadioSettings( - frequencyMHz: 868.856, + frequencyMHz: 868.570, bandwidth: LoRaBandwidth.bw62_5, spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Izhevsk (IJK)', - RadioSettings( - frequencyMHz: 868.732, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, codingRate: LoRaCodingRate.cr4_8, txPowerDbm: 20, ), @@ -272,19 +242,9 @@ class RadioSettings { ), ), ( - 'Russia Khabarovsk (KHV)', + 'Russia Izhevsk (IJK)', RadioSettings( - frequencyMHz: 864.281, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Tver (KLD)', - RadioSettings( - frequencyMHz: 869.169, + frequencyMHz: 868.732, bandwidth: LoRaBandwidth.bw62_5, spreadingFactor: LoRaSpreadingFactor.sf8, codingRate: LoRaCodingRate.cr4_8, @@ -302,12 +262,22 @@ class RadioSettings { ), ), ( - 'Russia Samara (KUF)', + 'Russia Kazan (KZN)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Khabarovsk (KHV)', RadioSettings( frequencyMHz: 864.281, bandwidth: LoRaBandwidth.bw62_5, spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_7, + codingRate: LoRaCodingRate.cr4_6, txPowerDbm: 20, ), ), @@ -321,16 +291,6 @@ class RadioSettings { txPowerDbm: 20, ), ), - ( - 'Russia Kazan (KZN)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), ( 'Russia Lipetsk (LPK)', RadioSettings( @@ -351,6 +311,16 @@ class RadioSettings { txPowerDbm: 20, ), ), + ( + 'Russia Nizhny Novgorod (GOJ)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), ( 'Russia Novosibirsk (OVB)', RadioSettings( @@ -382,9 +352,29 @@ class RadioSettings { ), ), ( - 'Russia Yekaterinburg (SVX)', + 'Russia Samara (KUF)', RadioSettings( - frequencyMHz: 869.046, + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Saratov (GSV)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia St. Petersburg (LED)', + RadioSettings( + frequencyMHz: 868.856, bandwidth: LoRaBandwidth.bw62_5, spreadingFactor: LoRaSpreadingFactor.sf7, codingRate: LoRaCodingRate.cr4_7, @@ -411,6 +401,16 @@ class RadioSettings { txPowerDbm: 20, ), ), + ( + 'Russia Tver (KLD)', + RadioSettings( + frequencyMHz: 869.169, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), ( 'Russia Ufa (UFA)', RadioSettings( @@ -442,25 +442,15 @@ class RadioSettings { ), ), ( - 'Russia Lugansk (VSG)', + 'Russia Yekaterinburg (SVX)', RadioSettings( - frequencyMHz: 868.825, + frequencyMHz: 869.046, bandwidth: LoRaBandwidth.bw62_5, spreadingFactor: LoRaSpreadingFactor.sf7, codingRate: LoRaCodingRate.cr4_7, txPowerDbm: 20, ), ), - ( - 'Russia Artyom (VVO)', - RadioSettings( - frequencyMHz: 864.281, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), ( 'Switzerland', RadioSettings( From f941f0dbfa937417f624d0a51c359f5acee9bebb Mon Sep 17 00:00:00 2001 From: ZIER Date: Wed, 13 May 2026 12:08:41 +0200 Subject: [PATCH 27/38] avoid double translation and strip replyInfo from translated text. --- lib/connector/meshcore_connector.dart | 114 ++++++++++++-------------- 1 file changed, 51 insertions(+), 63 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index ba18d3b1..1f422b88 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1072,7 +1072,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future _translateIncomingContactMessage( + Future _translateIncomingContactMessage( String contactKeyHex, Message message, ) async { @@ -1084,7 +1084,7 @@ class MeshCoreConnector extends ChangeNotifier { isCli: message.isCli, isOutgoing: message.isOutgoing, )) { - return; + return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( _appSettingsService?.settings.languageOverride, @@ -1094,7 +1094,7 @@ class MeshCoreConnector extends ChangeNotifier { targetLanguageCode: targetLanguageCode, ); if (result == null) { - return; + return null; } final translated = result.status == MessageTranslationStatus.completed ? result.translatedText @@ -1109,12 +1109,14 @@ class MeshCoreConnector extends ChangeNotifier { translationModelId: result.modelId, ), ); + return result; } catch (error) { appLogger.warn('Translation failed for contact message: $error'); + return null; } } - Future _translateIncomingChannelMessage( + Future _translateIncomingChannelMessage( int channelIndex, ChannelMessage message, ) async { @@ -1126,7 +1128,7 @@ class MeshCoreConnector extends ChangeNotifier { isCli: false, isOutgoing: message.isOutgoing, )) { - return; + return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( _appSettingsService?.settings.languageOverride, @@ -1136,11 +1138,16 @@ class MeshCoreConnector extends ChangeNotifier { targetLanguageCode: targetLanguageCode, ); if (result == null) { - return; + return null; } - final translated = result.status == MessageTranslationStatus.completed + var translated = result.status == MessageTranslationStatus.completed ? result.translatedText : null; + // Strip replyInfo prefix from translated text to match stored message.text + if (translated != null) { + final regex = RegExp(r'^@\[[^\]]+\]\s+', dotAll: true); + translated = translated.replaceFirst(regex, ''); + } _updateStoredChannelMessage( channelIndex, message.messageId, @@ -1151,8 +1158,10 @@ class MeshCoreConnector extends ChangeNotifier { translationModelId: result.modelId, ), ); + return result; } catch (error) { appLogger.warn('Translation failed for channel message: $error'); + return null; } } @@ -4394,9 +4403,11 @@ class MeshCoreConnector extends ChangeNotifier { } } _addMessage(message.senderKeyHex, message); + TranslationResult? translationResult; if (!message.isOutgoing) { - unawaited( - _translateIncomingContactMessage(message.senderKeyHex, message), + translationResult = await _translateIncomingContactMessage( + message.senderKeyHex, + message, ); } _maybeIncrementContactUnread(message); @@ -4412,10 +4423,9 @@ class MeshCoreConnector extends ChangeNotifier { final msg = message; if (contact?.type == advTypeChat) { unawaited(() async { - final resolvedText = await _maybeTranslateForNotification( - msg.text, - isCli: msg.isCli, - ); + final resolvedText = translationResult != null + ? translationResult.translatedText.trim() + : msg.text; await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown', message: resolvedText, @@ -4429,10 +4439,9 @@ class MeshCoreConnector extends ChangeNotifier { ? msg.text.substring(4) : msg.text; unawaited(() async { - final resolvedText = await _maybeTranslateForNotification( - bodyText, - isCli: msg.isCli, - ); + final resolvedText = translationResult != null + ? translationResult.translatedText.trim() + : bodyText; await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown Room', message: resolvedText, @@ -4708,6 +4717,7 @@ class MeshCoreConnector extends ChangeNotifier { void _maybeNotifyChannelMessage( ChannelMessage message, { String? channelName, + TranslationResult? translationResult, }) { if (message.isOutgoing || _appSettingsService == null) return; final channelIndex = message.channelIndex; @@ -4721,12 +4731,11 @@ class MeshCoreConnector extends ChangeNotifier { final label = channelName ?? _channelDisplayName(channelIndex); if (_appSettingsService!.isChannelMuted(label)) return; - // Translate channel notification text if enabled + // Reuse translation result from earlier translation, or use original text + final resolvedText = translationResult != null + ? translationResult.translatedText.trim() + : message.text; unawaited(() async { - final resolvedText = await _maybeTranslateForNotification( - message.text, - isCli: false, - ); await _notificationService.showChannelMessageNotification( channelName: label, senderName: message.senderName, @@ -4737,41 +4746,7 @@ class MeshCoreConnector extends ChangeNotifier { }()); } - Future _maybeTranslateForNotification( - String text, { - required bool isCli, - }) async { - final service = _translationService; - if (service == null) return text; - try { - if (!service.shouldTranslateIncoming( - text: text, - isCli: isCli, - isOutgoing: false, - )) { - return text; - } - final targetLanguageCode = service.resolvedIncomingLanguageCode( - _appSettingsService?.settings.languageOverride, - ); - if (targetLanguageCode == null || targetLanguageCode.isEmpty) { - return text; - } - final result = await service.translateIncomingText( - text: text, - targetLanguageCode: targetLanguageCode, - ); - final translated = result?.translatedText.trim(); - if (translated != null && translated.isNotEmpty) { - return translated; - } - } catch (_) { - // Fallback to original text on any error - } - return text; - } - - void _handleIncomingChannelMessage(Uint8List frame) { + void _handleIncomingChannelMessage(Uint8List frame) async { final parsed = ChannelMessage.fromFrame(frame); if (parsed != null && parsed.channelIndex != null) { if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { @@ -4790,15 +4765,20 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(message.channelIndex!, message); + TranslationResult? translationResult; if (isNew && !message.isOutgoing) { - unawaited( - _translateIncomingChannelMessage(message.channelIndex!, message), + translationResult = await _translateIncomingChannelMessage( + message.channelIndex!, + message, ); } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); if (isNew) { - _maybeNotifyChannelMessage(message); + _maybeNotifyChannelMessage( + message, + translationResult: translationResult, + ); } _handleQueuedMessageReceived(); } else if (_isSyncingQueuedMessages) { @@ -4806,7 +4786,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - void _handleLogRxData(Uint8List frame) { + void _handleLogRxData(Uint8List frame) async { if (frame.length < 4) return; try { final reader = BufferReader(frame); @@ -4874,8 +4854,12 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(channel.index, message); + TranslationResult? translationResult; if (isNew && !message.isOutgoing) { - unawaited(_translateIncomingChannelMessage(channel.index, message)); + translationResult = await _translateIncomingChannelMessage( + channel.index, + message, + ); } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); @@ -4883,7 +4867,11 @@ class MeshCoreConnector extends ChangeNotifier { final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name; - _maybeNotifyChannelMessage(message, channelName: label); + _maybeNotifyChannelMessage( + message, + channelName: label, + translationResult: translationResult, + ); } return; } catch (e) { From 1fbe1823cbb60953a74a37253ecee797db8f2c1c Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 13 May 2026 18:06:26 +0200 Subject: [PATCH 28/38] only take translated result if status==completed --- lib/connector/meshcore_connector.dart | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 1f422b88..a3dc4650 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4423,9 +4423,13 @@ class MeshCoreConnector extends ChangeNotifier { final msg = message; if (contact?.type == advTypeChat) { unawaited(() async { - final resolvedText = translationResult != null + final resolvedText = + (translationResult != null && + translationResult.status == + MessageTranslationStatus.completed && + translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : msg.text; + : msg.text.trim(); await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown', message: resolvedText, @@ -4439,9 +4443,13 @@ class MeshCoreConnector extends ChangeNotifier { ? msg.text.substring(4) : msg.text; unawaited(() async { - final resolvedText = translationResult != null + final resolvedText = + (translationResult != null && + translationResult.status == + MessageTranslationStatus.completed && + translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : bodyText; + : bodyText.trim(); await _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown Room', message: resolvedText, @@ -4731,10 +4739,13 @@ class MeshCoreConnector extends ChangeNotifier { final label = channelName ?? _channelDisplayName(channelIndex); if (_appSettingsService!.isChannelMuted(label)) return; - // Reuse translation result from earlier translation, or use original text - final resolvedText = translationResult != null + // Reuse translation result only if completed and non-empty; else use original text + final resolvedText = + (translationResult != null && + translationResult.status == MessageTranslationStatus.completed && + translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : message.text; + : message.text.trim(); unawaited(() async { await _notificationService.showChannelMessageNotification( channelName: label, From bc5f2993501d9a5213b8cd813ceed81d3a451422 Mon Sep 17 00:00:00 2001 From: ericz Date: Sun, 17 May 2026 10:47:50 +0200 Subject: [PATCH 29/38] try fix codex sync problem --- lib/connector/meshcore_connector.dart | 109 +++++++++++++------------- 1 file changed, 53 insertions(+), 56 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index a3dc4650..982f7e75 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4338,6 +4338,12 @@ class MeshCoreConnector extends ChangeNotifier { void _handleIncomingMessage(Uint8List frame) async { if (_selfPublicKey == null) return; + // If we're syncing the queued messages, advance the queue immediately + // before any potentially long async work (like translation/notifications). + if (_isSyncingQueuedMessages) { + _handleQueuedMessageReceived(); + } + var message = _parseContactMessage(frame); // If message parsing failed due to unknown contact, refresh contacts and retry @@ -4403,26 +4409,23 @@ class MeshCoreConnector extends ChangeNotifier { } } _addMessage(message.senderKeyHex, message); - TranslationResult? translationResult; - if (!message.isOutgoing) { - translationResult = await _translateIncomingContactMessage( - message.senderKeyHex, - message, - ); - } _maybeIncrementContactUnread(message); notifyListeners(); - // Show notification for new incoming message + // Show notification for new incoming message (run async with translation) if (!message.isOutgoing && !message.isCli && _appSettingsService != null) { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { - // Capture non-null local for async closure type promotion - final msg = message; - if (contact?.type == advTypeChat) { - unawaited(() async { + final msg = message; // capture for closure + final c = contact; // capture contact reference + unawaited(() async { + final translationResult = await _translateIncomingContactMessage( + msg.senderKeyHex, + msg, + ); + if (c?.type == advTypeChat) { final resolvedText = (translationResult != null && translationResult.status == @@ -4431,18 +4434,16 @@ class MeshCoreConnector extends ChangeNotifier { ? translationResult.translatedText.trim() : msg.text.trim(); await _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown', + contactName: c?.name ?? 'Unknown', message: resolvedText, contactId: msg.senderKeyHex, badgeCount: getTotalUnreadCount(), ); - }()); - } else if (contact?.type == advTypeRoom) { - // Room server messages include a 4-char prefix; strip it for notifications - final bodyText = msg.text.length > 4 - ? msg.text.substring(4) - : msg.text; - unawaited(() async { + } else if (c?.type == advTypeRoom) { + // Room server messages include a 4-char prefix; strip it for notifications + final bodyText = msg.text.length > 4 + ? msg.text.substring(4) + : msg.text; final resolvedText = (translationResult != null && translationResult.status == @@ -4451,18 +4452,15 @@ class MeshCoreConnector extends ChangeNotifier { ? translationResult.translatedText.trim() : bodyText.trim(); await _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown Room', + contactName: c?.name ?? 'Unknown Room', message: resolvedText, contactId: msg.senderKeyHex, badgeCount: getTotalUnreadCount(), ); - }()); - } + } + }()); } } - _handleQueuedMessageReceived(); - } else if (_isSyncingQueuedMessages) { - _handleQueuedMessageReceived(); } } @@ -4758,6 +4756,11 @@ class MeshCoreConnector extends ChangeNotifier { } void _handleIncomingChannelMessage(Uint8List frame) async { + // If we're syncing the queued messages, advance the queue immediately + // before any potentially long async work (like translation/notifications). + if (_isSyncingQueuedMessages) { + _handleQueuedMessageReceived(); + } final parsed = ChannelMessage.fromFrame(frame); if (parsed != null && parsed.channelIndex != null) { if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { @@ -4776,24 +4779,18 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(message.channelIndex!, message); - TranslationResult? translationResult; - if (isNew && !message.isOutgoing) { - translationResult = await _translateIncomingChannelMessage( - message.channelIndex!, - message, - ); - } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); - if (isNew) { - _maybeNotifyChannelMessage( - message, - translationResult: translationResult, - ); + if (isNew && !message.isOutgoing) { + final msg = message; // capture for closure + unawaited(() async { + final translationResult = await _translateIncomingChannelMessage( + msg.channelIndex!, + msg, + ); + _maybeNotifyChannelMessage(msg, translationResult: translationResult); + }()); } - _handleQueuedMessageReceived(); - } else if (_isSyncingQueuedMessages) { - _handleQueuedMessageReceived(); } } @@ -4865,24 +4862,24 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: message.pathBytes, ); final isNew = _addChannelMessage(channel.index, message); - TranslationResult? translationResult; - if (isNew && !message.isOutgoing) { - translationResult = await _translateIncomingChannelMessage( - channel.index, - message, - ); - } _maybeIncrementChannelUnread(message, isNew: isNew); notifyListeners(); if (isNew) { - final label = channel.name.isEmpty - ? 'Channel ${channel.index}' - : channel.name; - _maybeNotifyChannelMessage( - message, - channelName: label, - translationResult: translationResult, - ); + // Run translation + notification asynchronously to avoid blocking + unawaited(() async { + final translationResult = await _translateIncomingChannelMessage( + channel.index, + message, + ); + final label = channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name; + _maybeNotifyChannelMessage( + message, + channelName: label, + translationResult: translationResult, + ); + }()); } return; } catch (e) { From 72448f67d0f6210ef04536ef869a39938e0744be Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sun, 17 May 2026 13:20:49 +0300 Subject: [PATCH 30/38] Room-server: fixed first message letters trim --- lib/connector/meshcore_connector.dart | 28 +++++++++++++++------------ lib/screens/chat_screen.dart | 12 +++++------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 9a316897..7cbc8ff0 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4462,9 +4462,7 @@ class MeshCoreConnector extends ChangeNotifier { } else if (contact?.type == advTypeRoom) { _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown Room', - message: message.text.length > 4 - ? message.text.substring(4) - : message.text, + message: message.text, contactId: message.senderKeyHex, badgeCount: getTotalUnreadCount(), ); @@ -4511,16 +4509,24 @@ class MeshCoreConnector extends ChangeNotifier { timestampRaw * 1000, ); - if (txtType == 2) { - reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants + final flags = txtType; + final shiftedType = flags >> 2; + final rawType = flags; + final isSigned = shiftedType == txtTypeSigned || rawType == txtTypeSigned; + final Uint8List? roomAuthorPrefix; + if (isSigned) { + // Room-server pushed posts use signed/plain contact messages where this + // 4-byte "signature" field is actually the original author's pubkey + // prefix. Keep it as metadata; the text starts after these bytes. + roomAuthorPrefix = reader.readBytes(4); + } else { + roomAuthorPrefix = null; } final msgText = reader.readCString(); - final flags = txtType; - final shiftedType = flags >> 2; - final rawType = flags; - final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain; + final isPlain = + shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned; final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; if (!isPlain && !isCli) { appLogger.warn( @@ -4557,9 +4563,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLength == 0xFF ? 0 : pathLength, pathBytes: Uint8List(0), - fourByteRoomContactKey: msgText.length >= 4 - ? Uint8List.fromList(msgText.substring(0, 4).codeUnits) - : null, + fourByteRoomContactKey: roomAuthorPrefix, ); } catch (e) { appLogger.warn('Error parsing contact direct message: $e'); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 8d3bc66c..1548b44e 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -485,6 +485,8 @@ class _ChatScreenState extends State { final message = reversedMessages[messageIndex]; String fourByteHex = ''; if (contact.type == advTypeRoom) { + // Room-server messages carry the original author's 4-byte prefix + // separately from message.text; use it only for resolving the name. contact = _resolveContactFrom4Bytes( connector, message.fourByteRoomContactKey.isEmpty @@ -509,7 +511,6 @@ class _ChatScreenState extends State { ? "${contact.name} [$fourByteHex]" : contact.name, sourceId: widget.contact.publicKeyHex, - isRoomServer: resolvedContact.type == advTypeRoom, textScale: textScale, onTap: () => _openMessagePath(message, contact), onLongPress: () => _showMessageActions(message, contact), @@ -1721,7 +1722,6 @@ class _ChatScreenState extends State { class _MessageBubble extends StatelessWidget { final Message message; final String senderName; - final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; final void Function(Message message, String emoji)? onRetryReaction; @@ -1732,7 +1732,6 @@ class _MessageBubble extends StatelessWidget { required this.message, required this.senderName, required this.sourceId, - required this.isRoomServer, required this.textScale, this.onTap, this.onLongPress, @@ -1758,10 +1757,9 @@ class _MessageBubble extends StatelessWidget { : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); const bodyFontSize = 14.0; - String messageText = message.text; - if (isRoomServer && !isOutgoing) { - messageText = message.text.substring(4.clamp(0, message.text.length)); - } + // Do not strip room-server author bytes here: the parser stores them in + // fourByteRoomContactKey, so message.text is safe to render as-is. + final messageText = message.text; final translatedDisplayText = message.translatedText != null && message.translatedText!.trim().isNotEmpty From 9ada4ea601513a4f49d1508a99c7307f08418395 Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 20 May 2026 21:24:54 +0200 Subject: [PATCH 31/38] add toggle for autmatically translated messages for notification and chat or manual translation on message action. Due to heavy battery usage. --- lib/connector/meshcore_connector.dart | 62 ++++++++++++++++++-------- lib/l10n/app_bg.arb | 3 ++ lib/l10n/app_de.arb | 3 ++ lib/l10n/app_en.arb | 3 ++ lib/l10n/app_es.arb | 3 ++ lib/l10n/app_fr.arb | 3 ++ lib/l10n/app_hu.arb | 3 ++ lib/l10n/app_it.arb | 3 ++ lib/l10n/app_ja.arb | 3 ++ lib/l10n/app_ko.arb | 3 ++ lib/l10n/app_localizations.dart | 18 ++++++++ lib/l10n/app_localizations_bg.dart | 10 +++++ lib/l10n/app_localizations_de.dart | 11 +++++ lib/l10n/app_localizations_en.dart | 11 +++++ lib/l10n/app_localizations_es.dart | 11 +++++ lib/l10n/app_localizations_fr.dart | 11 +++++ lib/l10n/app_localizations_hu.dart | 10 +++++ lib/l10n/app_localizations_it.dart | 11 +++++ lib/l10n/app_localizations_ja.dart | 10 +++++ lib/l10n/app_localizations_ko.dart | 10 +++++ lib/l10n/app_localizations_nl.dart | 10 +++++ lib/l10n/app_localizations_pl.dart | 11 +++++ lib/l10n/app_localizations_pt.dart | 11 +++++ lib/l10n/app_localizations_ru.dart | 11 +++++ lib/l10n/app_localizations_sk.dart | 10 +++++ lib/l10n/app_localizations_sl.dart | 10 +++++ lib/l10n/app_localizations_sv.dart | 11 +++++ lib/l10n/app_localizations_uk.dart | 11 +++++ lib/l10n/app_localizations_zh.dart | 9 ++++ lib/l10n/app_nl.arb | 3 ++ lib/l10n/app_pl.arb | 3 ++ lib/l10n/app_pt.arb | 3 ++ lib/l10n/app_ru.arb | 3 ++ lib/l10n/app_sk.arb | 3 ++ lib/l10n/app_sl.arb | 3 ++ lib/l10n/app_sv.arb | 3 ++ lib/l10n/app_uk.arb | 3 ++ lib/l10n/app_zh.arb | 3 ++ lib/models/app_settings.dart | 8 ++++ lib/screens/app_settings_screen.dart | 39 ++++++++++++++-- lib/screens/channel_chat_screen.dart | 28 ++++++++++++ lib/screens/chat_screen.dart | 27 +++++++++++ lib/services/app_settings_service.dart | 6 +++ lib/services/translation_service.dart | 17 ++++++- 44 files changed, 424 insertions(+), 24 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 982f7e75..7eca6e88 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1072,18 +1072,30 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future _translateIncomingContactMessage( + Future translateContactMessage( String contactKeyHex, - Message message, - ) async { + Message message, { + bool manualTranslation = false, + }) async { try { + if (message.translatedText?.trim().isNotEmpty == true || + (!manualTranslation && + message.translationStatus != MessageTranslationStatus.none)) { + return null; + } final service = _translationService; if (service == null || - !service.shouldTranslateIncoming( - text: message.text, - isCli: message.isCli, - isOutgoing: message.isOutgoing, - )) { + !(manualTranslation + ? service.canTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + ) + : service.shouldAutoTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + ))) { return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( @@ -1116,18 +1128,30 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future _translateIncomingChannelMessage( + Future translateChannelMessage( int channelIndex, - ChannelMessage message, - ) async { + ChannelMessage message, { + bool manualTranslation = false, + }) async { try { + if (message.translatedText?.trim().isNotEmpty == true || + (!manualTranslation && + message.translationStatus != MessageTranslationStatus.none)) { + return null; + } final service = _translationService; if (service == null || - !service.shouldTranslateIncoming( - text: message.text, - isCli: false, - isOutgoing: message.isOutgoing, - )) { + !(manualTranslation + ? service.canTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ) + : service.shouldAutoTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ))) { return null; } final targetLanguageCode = service.resolvedIncomingLanguageCode( @@ -4421,7 +4445,7 @@ class MeshCoreConnector extends ChangeNotifier { final msg = message; // capture for closure final c = contact; // capture contact reference unawaited(() async { - final translationResult = await _translateIncomingContactMessage( + final translationResult = await translateContactMessage( msg.senderKeyHex, msg, ); @@ -4784,7 +4808,7 @@ class MeshCoreConnector extends ChangeNotifier { if (isNew && !message.isOutgoing) { final msg = message; // capture for closure unawaited(() async { - final translationResult = await _translateIncomingChannelMessage( + final translationResult = await translateChannelMessage( msg.channelIndex!, msg, ); @@ -4867,7 +4891,7 @@ class MeshCoreConnector extends ChangeNotifier { if (isNew) { // Run translation + notification asynchronously to avoid blocking unawaited(() async { - final translationResult = await _translateIncomingChannelMessage( + final translationResult = await translateChannelMessage( channel.index, message, ); diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 45536cf6..c70ca78d 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -2099,6 +2099,9 @@ "translation_composerTitle": "Преведете преди да изпратите", "translation_enableSubtitle": "Превеждайте входящите съобщения и позволявайте предварително превеждане преди изпращане.", "translation_composerSubtitle": "Контролира началния статус на иконата за превод, създадена от композитора.", + "translation_autoIncomingTitle": "Автоматичен превод на съобщения", + "translation_autoIncomingSubtitle": "Превежда автоматично съобщенията за известия, както и за чатове или канали.", + "translation_translateMessage": "Преведи съобщението", "translation_targetLanguage": "Целеви език", "translation_useAppLanguage": "Използвайте езика на приложението", "translation_downloadedModelLabel": "Изтегнат модел", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7f491d4f..4a9be70b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2127,6 +2127,9 @@ "translation_enableSubtitle": "Nachrichten empfangen und übersetzen sowie die Möglichkeit bieten, Nachrichten vor dem Versenden zu übersetzen.", "translation_enableTitle": "Aktivieren Sie die Übersetzung", "translation_composerSubtitle": "Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.", + "translation_autoIncomingTitle": "Nachrichten automatisch übersetzen", + "translation_autoIncomingSubtitle": "Übersetzt Nachrichten für Benachrichtigungen sowie für Chats oder Kanäle automatisch.", + "translation_translateMessage": "Nachricht übersetzen", "translation_targetLanguage": "Zielsprache", "translation_useAppLanguage": "Verwenden Sie die App-Sprache", "translation_downloadedModelLabel": "Heruntergeladenes Modell", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 456fab56..b3c5774a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2288,6 +2288,9 @@ "translation_enableSubtitle": "Translate incoming messages and allow pre-send translation.", "translation_composerTitle": "Translate before sending", "translation_composerSubtitle": "Controls the default state of the composer translation icon.", + "translation_autoIncomingTitle": "Auto-translate incoming messages", + "translation_autoIncomingSubtitle": "Translates Messages for notification and for chat or channel automatically.", + "translation_translateMessage": "Translate message", "translation_targetLanguage": "Target language", "translation_useAppLanguage": "Use app language", "translation_downloadedModelLabel": "Downloaded model", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d4cf27da..b85e7652 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2128,6 +2128,9 @@ "translation_enableTitle": "Habilitar la traducción", "translation_composerTitle": "Traducir antes de enviar", "translation_composerSubtitle": "Controla el estado predeterminado del icono de traducción del compositor.", + "translation_autoIncomingTitle": "Traducir mensajes automáticamente", + "translation_autoIncomingSubtitle": "Traduce mensajes para notificaciones y para chats o canales automáticamente.", + "translation_translateMessage": "Traducir mensaje", "translation_targetLanguage": "Idioma de destino", "translation_useAppLanguage": "Utilizar el idioma de la aplicación", "translation_downloadedModelLabel": "Modelo descargado", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7817c20d..fd6b53e0 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2099,6 +2099,9 @@ "translation_title": "Traduction", "translation_enableSubtitle": "Traduire les messages entrants et permettre la traduction avant l'envoi.", "translation_composerSubtitle": "Contrôle l'état par défaut de l'icône de traduction du composant.", + "translation_autoIncomingTitle": "Traduire automatiquement les messages", + "translation_autoIncomingSubtitle": "Traduit automatiquement les messages pour les notifications et pour les discussions ou les canaux.", + "translation_translateMessage": "Traduire le message", "translation_targetLanguage": "Langue cible", "translation_useAppLanguage": "Utiliser la langue de l'application", "translation_downloadedModelLabel": "Modèle téléchargé", diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 97426183..d41c62f2 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -2137,6 +2137,9 @@ "translation_enableSubtitle": "Fordítsa az érkező üzeneteket, és lehetővé tegye a küldés előtti fordítást.", "translation_composerTitle": "Fordítsa el, mielőtt elküldi", "translation_composerSubtitle": "Ellenőrzi a zeneszerző fordítási ikon alapértékét.", + "translation_autoIncomingTitle": "Üzenetek automatikus fordítása", + "translation_autoIncomingSubtitle": "Automatikusan lefordítja az üzeneteket az értesítésekhez, valamint a csevegésekhez vagy csatornákhoz.", + "translation_translateMessage": "Üzenet fordítása", "translation_targetLanguage": "Célnyelv", "translation_useAppLanguage": "Használja az alkalmazás nyelvének beállítását.", "translation_downloadedModelLabel": "Letöltött modell", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 664bceef..e60cfc4a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2100,6 +2100,9 @@ "translation_enableTitle": "Abilitare la traduzione", "translation_title": "Traduzione", "translation_composerSubtitle": "Controlla lo stato predefinito dell'icona di traduzione del compositore.", + "translation_autoIncomingTitle": "Traduci automaticamente i messaggi", + "translation_autoIncomingSubtitle": "Traduce automaticamente i messaggi per le notifiche e per le chat o i canali.", + "translation_translateMessage": "Traduci messaggio", "translation_targetLanguage": "Lingua di destinazione", "translation_useAppLanguage": "Utilizza la lingua dell'app", "translation_downloadedModelLabel": "Modello scaricato", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9d2cef21..c68fc98d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2137,6 +2137,9 @@ "translation_composerTitle": "送信する前に翻訳する", "translation_enableTitle": "翻訳機能を有効にする", "translation_composerSubtitle": "作曲家翻訳アイコンのデフォルト状態を制御する。", + "translation_autoIncomingTitle": "メッセージを自動翻訳", + "translation_autoIncomingSubtitle": "通知やチャット、チャンネルのメッセージを自動的に翻訳します。", + "translation_translateMessage": "メッセージを翻訳", "translation_targetLanguage": "翻訳対象言語", "translation_useAppLanguage": "アプリの言語設定", "translation_downloadedModelLabel": "ダウンロードしたモデル", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d778429b..7418068c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2137,6 +2137,9 @@ "translation_enableTitle": "번역 기능 활성화", "translation_composerTitle": "보내기 전에 번역", "translation_composerSubtitle": "컴포저 번역 아이콘의 기본 상태를 제어합니다.", + "translation_autoIncomingTitle": "메시지 자동 번역", + "translation_autoIncomingSubtitle": "알림과 채팅 또는 채널의 메시지를 자동으로 번역합니다.", + "translation_translateMessage": "메시지 번역", "translation_targetLanguage": "목표 언어", "translation_useAppLanguage": "앱 언어 사용", "translation_downloadedModelLabel": "다운로드한 모델", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 159d755c..b8c6558c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7114,6 +7114,24 @@ abstract class AppLocalizations { /// **'Controls the default state of the composer translation icon.'** String get translation_composerSubtitle; + /// No description provided for @translation_autoIncomingTitle. + /// + /// In en, this message translates to: + /// **'Auto-translate incoming messages'** + String get translation_autoIncomingTitle; + + /// No description provided for @translation_autoIncomingSubtitle. + /// + /// In en, this message translates to: + /// **'Translates Messages for notification and for chat or channel automatically.'** + String get translation_autoIncomingSubtitle; + + /// No description provided for @translation_translateMessage. + /// + /// In en, this message translates to: + /// **'Translate message'** + String get translation_translateMessage; + /// No description provided for @translation_targetLanguage. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 409803b2..19883f16 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4161,6 +4161,16 @@ class AppLocalizationsBg extends AppLocalizations { String get translation_composerSubtitle => 'Контролира началния статус на иконата за превод, създадена от композитора.'; + @override + String get translation_autoIncomingTitle => 'Автоматичен превод на съобщения'; + + @override + String get translation_autoIncomingSubtitle => + 'Превежда автоматично съобщенията за известия, както и за чатове или канали.'; + + @override + String get translation_translateMessage => 'Преведи съобщението'; + @override String get translation_targetLanguage => 'Целеви език'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9f06906d..20d64c39 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4176,6 +4176,17 @@ class AppLocalizationsDe extends AppLocalizations { String get translation_composerSubtitle => 'Steuert den Standardzustand des Icons für die Übersetzung des Komponisten.'; + @override + String get translation_autoIncomingTitle => + 'Nachrichten automatisch übersetzen'; + + @override + String get translation_autoIncomingSubtitle => + 'Übersetzt Nachrichten für Benachrichtigungen sowie für Chats oder Kanäle automatisch.'; + + @override + String get translation_translateMessage => 'Nachricht übersetzen'; + @override String get translation_targetLanguage => 'Zielsprache'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6def06b7..5da877e1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4085,6 +4085,17 @@ class AppLocalizationsEn extends AppLocalizations { String get translation_composerSubtitle => 'Controls the default state of the composer translation icon.'; + @override + String get translation_autoIncomingTitle => + 'Auto-translate incoming messages'; + + @override + String get translation_autoIncomingSubtitle => + 'Translates Messages for notification and for chat or channel automatically.'; + + @override + String get translation_translateMessage => 'Translate message'; + @override String get translation_targetLanguage => 'Target language'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index bb4f40e8..f55bb1c7 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4163,6 +4163,17 @@ class AppLocalizationsEs extends AppLocalizations { String get translation_composerSubtitle => 'Controla el estado predeterminado del icono de traducción del compositor.'; + @override + String get translation_autoIncomingTitle => + 'Traducir mensajes automáticamente'; + + @override + String get translation_autoIncomingSubtitle => + 'Traduce mensajes para notificaciones y para chats o canales automáticamente.'; + + @override + String get translation_translateMessage => 'Traducir mensaje'; + @override String get translation_targetLanguage => 'Idioma de destino'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 44e40657..a1770dc3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4193,6 +4193,17 @@ class AppLocalizationsFr extends AppLocalizations { String get translation_composerSubtitle => 'Contrôle l\'état par défaut de l\'icône de traduction du composant.'; + @override + String get translation_autoIncomingTitle => + 'Traduire automatiquement les messages'; + + @override + String get translation_autoIncomingSubtitle => + 'Traduit automatiquement les messages pour les notifications et pour les discussions ou les canaux.'; + + @override + String get translation_translateMessage => 'Traduire le message'; + @override String get translation_targetLanguage => 'Langue cible'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 298a6fd6..ee41febf 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4181,6 +4181,16 @@ class AppLocalizationsHu extends AppLocalizations { String get translation_composerSubtitle => 'Ellenőrzi a zeneszerző fordítási ikon alapértékét.'; + @override + String get translation_autoIncomingTitle => 'Üzenetek automatikus fordítása'; + + @override + String get translation_autoIncomingSubtitle => + 'Automatikusan lefordítja az üzeneteket az értesítésekhez, valamint a csevegésekhez vagy csatornákhoz.'; + + @override + String get translation_translateMessage => 'Üzenet fordítása'; + @override String get translation_targetLanguage => 'Célnyelv'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index e3f7ee58..55804809 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4169,6 +4169,17 @@ class AppLocalizationsIt extends AppLocalizations { String get translation_composerSubtitle => 'Controlla lo stato predefinito dell\'icona di traduzione del compositore.'; + @override + String get translation_autoIncomingTitle => + 'Traduci automaticamente i messaggi'; + + @override + String get translation_autoIncomingSubtitle => + 'Traduce automaticamente i messaggi per le notifiche e per le chat o i canali.'; + + @override + String get translation_translateMessage => 'Traduci messaggio'; + @override String get translation_targetLanguage => 'Lingua di destinazione'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 263a906e..844f3b66 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3941,6 +3941,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String get translation_composerSubtitle => '作曲家翻訳アイコンのデフォルト状態を制御する。'; + @override + String get translation_autoIncomingTitle => 'メッセージを自動翻訳'; + + @override + String get translation_autoIncomingSubtitle => + '通知やチャット、チャンネルのメッセージを自動的に翻訳します。'; + + @override + String get translation_translateMessage => 'メッセージを翻訳'; + @override String get translation_targetLanguage => '翻訳対象言語'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f9c0d760..1bc02940 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3942,6 +3942,16 @@ class AppLocalizationsKo extends AppLocalizations { @override String get translation_composerSubtitle => '컴포저 번역 아이콘의 기본 상태를 제어합니다.'; + @override + String get translation_autoIncomingTitle => '메시지 자동 번역'; + + @override + String get translation_autoIncomingSubtitle => + '알림과 채팅 또는 채널의 메시지를 자동으로 번역합니다.'; + + @override + String get translation_translateMessage => '메시지 번역'; + @override String get translation_targetLanguage => '목표 언어'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ad140bf3..cf78b5e7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4146,6 +4146,16 @@ class AppLocalizationsNl extends AppLocalizations { String get translation_composerSubtitle => 'Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.'; + @override + String get translation_autoIncomingTitle => 'Berichten automatisch vertalen'; + + @override + String get translation_autoIncomingSubtitle => + 'Vertaalt berichten automatisch voor meldingen en voor chats of kanalen.'; + + @override + String get translation_translateMessage => 'Bericht vertalen'; + @override String get translation_targetLanguage => 'Doeltaal'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 6c7c3696..e4b33f9f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4184,6 +4184,17 @@ class AppLocalizationsPl extends AppLocalizations { String get translation_composerSubtitle => 'Kontroluje domyślny stan ikony tłumaczenia w edytorze.'; + @override + String get translation_autoIncomingTitle => + 'Automatycznie tłumacz wiadomości'; + + @override + String get translation_autoIncomingSubtitle => + 'Automatycznie tłumaczy wiadomości do powiadomień oraz do czatów lub kanałów.'; + + @override + String get translation_translateMessage => 'Przetłumacz wiadomość'; + @override String get translation_targetLanguage => 'Język docelowy'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index e6fd49e3..f0ad632b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4159,6 +4159,17 @@ class AppLocalizationsPt extends AppLocalizations { String get translation_composerSubtitle => 'Controla o estado padrão do ícone de tradução do compositor.'; + @override + String get translation_autoIncomingTitle => + 'Traduzir mensagens automaticamente'; + + @override + String get translation_autoIncomingSubtitle => + 'Traduz automaticamente mensagens para notificações e para chats ou canais.'; + + @override + String get translation_translateMessage => 'Traduzir mensagem'; + @override String get translation_targetLanguage => 'Língua-alvo'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 25452003..46ef46b6 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4177,6 +4177,17 @@ class AppLocalizationsRu extends AppLocalizations { String get translation_composerSubtitle => 'Управляет исходным состоянием значка перевода, предоставляемого редактором.'; + @override + String get translation_autoIncomingTitle => + 'Автоматически переводить сообщения'; + + @override + String get translation_autoIncomingSubtitle => + 'Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.'; + + @override + String get translation_translateMessage => 'Перевести сообщение'; + @override String get translation_targetLanguage => 'Целевой язык'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 91461385..29a0b8a9 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4141,6 +4141,16 @@ class AppLocalizationsSk extends AppLocalizations { String get translation_composerSubtitle => 'Riadi výchoce stav ikony pre preklad, ktorú používa program.'; + @override + String get translation_autoIncomingTitle => 'Automaticky prekladať správy'; + + @override + String get translation_autoIncomingSubtitle => + 'Automaticky prekladá správy pre upozornenia aj pre čet alebo kanál.'; + + @override + String get translation_translateMessage => 'Preložiť správu'; + @override String get translation_targetLanguage => 'Cieľový jazyk'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 48103647..125aa604 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4139,6 +4139,16 @@ class AppLocalizationsSl extends AppLocalizations { String get translation_composerSubtitle => 'Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.'; + @override + String get translation_autoIncomingTitle => 'Samodejno prevajaj sporočila'; + + @override + String get translation_autoIncomingSubtitle => + 'Samodejno prevaja sporočila za obvestila ter za klepete ali kanale.'; + + @override + String get translation_translateMessage => 'Prevedi sporočilo'; + @override String get translation_targetLanguage => 'Ciljna jezika'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 7453c18f..4bf43719 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4113,6 +4113,17 @@ class AppLocalizationsSv extends AppLocalizations { String get translation_composerSubtitle => 'Styr standardtillståndet för kompositorns översättningsikon.'; + @override + String get translation_autoIncomingTitle => + 'Översätt meddelanden automatiskt'; + + @override + String get translation_autoIncomingSubtitle => + 'Översätter meddelanden automatiskt för aviseringar och för chattar eller kanaler.'; + + @override + String get translation_translateMessage => 'Översätt meddelande'; + @override String get translation_targetLanguage => 'Målmedvetet språk'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 9c30fd8e..58f381cf 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4176,6 +4176,17 @@ class AppLocalizationsUk extends AppLocalizations { String get translation_composerSubtitle => 'Контролює стан ікон перекладу, який використовується за замовчуванням.'; + @override + String get translation_autoIncomingTitle => + 'Автоматично перекладати повідомлення'; + + @override + String get translation_autoIncomingSubtitle => + 'Автоматично перекладає повідомлення для сповіщень, а також для чатів і каналів.'; + + @override + String get translation_translateMessage => 'Перекласти повідомлення'; + @override String get translation_targetLanguage => 'Цільова мова'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d2304a66..ec03b8e8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3816,6 +3816,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get translation_composerSubtitle => '控制作曲家翻译图标的默认状态。'; + @override + String get translation_autoIncomingTitle => '自动翻译消息'; + + @override + String get translation_autoIncomingSubtitle => '自动为通知以及聊天或频道翻译消息。'; + + @override + String get translation_translateMessage => '翻译消息'; + @override String get translation_targetLanguage => '目标语言'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 7e83215b..68d53148 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2101,6 +2101,9 @@ "translation_composerTitle": "Vertaal voor verzending", "translation_composerSubtitle": "Stelt de standaardstatus van het pictogram voor de vertaling van de componist in.", "translation_useAppLanguage": "Gebruik de taal van de app", + "translation_autoIncomingTitle": "Berichten automatisch vertalen", + "translation_autoIncomingSubtitle": "Vertaalt berichten automatisch voor meldingen en voor chats of kanalen.", + "translation_translateMessage": "Bericht vertalen", "translation_targetLanguage": "Doeltaal", "translation_downloadedModelLabel": "Gedownloade model", "translation_presetModelLabel": "Voorgeprogrammeerd Hugging Face-model", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 4366e273..f79a589a 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2137,6 +2137,9 @@ "translation_enableTitle": "Włącz tłumaczenie", "translation_enableSubtitle": "Tłumaczenie otrzymywanych wiadomości oraz umożliwienie tłumaczenia przed wysłaniem.", "translation_composerSubtitle": "Kontroluje domyślny stan ikony tłumaczenia w edytorze.", + "translation_autoIncomingTitle": "Automatycznie tłumacz wiadomości", + "translation_autoIncomingSubtitle": "Automatycznie tłumaczy wiadomości do powiadomień oraz do czatów lub kanałów.", + "translation_translateMessage": "Przetłumacz wiadomość", "translation_targetLanguage": "Język docelowy", "translation_useAppLanguage": "Użyj języka aplikacji", "translation_downloadedModelLabel": "Pobudowany model", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6b8abbbc..994ff8a2 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2100,6 +2100,9 @@ "translation_enableTitle": "Ativar a tradução", "translation_title": "Tradução", "translation_composerSubtitle": "Controla o estado padrão do ícone de tradução do compositor.", + "translation_autoIncomingTitle": "Traduzir mensagens automaticamente", + "translation_autoIncomingSubtitle": "Traduz automaticamente mensagens para notificações e para chats ou canais.", + "translation_translateMessage": "Traduzir mensagem", "translation_targetLanguage": "Língua-alvo", "translation_useAppLanguage": "Utilize o idioma da aplicação", "translation_downloadedModelLabel": "Modelo baixado", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5d5bcfc3..2b0978a6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1268,6 +1268,9 @@ "translation_title": "Перевод", "translation_enableTitle": "Включить перевод", "translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.", + "translation_autoIncomingTitle": "Автоматически переводить сообщения", + "translation_autoIncomingSubtitle": "Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.", + "translation_translateMessage": "Перевести сообщение", "translation_targetLanguage": "Целевой язык", "translation_useAppLanguage": "Используйте язык приложения", "translation_downloadedModelLabel": "Загруженная модель", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 713c3976..1a65b35f 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -2100,6 +2100,9 @@ "translation_composerTitle": "Preložte pred odeslaním", "translation_title": "Preklad", "translation_composerSubtitle": "Riadi výchoce stav ikony pre preklad, ktorú používa program.", + "translation_autoIncomingTitle": "Automaticky prekladať správy", + "translation_autoIncomingSubtitle": "Automaticky prekladá správy pre upozornenia aj pre čet alebo kanál.", + "translation_translateMessage": "Preložiť správu", "translation_targetLanguage": "Cieľový jazyk", "translation_useAppLanguage": "Použite jazyk aplikácie", "translation_downloadedModelLabel": "Stiahnutý model", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 00930c85..eff2db17 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -2099,6 +2099,9 @@ "translation_enableSubtitle": "Prevedite vstopne sporočila in omogočite predhodno prevajanje.", "translation_enableTitle": "Omogočite prevod", "translation_composerSubtitle": "Ureja privzeto stanje ikone za prevod, ki jo uporablja avtor.", + "translation_autoIncomingTitle": "Samodejno prevajaj sporočila", + "translation_autoIncomingSubtitle": "Samodejno prevaja sporočila za obvestila ter za klepete ali kanale.", + "translation_translateMessage": "Prevedi sporočilo", "translation_targetLanguage": "Ciljna jezika", "translation_useAppLanguage": "Uporabite jezik aplikacije", "translation_downloadedModelLabel": "Naložen model", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 00fd3b08..044abbc7 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -2100,6 +2100,9 @@ "translation_title": "Översättning", "translation_composerTitle": "Översätt innan du skickar", "translation_composerSubtitle": "Styr standardtillståndet för kompositorns översättningsikon.", + "translation_autoIncomingTitle": "Översätt meddelanden automatiskt", + "translation_autoIncomingSubtitle": "Översätter meddelanden automatiskt för aviseringar och för chattar eller kanaler.", + "translation_translateMessage": "Översätt meddelande", "translation_targetLanguage": "Målmedvetet språk", "translation_useAppLanguage": "Använd appens språk", "translation_downloadedModelLabel": "Nedladdad modell", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 8330d364..37300095 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2109,6 +2109,9 @@ "translation_enableTitle": "Увімкнути переклад", "translation_enableSubtitle": "Перекладати отримані повідомлення та дозволяти попередній переклад перед відправкою.", "translation_composerSubtitle": "Контролює стан ікон перекладу, який використовується за замовчуванням.", + "translation_autoIncomingTitle": "Автоматично перекладати повідомлення", + "translation_autoIncomingSubtitle": "Автоматично перекладає повідомлення для сповіщень, а також для чатів і каналів.", + "translation_translateMessage": "Перекласти повідомлення", "translation_targetLanguage": "Цільова мова", "translation_useAppLanguage": "Використовувати мову застосунку", "translation_downloadedModelLabel": "Завантажений шаблон", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1e289315..5603034f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2105,6 +2105,9 @@ "translation_composerTitle": "在发送之前进行翻译", "translation_enableTitle": "启用翻译功能", "translation_composerSubtitle": "控制作曲家翻译图标的默认状态。", + "translation_autoIncomingTitle": "自动翻译消息", + "translation_autoIncomingSubtitle": "自动为通知以及聊天或频道翻译消息。", + "translation_translateMessage": "翻译消息", "translation_targetLanguage": "目标语言", "translation_useAppLanguage": "使用应用程序语言", "translation_downloadedModelLabel": "下载的模型", diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 4e95311f..6995b6ed 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -113,6 +113,7 @@ class AppSettings { final int tcpServerPort; final bool jumpToOldestUnread; final bool translationEnabled; + final bool autoTranslateIncomingMessages; final String? translationTargetLanguageCode; final bool composerTranslationEnabled; final String? translationModelSourceUrl; @@ -166,6 +167,7 @@ class AppSettings { this.tcpServerPort = 0, this.jumpToOldestUnread = false, this.translationEnabled = false, + this.autoTranslateIncomingMessages = true, this.translationTargetLanguageCode, this.composerTranslationEnabled = false, this.translationModelSourceUrl, @@ -226,6 +228,7 @@ class AppSettings { 'tcp_server_port': tcpServerPort, 'jump_to_oldest_unread': jumpToOldestUnread, 'translation_enabled': translationEnabled, + 'auto_translate_incoming_messages': autoTranslateIncomingMessages, 'translation_target_language_code': translationTargetLanguageCode, 'composer_translation_enabled': composerTranslationEnabled, 'translation_model_source_url': translationModelSourceUrl, @@ -307,6 +310,8 @@ class AppSettings { tcpServerPort: json['tcp_server_port'] as int? ?? 0, jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false, translationEnabled: json['translation_enabled'] as bool? ?? false, + autoTranslateIncomingMessages: + json['auto_translate_incoming_messages'] as bool? ?? true, translationTargetLanguageCode: json['translation_target_language_code'] as String?, composerTranslationEnabled: @@ -396,6 +401,7 @@ class AppSettings { int? tcpServerPort, bool? jumpToOldestUnread, bool? translationEnabled, + bool? autoTranslateIncomingMessages, Object? translationTargetLanguageCode = _unset, bool? composerTranslationEnabled, Object? translationModelSourceUrl = _unset, @@ -453,6 +459,8 @@ class AppSettings { tcpServerPort: tcpServerPort ?? this.tcpServerPort, jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread, translationEnabled: translationEnabled ?? this.translationEnabled, + autoTranslateIncomingMessages: + autoTranslateIncomingMessages ?? this.autoTranslateIncomingMessages, translationTargetLanguageCode: translationTargetLanguageCode == _unset ? this.translationTargetLanguageCode : translationTargetLanguageCode as String?, diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 94b3efe8..4d791381 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -559,6 +559,7 @@ class AppSettingsScreen extends StatelessWidget { TranslationService translationService, ) { final settings = settingsService.settings; + final translationEnabled = settings.translationEnabled; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -579,11 +580,41 @@ class AppSettingsScreen extends StatelessWidget { ), const Divider(height: 1), SwitchListTile( - secondary: const Icon(Icons.outgoing_mail), - title: Text(context.l10n.translation_composerTitle), - subtitle: Text(context.l10n.translation_composerSubtitle), + secondary: Icon( + Icons.auto_awesome_outlined, + color: translationEnabled ? null : Colors.grey, + ), + title: Text( + context.l10n.translation_autoIncomingTitle, + style: TextStyle(color: translationEnabled ? null : Colors.grey), + ), + subtitle: Text( + context.l10n.translation_autoIncomingSubtitle, + style: TextStyle(color: translationEnabled ? null : Colors.grey), + ), + value: settings.autoTranslateIncomingMessages, + onChanged: translationEnabled + ? settingsService.setAutoTranslateIncomingMessages + : null, + ), + const Divider(height: 1), + SwitchListTile( + secondary: Icon( + Icons.outgoing_mail, + color: translationEnabled ? null : Colors.grey, + ), + title: Text( + context.l10n.translation_composerTitle, + style: TextStyle(color: translationEnabled ? null : Colors.grey), + ), + subtitle: Text( + context.l10n.translation_composerSubtitle, + style: TextStyle(color: translationEnabled ? null : Colors.grey), + ), value: settings.composerTranslationEnabled, - onChanged: settingsService.setComposerTranslationEnabled, + onChanged: translationEnabled + ? settingsService.setComposerTranslationEnabled + : null, ), const Divider(height: 1), ListTile( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index fcd50861..b57bd235 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; @@ -1320,6 +1321,18 @@ class _ChannelChatScreenState extends State { } void _showMessageActions(ChannelMessage message) { + final settings = context.read().settings; + final translationService = context.read(); + final canTranslateMessage = + settings.translationEnabled && + !settings.autoTranslateIncomingMessages && + translationService.canTranslateIncoming( + text: message.text, + isCli: false, + isOutgoing: message.isOutgoing, + ) && + (message.translatedText?.trim().isEmpty ?? true); + showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -1361,6 +1374,21 @@ class _ChannelChatScreenState extends State { _copyMessageText(message.text); }, ), + if (canTranslateMessage) + ListTile( + leading: const Icon(Icons.translate), + title: Text(context.l10n.translation_translateMessage), + onTap: () { + Navigator.pop(sheetContext); + unawaited( + context.read().translateChannelMessage( + widget.channel.index, + message, + manualTranslation: true, + ), + ); + }, + ), if (!message.isOutgoing) ListTile( leading: const Icon(Icons.mark_chat_unread_outlined), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 8d3bc66c..07884b96 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1577,6 +1577,18 @@ class _ChatScreenState extends State { } void _showMessageActions(Message message, Contact contact) { + final settings = context.read().settings; + final translationService = context.read(); + final canTranslateMessage = + settings.translationEnabled && + !settings.autoTranslateIncomingMessages && + translationService.canTranslateIncoming( + text: message.text, + isCli: message.isCli, + isOutgoing: message.isOutgoing, + ) && + (message.translatedText?.trim().isEmpty ?? true); + showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -1610,6 +1622,21 @@ class _ChatScreenState extends State { _copyMessageText(message.text); }, ), + if (canTranslateMessage) + ListTile( + leading: const Icon(Icons.translate), + title: Text(context.l10n.translation_translateMessage), + onTap: () { + Navigator.pop(sheetContext); + unawaited( + context.read().translateContactMessage( + widget.contact.publicKeyHex, + message, + manualTranslation: true, + ), + ); + }, + ), if (!message.isOutgoing) ListTile( leading: const Icon(Icons.mark_chat_unread_outlined), diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 7b3d5848..88ff0f47 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -235,6 +235,12 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(translationEnabled: value)); } + Future setAutoTranslateIncomingMessages(bool value) async { + await updateSettings( + _settings.copyWith(autoTranslateIncomingMessages: value), + ); + } + Future setTranslationTargetLanguageCode(String? value) async { await updateSettings( _settings.copyWith(translationTargetLanguageCode: value), diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 148650ca..4437d1f1 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -89,7 +89,22 @@ class TranslationService extends ChangeNotifier { 'en'; } - bool shouldTranslateIncoming({ + bool shouldAutoTranslateIncoming({ + required String text, + required bool isCli, + required bool isOutgoing, + }) { + if (!_settings.autoTranslateIncomingMessages) { + return false; + } + return canTranslateIncoming( + text: text, + isCli: isCli, + isOutgoing: isOutgoing, + ); + } + + bool canTranslateIncoming({ required String text, required bool isCli, required bool isOutgoing, From 3fe5cdf55d27ee8eb6b41ae1bc8bdb00fa39286a Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 20 May 2026 23:20:16 +0200 Subject: [PATCH 32/38] update to current dev a50c0d0b2dcfb33cf57b553db6b909802262dfe6 --- README.md | 1 + lib/connector/meshcore_connector.dart | 96 ++++++-- lib/connector/meshcore_uuids.dart | 1 + lib/models/channel.dart | 34 +++ lib/models/community.dart | 33 +++ lib/models/radio_settings.dart | 270 ++++++++++++++++++++++ lib/screens/channel_chat_screen.dart | 77 +++++- lib/screens/channels_screen.dart | 84 +++---- lib/screens/chat_screen.dart | 15 +- lib/screens/contacts_screen.dart | 2 + lib/screens/line_of_sight_map_screen.dart | 6 + lib/screens/map_screen.dart | 2 + lib/services/translation_service.dart | 16 +- lib/widgets/quick_switch_bar.dart | 46 +++- 14 files changed, 583 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 94d54bac..041ea6e9 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ Devices are discovered by scanning for BLE advertisements with known MeshCore de - `WisCore-` - `HT-` - `LowMesh_MC_` + - `NRF52` New device prefixes can be added in `lib/connector/meshcore_uuids.dart`. diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7eca6e88..0a7056d5 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -302,6 +302,8 @@ class MeshCoreConnector extends ChangeNotifier { final Map _contactUnreadCount = {}; final Map _repeaterBatterySnapshots = {}; bool _unreadStateLoaded = false; + int _cachedContactsUnreadTotal = 0; + int _cachedChannelsUnreadTotal = 0; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; int? _activeChannelIndex; @@ -606,16 +608,42 @@ class MeshCoreConnector extends ChangeNotifier { int getTotalUnreadCount() { if (!_unreadStateLoaded) return 0; - var total = 0; - // Count unread contact messages - for (final contact in _contacts) { - total += getUnreadCountForContact(contact); - } - // Count unread channel messages - for (final channelIndex in _channelMessages.keys) { - total += getUnreadCountForChannelIndex(channelIndex); - } - return total; + return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount(); + } + + int getTotalContactsUnreadCount() { + if (!_unreadStateLoaded) return 0; + return _cachedContactsUnreadTotal; + } + + int getTotalChannelsUnreadCount() { + if (!_unreadStateLoaded) return 0; + return _cachedChannelsUnreadTotal; + } + + /// Recalculates both cached unread totals from scratch. + /// Called when unread state is first loaded. + void _recalculateCachedUnreadTotals() { + _recalculateCachedContactsUnreadTotal(); + _recalculateCachedChannelsUnreadTotal(); + } + + void _recalculateCachedContactsUnreadTotal() { + int total = 0; + _contactUnreadCount.forEach((contactKeyHex, count) { + if (_shouldTrackUnreadForContactKey(contactKeyHex)) { + total += count; + } + }); + _cachedContactsUnreadTotal = total; + } + + void _recalculateCachedChannelsUnreadTotal() { + final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels; + _cachedChannelsUnreadTotal = allChannels.fold( + 0, + (total, ch) => total + ch.unreadCount, + ); } bool isChannelSmazEnabled(int channelIndex) { @@ -649,11 +677,13 @@ class MeshCoreConnector extends ChangeNotifier { ..clear() ..addAll(await _unreadStore.loadContactUnreadCount()); _unreadStateLoaded = true; + _recalculateCachedUnreadTotals(); notifyListeners(); } Future loadCachedChannels() async { _cachedChannels = await _channelStore.loadChannels(); + _recalculateCachedChannelsUnreadTotal(); } void setActiveContact(String? contactKeyHex) { @@ -680,6 +710,8 @@ class MeshCoreConnector extends ChangeNotifier { final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; if (previousCount > 0) { _contactUnreadCount[contactKeyHex] = 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount) + .clamp(0, _cachedContactsUnreadTotal); _appDebugLogService?.info( 'Contact $contactKeyHex marked as read (was $previousCount unread)', tag: 'Unread', @@ -721,6 +753,8 @@ class MeshCoreConnector extends ChangeNotifier { if (channel != null && channel.unreadCount > 0) { final previousCount = channel.unreadCount; channel.unreadCount = 0; + _cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount) + .clamp(0, _cachedChannelsUnreadTotal); _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', tag: 'Unread', @@ -3156,6 +3190,9 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(_persistContacts()); _conversations.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex); + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -3549,6 +3586,7 @@ class MeshCoreConnector extends ChangeNotifier { // Cache channels for offline use _cachedChannels = List.from(_channels); unawaited(_channelStore.saveChannels(_channels)); + _recalculateCachedChannelsUnreadTotal(); // Apply ordering and notify UI _applyChannelOrder(); @@ -4101,6 +4139,9 @@ class MeshCoreConnector extends ChangeNotifier { _handleDiscovery(contact, frame, noNotify: true, addActive: true); if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -4191,6 +4232,9 @@ class MeshCoreConnector extends ChangeNotifier { } if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -4464,17 +4508,13 @@ class MeshCoreConnector extends ChangeNotifier { badgeCount: getTotalUnreadCount(), ); } else if (c?.type == advTypeRoom) { - // Room server messages include a 4-char prefix; strip it for notifications - final bodyText = msg.text.length > 4 - ? msg.text.substring(4) - : msg.text; final resolvedText = (translationResult != null && translationResult.status == MessageTranslationStatus.completed && translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : bodyText.trim(); + : msg.text.trim(); await _notificationService.showMessageNotification( contactName: c?.name ?? 'Unknown Room', message: resolvedText, @@ -4522,16 +4562,24 @@ class MeshCoreConnector extends ChangeNotifier { timestampRaw * 1000, ); - if (txtType == 2) { - reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants + final flags = txtType; + final shiftedType = flags >> 2; + final rawType = flags; + final isSigned = shiftedType == txtTypeSigned || rawType == txtTypeSigned; + final Uint8List? roomAuthorPrefix; + if (isSigned) { + // Room-server pushed posts use signed/plain contact messages where this + // 4-byte "signature" field is actually the original author's pubkey + // prefix. Keep it as metadata; the text starts after these bytes. + roomAuthorPrefix = reader.readBytes(4); + } else { + roomAuthorPrefix = null; } final msgText = reader.readCString(); - final flags = txtType; - final shiftedType = flags >> 2; - final rawType = flags; - final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain; + final isPlain = + shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned; final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; if (!isPlain && !isCli) { appLogger.warn( @@ -4568,9 +4616,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLength == 0xFF ? 0 : pathLength, pathBytes: Uint8List(0), - fourByteRoomContactKey: msgText.length >= 4 - ? Uint8List.fromList(msgText.substring(0, 4).codeUnits) - : null, + fourByteRoomContactKey: roomAuthorPrefix, ); } catch (e) { appLogger.warn('Error parsing contact direct message: $e'); @@ -5247,6 +5293,7 @@ class MeshCoreConnector extends ChangeNotifier { final channel = _findChannelByIndex(channelIndex); if (channel != null) { channel.unreadCount++; + _cachedChannelsUnreadTotal++; _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}', tag: 'Unread', @@ -5291,6 +5338,7 @@ class MeshCoreConnector extends ChangeNotifier { final currentCount = _contactUnreadCount[contactKey] ?? 0; _contactUnreadCount[contactKey] = currentCount + 1; + _cachedContactsUnreadTotal++; _appDebugLogService?.info( 'Contact $contactKey unread count incremented to ${currentCount + 1}', tag: 'Unread', diff --git a/lib/connector/meshcore_uuids.dart b/lib/connector/meshcore_uuids.dart index ae6697b6..084cc424 100644 --- a/lib/connector/meshcore_uuids.dart +++ b/lib/connector/meshcore_uuids.dart @@ -11,5 +11,6 @@ class MeshCoreUuids { "Lilygo", "HT-", "LowMesh_MC_", + "NRF52", ]; } diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 4fdd6270..9baf6302 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import '../connector/meshcore_protocol.dart'; +import 'community.dart'; + +enum ChannelType { public, private, hashtag, communityPublic, communityHashtag } class Channel { final int index; @@ -111,5 +114,36 @@ class Channel { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } + static bool isCommunityChannel(ChannelType channelType) { + switch (channelType) { + case ChannelType.communityPublic: + case ChannelType.communityHashtag: + return true; + case ChannelType.public: + case ChannelType.private: + case ChannelType.hashtag: + return false; + } + } + + static ChannelType getChannelType( + Channel channel, + CommunityPskIndex communityIndex, + ) { + Community? community = communityIndex.getCommunityForChannel(channel); + if (community != null) { + if (Community.isCommunityPublicChannel(channel, community)) { + return ChannelType.communityPublic; + } + return ChannelType.communityHashtag; + } + if (channel.isPublicChannel) { + return ChannelType.public; + } else if (channel.name.startsWith('#')) { + return ChannelType.hashtag; + } + return ChannelType.private; + } + static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72'; } diff --git a/lib/models/community.dart b/lib/models/community.dart index c829f3d1..7261ddf9 100644 --- a/lib/models/community.dart +++ b/lib/models/community.dart @@ -4,6 +4,8 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; +import 'channel.dart'; + /// Represents a community with a shared secret for deriving channel PSKs. /// /// A Community is a namespace with a shared secret K (32 random bytes), @@ -162,6 +164,12 @@ class Community { return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim(); } + /// Returns true if this is the community's public channel + static bool isCommunityPublicChannel(Channel channel, Community community) { + final publicPsk = community.deriveCommunityPublicPsk(); + return channel.pskHex == Channel.formatPskHex(publicPsk); + } + /// Add a hashtag channel to this community's list Community addHashtagChannel(String hashtag) { final normalized = _normalizeCommunityHashtag(hashtag); @@ -237,3 +245,28 @@ class Community { @override int get hashCode => id.hashCode; } + +class CommunityPskIndex { + // Cache of PSK hex -> Community for quick lookup + final Map _pskToCommunity = {}; + + void initialize(List communities) { + _pskToCommunity.clear(); + for (final community in communities) { + // Map the community public channel PSK + final publicPsk = community.deriveCommunityPublicPsk(); + _pskToCommunity[Channel.formatPskHex(publicPsk)] = community; + + // Map all known hashtag channel PSKs + for (final hashtag in community.hashtagChannels) { + final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag); + _pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community; + } + } + } + + /// Returns the community this channel belongs to, or null if not a community channel + Community? getCommunityForChannel(Channel channel) { + return _pskToCommunity[channel.pskHex]; + } +} diff --git a/lib/models/radio_settings.dart b/lib/models/radio_settings.dart index 099d9201..e74cf209 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -181,6 +181,276 @@ class RadioSettings { txPowerDbm: 14, ), ), + ( + 'Russia Artyom (VVO)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Biysk (BSK)', + RadioSettings( + frequencyMHz: 869.000, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Chelyabinsk (CEK)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Cherepovets (CEE)', + RadioSettings( + frequencyMHz: 868.570, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Irkutsk (IKT)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ivanovo (IWA)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Izhevsk (IJK)', + RadioSettings( + frequencyMHz: 868.732, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kaluga (KLF)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kazan (KZN)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Khabarovsk (KHV)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kirov (KVX)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Lipetsk (LPK)', + RadioSettings( + frequencyMHz: 868.950, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Moscow (MOW)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Nizhny Novgorod (GOJ)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Novosibirsk (OVB)', + RadioSettings( + frequencyMHz: 869.000, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Rostov-on-Don (ROV)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ryazan (RZN)', + RadioSettings( + frequencyMHz: 868.880, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Samara (KUF)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Saratov (GSV)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia St. Petersburg (LED)', + RadioSettings( + frequencyMHz: 868.856, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tambov (TBW)', + RadioSettings( + frequencyMHz: 868.950, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf10, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tula (TYA)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tver (KLD)', + RadioSettings( + frequencyMHz: 869.169, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ufa (UFA)', + RadioSettings( + frequencyMHz: 868.732, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Volgograd (VOG)', + RadioSettings( + frequencyMHz: 869.525, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Voronezh (VOZ)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Yekaterinburg (SVX)', + RadioSettings( + frequencyMHz: 869.046, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), ( 'Switzerland', RadioSettings( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index b57bd235..f34bab04 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -9,6 +9,8 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../models/community.dart'; +import '../storage/community_store.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; @@ -57,8 +59,11 @@ class _ChannelChatScreenState extends State { final ChatScrollController _scrollController = ChatScrollController(); final FocusNode _textFieldFocusNode = FocusNode(); ChannelMessage? _replyingToMessage; + final CommunityStore _communityStore = CommunityStore(); + final CommunityPskIndex _communityIndex = CommunityPskIndex(); final Map _messageKeys = {}; bool _isLoadingOlder = false; + bool _communitiesLoaded = false; MeshCoreConnector? _connector; DateTime? _lastChannelSendAt; @@ -82,6 +87,7 @@ class _ChannelChatScreenState extends State { final idx = widget.channel.index; final unread = widget.initialUnreadCount; final messages = connector.getChannelMessages(widget.channel); + _loadCommunities(); ChannelMessage? anchor; if (unread > 0) { anchor = _findOldestUnreadChannelAnchor(messages, unread); @@ -108,6 +114,19 @@ class _ChannelChatScreenState extends State { }); } + // TODO: Reload communities when returning from another screen + Future _loadCommunities() async { + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + final communities = await _communityStore.loadCommunities(); + if (mounted) { + setState(() { + _communityIndex.initialize(communities); + _communitiesLoaded = true; + }); + } + } + ChannelMessage? _findOldestUnreadChannelAnchor( List messages, int unreadCount, @@ -194,16 +213,63 @@ class _ChannelChatScreenState extends State { ); } + Widget _channelIcon(Channel channel) { + // Determine icon based on channel type + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + IconData icon; + switch (channelType) { + case ChannelType.communityPublic: + icon = Icons.groups; + case ChannelType.communityHashtag: + icon = Icons.tag; + case ChannelType.public: + icon = Icons.public; + case ChannelType.hashtag: + icon = Icons.tag; + case ChannelType.private: + icon = Icons.lock; + } + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: _communitiesLoaded + ? Icon(icon, size: 20) + : SizedBox.square(dimension: 20), + ), + if (isCommunityChannel) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: Colors.purple, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).cardColor, + width: 2, + ), + ), + child: const Icon(Icons.people, size: 8, color: Colors.white), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( children: [ - Icon( - widget.channel.isPublicChannel ? Icons.public : Icons.tag, - size: 20, - ), + _channelIcon(widget.channel), const SizedBox(width: 8), Expanded( child: Column( @@ -1321,11 +1387,8 @@ class _ChannelChatScreenState extends State { } void _showMessageActions(ChannelMessage message) { - final settings = context.read().settings; final translationService = context.read(); final canTranslateMessage = - settings.translationEnabled && - !settings.autoTranslateIncomingMessages && translationService.canTranslateIncoming( text: message.text, isCli: false, diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 4613a8ee..8b05b8ce 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -44,11 +44,9 @@ class _ChannelsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); final CommunityStore _communityStore = CommunityStore(); - Timer? _searchDebounce; + final CommunityPskIndex _communityIndex = CommunityPskIndex(); List _communities = []; - - // Cache of PSK hex -> Community for quick lookup - final Map _pskToCommunity = {}; + Timer? _searchDebounce; ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); @@ -71,37 +69,11 @@ class _ChannelsScreenState extends State if (mounted) { setState(() { _communities = communities; - _buildPskCommunityMap(); + _communityIndex.initialize(communities); }); } } - void _buildPskCommunityMap() { - _pskToCommunity.clear(); - for (final community in _communities) { - // Map the community public channel PSK - final publicPsk = community.deriveCommunityPublicPsk(); - _pskToCommunity[Channel.formatPskHex(publicPsk)] = community; - - // Map all known hashtag channel PSKs - for (final hashtag in community.hashtagChannels) { - final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag); - _pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community; - } - } - } - - /// Returns the community this channel belongs to, or null if not a community channel - Community? _getCommunityForChannel(Channel channel) { - return _pskToCommunity[channel.pskHex]; - } - - /// Returns true if this is the community's public channel - bool _isCommunityPublicChannel(Channel channel, Community community) { - final publicPsk = community.deriveCommunityPublicPsk(); - return channel.pskHex == Channel.formatPskHex(publicPsk); - } - @override void dispose() { _searchDebounce?.cancel(); @@ -360,6 +332,8 @@ class _ChannelsScreenState extends State selectedIndex: 1, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), @@ -375,37 +349,37 @@ class _ChannelsScreenState extends State int? dragIndex, }) { final unreadCount = connector.getUnreadCountForChannel(channel); - final community = _getCommunityForChannel(channel); - final isCommunityChannel = community != null; - final isCommunityPublic = - isCommunityChannel && _isCommunityPublicChannel(channel, community); // Determine icon and colors based on channel type IconData icon; Color iconColor; Color bgColor; - - if (isCommunityChannel) { - // Community channel styling - iconColor = Colors.purple; - bgColor = Colors.purple.withValues(alpha: 0.2); - if (isCommunityPublic) { + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + switch (channelType) { + case ChannelType.communityPublic: icon = Icons.groups; - } else { + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.communityHashtag: icon = Icons.tag; - } - } else if (channel.isPublicChannel) { - icon = Icons.public; - iconColor = Colors.green; - bgColor = Colors.green.withValues(alpha: 0.2); - } else if (channel.name.startsWith('#')) { - icon = Icons.tag; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); - } else { - icon = Icons.lock; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.public: + icon = Icons.public; + iconColor = Colors.green; + bgColor = Colors.green.withValues(alpha: 0.2); + case ChannelType.hashtag: + icon = Icons.tag; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); + case ChannelType.private: + icon = Icons.lock; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); } return Card( diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 07884b96..7c71d597 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -485,6 +485,8 @@ class _ChatScreenState extends State { final message = reversedMessages[messageIndex]; String fourByteHex = ''; if (contact.type == advTypeRoom) { + // Room-server messages carry the original author's 4-byte prefix + // separately from message.text; use it only for resolving the name. contact = _resolveContactFrom4Bytes( connector, message.fourByteRoomContactKey.isEmpty @@ -509,7 +511,6 @@ class _ChatScreenState extends State { ? "${contact.name} [$fourByteHex]" : contact.name, sourceId: widget.contact.publicKeyHex, - isRoomServer: resolvedContact.type == advTypeRoom, textScale: textScale, onTap: () => _openMessagePath(message, contact), onLongPress: () => _showMessageActions(message, contact), @@ -1577,11 +1578,8 @@ class _ChatScreenState extends State { } void _showMessageActions(Message message, Contact contact) { - final settings = context.read().settings; final translationService = context.read(); final canTranslateMessage = - settings.translationEnabled && - !settings.autoTranslateIncomingMessages && translationService.canTranslateIncoming( text: message.text, isCli: message.isCli, @@ -1748,7 +1746,6 @@ class _ChatScreenState extends State { class _MessageBubble extends StatelessWidget { final Message message; final String senderName; - final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; final void Function(Message message, String emoji)? onRetryReaction; @@ -1759,7 +1756,6 @@ class _MessageBubble extends StatelessWidget { required this.message, required this.senderName, required this.sourceId, - required this.isRoomServer, required this.textScale, this.onTap, this.onLongPress, @@ -1785,10 +1781,9 @@ class _MessageBubble extends StatelessWidget { : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); const bodyFontSize = 14.0; - String messageText = message.text; - if (isRoomServer && !isOutgoing) { - messageText = message.text.substring(4.clamp(0, message.text.length)); - } + // Do not strip room-server author bytes here: the parser stores them in + // fourByteRoomContactKey, so message.text is safe to render as-is. + final messageText = message.text; final translatedDisplayText = message.translatedText != null && message.translatedText!.trim().isNotEmpty diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index a4cc35ca..d5e1b79a 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -430,6 +430,8 @@ class _ContactsScreenState extends State selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 3ba79d59..f908f5ea 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -539,6 +539,12 @@ class _LineOfSightMapScreenState extends State { child: QuickSwitchBar( selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: context + .watch() + .getTotalContactsUnreadCount(), + channelsUnreadCount: context + .watch() + .getTotalChannelsUnreadCount(), ), ), ); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 4ae2fd43..be133240 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -676,6 +676,8 @@ class _MapScreenState extends State { selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), floatingActionButton: FloatingActionButton( diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 4437d1f1..7b1d7f5f 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -388,7 +388,9 @@ class TranslationService extends ChangeNotifier { if (targetLanguageCode == null || !_isPlainTextEligible(text)) { return null; } - final detectedLanguageCode = await detectLanguage(text); + final detectedLanguageCode = await detectLanguage( + _stripReplyInfoForDetection(text), + ); if (detectedLanguageCode != null && detectedLanguageCode == targetLanguageCode) { return const TranslationResult( @@ -429,7 +431,9 @@ class TranslationService extends ChangeNotifier { if (targetLanguageCode == null || !_isPlainTextEligible(text)) { return null; } - final detectedLanguageCode = await detectLanguage(text); + final detectedLanguageCode = await detectLanguage( + _stripReplyInfoForDetection(text), + ); if (detectedLanguageCode != null && detectedLanguageCode == targetLanguageCode) { return const TranslationResult( @@ -470,6 +474,14 @@ class TranslationService extends ChangeNotifier { } } + String _stripReplyInfoForDetection(String text) { + final match = RegExp( + r'@\[([^\]]+)\]\s+(.+)$', + dotAll: true, + ).firstMatch(text); + return match?.group(2) ?? text; + } + Future _translateText({ required String text, required String targetLanguageCode, diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index 134091ff..40dcb59a 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -6,11 +6,15 @@ import '../l10n/l10n.dart'; class QuickSwitchBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; + final int contactsUnreadCount; + final int channelsUnreadCount; const QuickSwitchBar({ super.key, required this.selectedIndex, required this.onDestinationSelected, + this.contactsUnreadCount = 0, + this.channelsUnreadCount = 0, }); @override @@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget { onDestinationSelected: onDestinationSelected, destinations: [ NavigationDestination( - icon: const Icon(Icons.people_outline), + icon: _buildIconWithBadge( + const Icon(Icons.people_outline), + contactsUnreadCount, + ), + selectedIcon: _buildIconWithBadge( + const Icon(Icons.people), + contactsUnreadCount, + ), label: context.l10n.nav_contacts, ), NavigationDestination( - icon: const Icon(Icons.tag), + icon: _buildIconWithBadge( + const Icon(Icons.tag), + channelsUnreadCount, + ), + selectedIcon: _buildIconWithBadge( + const Icon(Icons.tag), + channelsUnreadCount, + ), label: context.l10n.nav_channels, ), NavigationDestination( icon: const Icon(Icons.map_outlined), + selectedIcon: const Icon(Icons.map), label: context.l10n.nav_map, ), ], @@ -81,4 +100,27 @@ class QuickSwitchBar extends StatelessWidget { ), ); } + + Widget _buildIconWithBadge(Icon icon, int count) { + if (count <= 0) return icon; + + return Stack( + clipBehavior: Clip.none, + children: [ + icon, + Positioned( + right: -2, + top: -2, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + } } From 8682e6ea6762c475e6d3750f68103b2e268c0d81 Mon Sep 17 00:00:00 2001 From: ericz Date: Thu, 21 May 2026 00:02:21 +0200 Subject: [PATCH 33/38] fix missing _handleQueuedMessageReceived after merge of dev --- lib/connector/meshcore_connector.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0a7056d5..bb0def43 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4525,6 +4525,9 @@ class MeshCoreConnector extends ChangeNotifier { }()); } } + _handleQueuedMessageReceived(); + } else if (_isSyncingQueuedMessages) { + _handleQueuedMessageReceived(); } } @@ -4861,6 +4864,9 @@ class MeshCoreConnector extends ChangeNotifier { _maybeNotifyChannelMessage(msg, translationResult: translationResult); }()); } + _handleQueuedMessageReceived(); + } else if (_isSyncingQueuedMessages) { + _handleQueuedMessageReceived(); } } From bac82dc9e8b1e464613c498ade1b6e5f86f18df9 Mon Sep 17 00:00:00 2001 From: ericz Date: Thu, 21 May 2026 00:11:54 +0200 Subject: [PATCH 34/38] Fix Flutter SDK update --- lib/screens/channels_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 8b05b8ce..0737a7d0 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -273,7 +273,7 @@ class _ChannelsScreenState extends State ), buildDefaultDragHandles: false, itemCount: filteredChannels.length, - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { if (newIndex > oldIndex) newIndex -= 1; final reordered = List.from( filteredChannels, From 6d258154a02d939473dfac43d0e964841bc659b1 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sat, 23 May 2026 18:26:45 +0300 Subject: [PATCH 35/38] fix Flutter SDK update PR #458 included --- lib/main.dart | 24 +++++++++++++++++++++++- lib/screens/channels_screen.dart | 5 +++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index cd622811..37aa29ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -214,7 +215,10 @@ class MeshCoreApp extends StatelessWidget { // Update notification service with resolved locale final locale = Localizations.localeOf(context); NotificationService().setLocale(locale); - return child ?? const SizedBox.shrink(); + return AnnotatedRegion( + value: _systemUiOverlayStyle(context), + child: child ?? const SizedBox.shrink(), + ); }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) ? const ChromeRequiredScreen() @@ -236,6 +240,24 @@ class MeshCoreApp extends StatelessWidget { } } + SystemUiOverlayStyle _systemUiOverlayStyle(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final iconBrightness = isDark ? Brightness.light : Brightness.dark; + + // Keep Android system bars aligned with the resolved Flutter theme. + return SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: iconBrightness, + statusBarBrightness: isDark ? Brightness.dark : Brightness.light, + systemNavigationBarColor: colorScheme.surface, + systemNavigationBarIconBrightness: iconBrightness, + systemNavigationBarDividerColor: colorScheme.surface, + systemNavigationBarContrastEnforced: false, + ); + } + Locale? _localeFromSetting(String? languageCode) { if (languageCode == null) return null; return Locale(languageCode); diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 8b05b8ce..d305a6f3 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -273,8 +273,9 @@ class _ChannelsScreenState extends State ), buildDefaultDragHandles: false, itemCount: filteredChannels.length, - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; + onReorderItem: (oldIndex, newIndex) { + // onReorderItem already adjusts newIndex after the + // removed item, unlike the deprecated onReorder. final reordered = List.from( filteredChannels, ); From ed0e6b6554f16b427498ef85d2baba555b7647fd Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sat, 23 May 2026 21:06:52 +0300 Subject: [PATCH 36/38] Onstart sync progressbar: init --- lib/connector/meshcore_connector.dart | 263 ++++++++++++++++++------- lib/screens/app_settings_screen.dart | 2 + lib/screens/channel_chat_screen.dart | 2 + lib/screens/channels_screen.dart | 12 +- lib/screens/chat_screen.dart | 2 + lib/screens/contacts_screen.dart | 15 +- lib/screens/map_screen.dart | 2 + lib/screens/settings_screen.dart | 2 + lib/screens/telemetry_screen.dart | 2 + lib/widgets/sync_progress_overlay.dart | 60 ++++++ 10 files changed, 282 insertions(+), 80 deletions(-) create mode 100644 lib/widgets/sync_progress_overlay.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7cbc8ff0..d1581e44 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -199,6 +199,7 @@ class MeshCoreConnector extends ChangeNotifier { double? _selfLongitude; final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; + bool _hasLoadedContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; TimeoutPredictionService? _timeoutPredictionService; @@ -220,10 +221,13 @@ class MeshCoreConnector extends ChangeNotifier { bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _hasReceivedDeviceInfo = false; + // Initial sync is serialized for predictable progress. Firmware exposes one + // FIFO queued-message stream, so direct/room frames are buffered until after + // contacts are known. bool _pendingInitialChannelSync = false; bool _pendingInitialContactsSync = false; + bool _pendingInitialQueuedMessageSync = false; bool _bleInitialSyncStarted = false; - bool _pendingDeferredChannelSyncAfterContacts = false; bool _webInitialHandshakeRequestSent = false; bool _preserveContactsOnRefresh = false; bool _autoAddUsers = false; @@ -238,13 +242,18 @@ class MeshCoreConnector extends ChangeNotifier { int _advertLocPolicy = 0; int _multiAcks = 0; - static const int _defaultMaxContacts = 32; - static const int _defaultMaxChannels = 8; + static const int _defaultMaxContacts = 350; + static const int _defaultMaxChannels = 40; int _maxContacts = _defaultMaxContacts; int _maxChannels = _defaultMaxChannels; + int? _contactSyncTotal; + int _contactSyncReceived = 0; + bool _contactSyncUsesSinceFilter = false; bool _isSyncingQueuedMessages = false; + bool _deferQueuedContactMessagesUntilContacts = false; + bool _isProcessingDeferredQueuedContactMessages = false; bool _queuedMessageSyncInFlight = false; - bool _didInitialQueueSync = false; + final List _deferredQueuedContactMessageFrames = []; bool _pendingQueueSync = false; Timer? _queueSyncTimeout; int _queueSyncRetries = 0; @@ -373,7 +382,9 @@ class MeshCoreConnector extends ChangeNotifier { List get channels => List.unmodifiable(_channels); bool get isConnected => _state == MeshCoreConnectionState.connected; bool get isLoadingContacts => _isLoadingContacts; + bool get hasLoadedContacts => _hasLoadedContacts; bool get isLoadingChannels => _isLoadingChannels; + bool get hasLoadedChannels => _hasLoadedChannels; Stream get receivedFrames => _receivedFramesController.stream; Uint8List? get selfPublicKey => _selfPublicKey; String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); @@ -436,7 +447,16 @@ class MeshCoreConnector extends ChangeNotifier { int get maxContacts => _maxContacts; int get maxChannels => _maxChannels; Set get knownContactKeys => Set.unmodifiable(_knownContactKeys); - bool get isSyncingQueuedMessages => _isSyncingQueuedMessages; + double? get contactSyncProgress { + final total = _contactSyncTotal; + if (!_isLoadingContacts || total == null || total <= 0) return null; + return (_contactSyncReceived / total).clamp(0.0, 1.0).toDouble(); + } + + bool get isSyncingQueuedMessages => + _isSyncingQueuedMessages || _isProcessingDeferredQueuedContactMessages; + bool get isShowingQueuedMessageSyncProgress => + _deferQueuedContactMessagesUntilContacts && isSyncingQueuedMessages; bool get isSyncingChannels => _isSyncingChannels; int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0 @@ -1515,6 +1535,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; _appDebugLogService?.info( 'connectUsb: requesting device info…', tag: 'USB', @@ -1625,6 +1647,8 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); _pendingInitialChannelSync = true; + _pendingInitialQueuedMessageSync = true; + _pendingInitialContactsSync = true; await _requestDeviceInfo(); _startBatteryPolling(); if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); @@ -2259,7 +2283,9 @@ class MeshCoreConnector extends ChangeNotifier { return; } _bleInitialSyncStarted = true; + _pendingInitialChannelSync = true; _pendingInitialContactsSync = true; + _pendingInitialQueuedMessageSync = true; await _requestDeviceInfo(); _startBatteryPolling(); @@ -2274,7 +2300,7 @@ class MeshCoreConnector extends ChangeNotifier { } await syncTime(); - unawaited(getChannels()); + _maybeStartInitialChannelSync(); } void _resetConnectionHandshakeState() { @@ -2287,11 +2313,39 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer = null; _hasReceivedDeviceInfo = false; + _resetSyncProgressState(); + _bleInitialSyncStarted = false; + _pathHashByteWidth = 1; + } + + void _resetSyncProgressState() { _pendingInitialChannelSync = false; _pendingInitialContactsSync = false; - _bleInitialSyncStarted = false; - _pendingDeferredChannelSyncAfterContacts = false; - _pathHashByteWidth = 1; + _pendingInitialQueuedMessageSync = false; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = false; + _isLoadingContacts = false; + _hasLoadedContacts = false; + _isLoadingChannels = false; + _hasLoadedChannels = false; + _isSyncingQueuedMessages = false; + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + _queuedMessageSyncInFlight = false; + _deferredQueuedContactMessageFrames.clear(); + _pendingQueueSync = false; + _queueSyncTimeout?.cancel(); + _queueSyncTimeout = null; + _queueSyncRetries = 0; + _isSyncingChannels = false; + _channelSyncInFlight = false; + _channelSyncTimeout?.cancel(); + _channelSyncTimeout = null; + _channelSyncRetries = 0; + _nextChannelIndexToRequest = 0; + _totalChannelsToRequest = 0; + _previousChannelsCache.clear(); } bool get _shouldAutoReconnect => @@ -2428,17 +2482,9 @@ class MeshCoreConnector extends ChangeNotifier { _batteryRequested = false; _awaitingSelfInfo = false; _hasReceivedDeviceInfo = false; - _pendingInitialChannelSync = false; - _pendingInitialContactsSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; - _isSyncingQueuedMessages = false; - _queuedMessageSyncInFlight = false; - _didInitialQueueSync = false; - _pendingQueueSync = false; - _isSyncingChannels = false; - _channelSyncInFlight = false; - _hasLoadedChannels = false; + _resetSyncProgressState(); _pendingChannelSentQueue.clear(); _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; @@ -2691,10 +2737,14 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; _preserveContactsOnRefresh = preserveExisting; + _contactSyncTotal = null; + _contactSyncReceived = 0; + _contactSyncUsesSinceFilter = since != null; if (!preserveExisting) { + _hasLoadedContacts = false; _contacts.clear(); - notifyListeners(); } + notifyListeners(); await sendFrame(buildGetContactsFrame(since: since)); } @@ -3292,11 +3342,20 @@ class MeshCoreConnector extends ChangeNotifier { Future syncQueuedMessages({bool force = false}) async { if (!isConnected) return; if (!force && _isSyncingQueuedMessages) return; + if (_isProcessingDeferredQueuedContactMessages) { + _pendingQueueSync = true; + return; + } if (_awaitingSelfInfo || _isLoadingContacts) { _pendingQueueSync = true; return; } + if (_isSyncingChannels || _channelSyncInFlight) { + _pendingQueueSync = true; + return; + } _isSyncingQueuedMessages = true; + notifyListeners(); await _requestNextQueuedMessage(); } @@ -3330,6 +3389,8 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queueSyncTimeout?.cancel(); _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3349,6 +3410,8 @@ class MeshCoreConnector extends ChangeNotifier { _queuedMessageSyncInFlight = false; _isSyncingQueuedMessages = false; _queueSyncRetries = 0; + notifyListeners(); + _continueAfterQueuedMessageSync(); } } @@ -3448,6 +3511,7 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingChannels = true; _isSyncingChannels = true; + _hasLoadedChannels = false; _previousChannelsCache = List.from(_channels); _channels.clear(); _nextChannelIndexToRequest = 0; @@ -3537,6 +3601,7 @@ class MeshCoreConnector extends ChangeNotifier { _nextChannelIndexToRequest++; _channelSyncRetries = 0; _channelSyncInFlight = false; + notifyListeners(); unawaited(_requestNextChannel()); } } @@ -3574,16 +3639,22 @@ class MeshCoreConnector extends ChangeNotifier { _previousChannelsCache.clear(); } - // Fallback: if contact sync was deferred waiting for channel 0 but - // channel sync finished without triggering it, start contacts now. - if (_pendingInitialContactsSync && isConnected) { - _pendingInitialContactsSync = false; - unawaited(getContacts()); + if (isConnected) { + _startPostChannelInitialQueuedMessageSync(); } // Keep cache on failure/disconnection for future attempts } + void _startPostChannelInitialQueuedMessageSync() { + if (_pendingInitialQueuedMessageSync || _pendingQueueSync) { + _deferQueuedContactMessagesUntilContacts = _pendingInitialContactsSync; + _pendingInitialQueuedMessageSync = false; + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } + } + Future setChannel(int index, String name, Uint8List psk) async { if (!isConnected) return; @@ -3633,6 +3704,20 @@ class MeshCoreConnector extends ChangeNotifier { _contacts.clear(); } _isLoadingContacts = true; + _contactSyncReceived = 0; + // Firmware v3+ includes total contacts after CONTACTS_START. + // Incremental sync reports total contacts, not filtered result count. + if (frame.length >= 5 && !_contactSyncUsesSinceFilter) { + final reader = BufferReader(frame); + reader.skipBytes(1); + _contactSyncTotal = reader.readUInt32LE(); + } else if (!_contactSyncUsesSinceFilter) { + // Older firmwares may omit the count; use the nRF node capacity as + // a conservative progress fallback instead of hiding the progress. + _contactSyncTotal = _defaultMaxContacts; + } else { + _contactSyncTotal = null; + } notifyListeners(); break; case pushCodeAdvert: @@ -3650,7 +3735,9 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeEndOfContacts: debugPrint('Got END_OF_CONTACTS'); _isLoadingContacts = false; + _hasLoadedContacts = true; _preserveContactsOnRefresh = false; + _contactSyncUsesSinceFilter = false; unawaited(updateKnownDiscovered()); notifyListeners(); unawaited(_persistContacts()); @@ -3660,23 +3747,21 @@ class MeshCoreConnector extends ChangeNotifier { !_channelSyncInFlight) { unawaited(_requestNextChannel()); } - if (!_didInitialQueueSync || _pendingQueueSync) { - _didInitialQueueSync = true; + if (_deferQueuedContactMessagesUntilContacts) { + unawaited(_processDeferredQueuedContactMessages()); + } else if (_pendingQueueSync) { _pendingQueueSync = false; unawaited(syncQueuedMessages(force: true)); } - if (_pendingDeferredChannelSyncAfterContacts && - (_activeTransport == MeshCoreTransportType.bluetooth || - _activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp)) { - _pendingDeferredChannelSyncAfterContacts = false; - _pendingInitialChannelSync = false; - unawaited(getChannels()); - } break; case respCodeContactMsgRecv: case respCodeContactMsgRecvV3: - _handleIncomingMessage(frame); + if (_shouldDeferQueuedContactMessage(frame)) { + _deferredQueuedContactMessageFrames.add(Uint8List.fromList(frame)); + _handleQueuedMessageReceived(); + } else { + unawaited(_handleIncomingMessage(frame)); + } break; case respCodeChannelMsgRecv: case respCodeChannelMsgRecvV3: @@ -3860,23 +3945,8 @@ class MeshCoreConnector extends ChangeNotifier { _selfInfoRetryTimer = null; notifyListeners(); - // Auto-fetch contacts after getting self info. On web BLE, defer this - // until after channel 0 so startup writes stay serialized. - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth) { - _pendingInitialContactsSync = true; - } else if (_activeTransport == MeshCoreTransportType.usb || - _activeTransport == MeshCoreTransportType.tcp) { - _pendingDeferredChannelSyncAfterContacts = true; - getContacts(); - } else { - getContacts(); - } - if (_shouldGateInitialChannelSync && - _activeTransport != MeshCoreTransportType.usb && - _activeTransport != MeshCoreTransportType.tcp) { - _maybeStartInitialChannelSync(); - } + // Start the serialized initial sync pipeline after SELF_INFO. + _maybeStartInitialChannelSync(); } void _handleDeviceInfo(Uint8List frame) { @@ -3916,7 +3986,7 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels)); if (isConnected && _selfPublicKey != null && - (!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) { + !_pendingInitialChannelSync) { unawaited(getChannels(maxChannels: nextMaxChannels)); } } @@ -3931,12 +4001,13 @@ class MeshCoreConnector extends ChangeNotifier { if (!_pendingInitialChannelSync || !isConnected) { return; } - if (_selfPublicKey == null || !_hasReceivedDeviceInfo) { + if (_selfPublicKey == null || + (_shouldGateInitialChannelSync && !_hasReceivedDeviceInfo)) { return; } _pendingInitialChannelSync = false; - unawaited(getChannels(maxChannels: _maxChannels)); + unawaited(getChannels(maxChannels: _maxChannels, force: true)); } void _handleNoMoreMessages() { @@ -3945,6 +4016,64 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingQueuedMessages = false; _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful completion + notifyListeners(); + _continueAfterQueuedMessageSync(); + } + + bool _shouldDeferQueuedContactMessage(Uint8List frame) { + if (!_deferQueuedContactMessagesUntilContacts || + !_isSyncingQueuedMessages) { + return false; + } + if (frame.isEmpty) return false; + return frame[0] == respCodeContactMsgRecv || + frame[0] == respCodeContactMsgRecvV3; + } + + void _continueAfterQueuedMessageSync() { + if (!_deferQueuedContactMessagesUntilContacts) return; + if (_pendingInitialContactsSync && isConnected) { + _pendingInitialContactsSync = false; + unawaited(getContacts()); + return; + } + unawaited(_processDeferredQueuedContactMessages()); + } + + Future _processDeferredQueuedContactMessages() async { + if (!_deferQueuedContactMessagesUntilContacts || + _isProcessingDeferredQueuedContactMessages) { + return; + } + if (_deferredQueuedContactMessageFrames.isEmpty) { + _deferQueuedContactMessagesUntilContacts = false; + notifyListeners(); + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } + return; + } + + _isProcessingDeferredQueuedContactMessages = true; + notifyListeners(); + try { + // Replay direct/room queued messages only after contacts are loaded, so + // sender prefixes can be resolved against the current contact list. + while (_deferredQueuedContactMessageFrames.isNotEmpty) { + final frame = _deferredQueuedContactMessageFrames.removeAt(0); + await _handleIncomingMessage(frame); + } + } finally { + _deferQueuedContactMessagesUntilContacts = false; + _isProcessingDeferredQueuedContactMessages = false; + notifyListeners(); + } + + if (_pendingQueueSync && isConnected) { + _pendingQueueSync = false; + unawaited(syncQueuedMessages(force: true)); + } } void _handleQueuedMessageReceived() { @@ -3953,6 +4082,7 @@ class MeshCoreConnector extends ChangeNotifier { _queueSyncTimeout?.cancel(); // Cancel timeout - message arrived _queuedMessageSyncInFlight = false; _queueSyncRetries = 0; // Reset retry counter on successful message + notifyListeners(); unawaited(_requestNextQueuedMessage()); } @@ -4094,11 +4224,15 @@ class MeshCoreConnector extends ChangeNotifier { void _handleContact(Uint8List frame, {bool isContact = true}) { final contactTmp = Contact.fromFrame(frame); if (contactTmp != null) { + if (isContact && _isLoadingContacts) { + _contactSyncReceived++; + } if (listEquals(contactTmp.publicKey, _selfPublicKey)) { appLogger.info( 'Ignoring contact with self public key: ${contactTmp.name}', tag: 'Connector', ); + notifyListeners(); removeContact(contactTmp); return; } @@ -4162,6 +4296,7 @@ class MeshCoreConnector extends ChangeNotifier { "Discovered contact ${contact.name} (type ${contact.typeLabelRaw}) not added due to auto-add settings", tag: 'Connector', ); + notifyListeners(); return; } } @@ -4370,7 +4505,7 @@ class MeshCoreConnector extends ChangeNotifier { return false; } - void _handleIncomingMessage(Uint8List frame) async { + Future _handleIncomingMessage(Uint8List frame) async { if (_selfPublicKey == null) return; var message = _parseContactMessage(frame); @@ -5092,14 +5227,7 @@ class MeshCoreConnector extends ChangeNotifier { // Move to next channel _nextChannelIndexToRequest++; - if (PlatformInfo.isWeb && - _activeTransport == MeshCoreTransportType.bluetooth && - channel.index == 0 && - _pendingInitialContactsSync) { - _pendingInitialContactsSync = false; - unawaited(getContacts()); - return; - } + notifyListeners(); unawaited(_requestNextChannel()); return; } else { @@ -5799,14 +5927,9 @@ class MeshCoreConnector extends ChangeNotifier { // Preserve deviceId and displayName for UI display during reconnection // They're only cleared on manual disconnect via disconnect() method _hasReceivedDeviceInfo = false; - _pendingInitialChannelSync = false; - _pendingInitialContactsSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; - _isSyncingQueuedMessages = false; - _queuedMessageSyncInFlight = false; - _isSyncingChannels = false; - _channelSyncInFlight = false; + _resetSyncProgressState(); _pendingChannelSentQueue.clear(); _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 94b3efe8..aaae8011 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -11,6 +11,7 @@ import '../services/app_settings_service.dart'; import '../services/notification_service.dart'; import '../services/translation_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../helpers/snack_bar_builder.dart'; import 'map_cache_screen.dart'; @@ -23,6 +24,7 @@ class AppSettingsScreen extends StatelessWidget { appBar: AppBar( title: AdaptiveAppBarTitle(context.l10n.appSettings_title), centerTitle: true, + bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( top: false, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index be72eaa8..8562f7d1 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -34,6 +34,7 @@ import '../widgets/gif_picker.dart'; import '../widgets/message_translation_button.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../widgets/unread_divider.dart'; import 'channel_message_path_screen.dart'; @@ -302,6 +303,7 @@ class _ChannelChatScreenState extends State { ], ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ const RadioStatsIconButton(), PopupMenuButton( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index d305a6f3..1475bdef 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -23,6 +23,7 @@ import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; @@ -103,6 +104,7 @@ class _ChannelsScreenState extends State title: AppBarTitle(context.l10n.channels_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -152,12 +154,16 @@ class _ChannelsScreenState extends State await context.read().getChannels(force: true); }, child: () { - if (connector.isLoadingChannels) { + final channels = connector.channels; + final waitingForInitialChannels = + !connector.hasLoadedChannels && !connector.isLoadingChannels; + final waitingForFirstChannel = + connector.isLoadingChannels && channels.isEmpty; + + if (waitingForInitialChannels || waitingForFirstChannel) { return const Center(child: CircularProgressIndicator()); } - final channels = connector.channels; - if (channels.isEmpty) { return ListView( children: [ diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 1548b44e..a2747043 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -41,6 +41,7 @@ import '../widgets/gif_picker.dart'; import '../widgets/message_translation_button.dart'; import '../widgets/path_selection_dialog.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; @@ -216,6 +217,7 @@ class _ChatScreenState extends State { }, ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ Consumer( builder: (context, connector, _) { diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index d5e1b79a..bdadc2b6 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -27,6 +27,7 @@ import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; @@ -318,6 +319,7 @@ class _ContactsScreenState extends State appBar: AppBar( title: AppBarTitle(context.l10n.contacts_title), automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( itemBuilder: (context) => [ @@ -606,15 +608,14 @@ class _ContactsScreenState extends State Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { final viewState = context.watch(); final contacts = connector.contacts; - final shouldShowStartupSpinner = - contacts.isEmpty && - _groups.isEmpty && + final waitingForInitialContacts = connector.isConnected && - (connector.isLoadingContacts || - connector.isLoadingChannels || - connector.selfPublicKey == null); + !connector.hasLoadedContacts && + !connector.isLoadingContacts; + final waitingForFirstContact = + connector.isLoadingContacts && contacts.isEmpty; - if (shouldShowStartupSpinner) { + if (waitingForInitialContacts || waitingForFirstContact) { return const Center(child: CircularProgressIndicator()); } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index be133240..766b1852 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -23,6 +23,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/sync_progress_overlay.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -414,6 +415,7 @@ class _MapScreenState extends State { title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, + bottom: const SyncProgressAppBarBottom(), actions: [ if (!_isBuildingPathTrace) IconButton( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5679f757..ead36eaf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -16,6 +16,7 @@ import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import '../widgets/radio_stats_entry.dart'; +import '../widgets/sync_progress_overlay.dart'; /// Convert device coding-rate value (1-4 on some firmware, 5-8 on others) /// to the UI enum range (always 5-8). @@ -67,6 +68,7 @@ class _SettingsScreenState extends State { indicators: false, subtitle: false, ), + bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( top: false, diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 47593a3f..873ca3b1 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -15,6 +15,7 @@ import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; import '../utils/battery_utils.dart'; import '../helpers/snack_bar_builder.dart'; +import '../widgets/sync_progress_overlay.dart'; class TelemetryScreen extends StatefulWidget { final Contact contact; @@ -239,6 +240,7 @@ class _TelemetryScreenState extends State { ], ), centerTitle: false, + bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( icon: Icon(isFloodMode ? Icons.waves : Icons.route), diff --git a/lib/widgets/sync_progress_overlay.dart b/lib/widgets/sync_progress_overlay.dart new file mode 100644 index 00000000..4aef90ea --- /dev/null +++ b/lib/widgets/sync_progress_overlay.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; + +class SyncProgressAppBarBottom extends StatelessWidget + implements PreferredSizeWidget { + static const double height = 3; + + const SyncProgressAppBarBottom({super.key}); + + @override + Size get preferredSize => const Size.fromHeight(height); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, connector, _) { + final state = _SyncProgressState.fromConnector(connector); + if (state == null) return const SizedBox(height: height); + + return SizedBox( + height: height, + child: LinearProgressIndicator( + value: state.value, + minHeight: height, + color: state.color, + backgroundColor: state.color.withValues(alpha: 0.18), + ), + ); + }, + ); + } +} + +class _SyncProgressState { + final double? value; + final Color color; + + const _SyncProgressState({required this.value, required this.color}); + + static _SyncProgressState? fromConnector(MeshCoreConnector connector) { + if (connector.isLoadingContacts) { + return _SyncProgressState( + value: connector.contactSyncProgress, + color: Colors.red, + ); + } + if (connector.isSyncingChannels) { + return _SyncProgressState( + value: connector.channelSyncProgress / 100, + color: Colors.blue, + ); + } + if (connector.isShowingQueuedMessageSyncProgress) { + return const _SyncProgressState(value: null, color: Colors.green); + } + return null; + } +} From 4dd472e3c3a6fe6c4b841c8f722827ba81f8a716 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Sun, 24 May 2026 15:33:54 +0300 Subject: [PATCH 37/38] Onstart sync progressbar: changed default screen to channels Because it's synchronization is faster and first in line --- lib/screens/scanner_screen.dart | 4 ++-- lib/screens/tcp_screen.dart | 14 +++++++------- lib/screens/usb_screen.dart | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index a503ec0e..ec148c5f 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -11,7 +11,7 @@ import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'tcp_screen.dart'; import 'usb_screen.dart'; @@ -46,7 +46,7 @@ class _ScannerScreenState extends State { _changedNavigation = true; if (mounted) { Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ContactsScreen()), + MaterialPageRoute(builder: (context) => const ChannelsScreen()), ); } } diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 3bd1b0bf..a0d71922 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -9,7 +9,7 @@ import '../services/app_settings_service.dart'; import '../utils/platform_info.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'usb_screen.dart'; class TcpScreen extends StatefulWidget { @@ -24,7 +24,7 @@ class _TcpScreenState extends State { late final TextEditingController _portController; late final MeshCoreConnector _connector; late final VoidCallback _connectionListener; - bool _navigatedToContacts = false; + bool _navigatedToChannels = false; @override void initState() { @@ -42,20 +42,20 @@ class _TcpScreenState extends State { _connectionListener = () { if (!mounted) return; if (_connector.state == MeshCoreConnectionState.disconnected) { - _navigatedToContacts = false; + _navigatedToChannels = false; } if (_connector.state == MeshCoreConnectionState.connected && _connector.isTcpTransportConnected && - !_navigatedToContacts) { + !_navigatedToChannels) { context.read().setTcpServerAddress( _hostController.text, ); context.read().setTcpServerPort( int.tryParse(_portController.text) ?? 0, ); - _navigatedToContacts = true; + _navigatedToChannels = true; Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), + MaterialPageRoute(builder: (_) => const ChannelsScreen()), ); } }; @@ -67,7 +67,7 @@ class _TcpScreenState extends State { _hostController.dispose(); _portController.dispose(); _connector.removeListener(_connectionListener); - if (!_navigatedToContacts && + if (!_navigatedToChannels && _connector.activeTransport == MeshCoreTransportType.tcp && _connector.state != MeshCoreConnectionState.disconnected) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index 6b8fe9d9..25992de8 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -11,7 +11,7 @@ import '../utils/platform_info.dart'; import '../utils/usb_port_labels.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; -import 'contacts_screen.dart'; +import 'channels_screen.dart'; import 'scanner_screen.dart'; import 'tcp_screen.dart'; @@ -25,7 +25,7 @@ class UsbScreen extends StatefulWidget { class _UsbScreenState extends State { final List _ports = []; bool _isLoadingPorts = true; - bool _navigatedToContacts = false; + bool _navigatedToChannels = false; bool _didScheduleInitialLoad = false; Timer? _hotPlugTimer; late final MeshCoreConnector _connector; @@ -41,14 +41,14 @@ class _UsbScreenState extends State { _connectionListener = () { if (!mounted) return; if (_connector.state == MeshCoreConnectionState.disconnected) { - _navigatedToContacts = false; + _navigatedToChannels = false; } if (_connector.state == MeshCoreConnectionState.connected && _connector.isUsbTransportConnected && - !_navigatedToContacts) { - _navigatedToContacts = true; + !_navigatedToChannels) { + _navigatedToChannels = true; Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), + MaterialPageRoute(builder: (_) => const ChannelsScreen()), ); } }; @@ -72,7 +72,7 @@ class _UsbScreenState extends State { _hotPlugTimer?.cancel(); _hotPlugTimer = null; _connector.removeListener(_connectionListener); - if (!_navigatedToContacts && + if (!_navigatedToChannels && _connector.activeTransport == MeshCoreTransportType.usb && _connector.state != MeshCoreConnectionState.disconnected) { WidgetsBinding.instance.addPostFrameCallback((_) { From 2328848400b002e626363eea535c2b42f426504d Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Tue, 26 May 2026 00:37:42 +0300 Subject: [PATCH 38/38] Onstart sync progressbar: fix potential bug with spinner on interrupt synchronization --- lib/connector/meshcore_connector.dart | 9 +++++++++ lib/screens/channels_screen.dart | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d1581e44..271ea069 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3637,6 +3637,12 @@ class MeshCoreConnector extends ChangeNotifier { if (completed) { _hasLoadedChannels = true; _previousChannelsCache.clear(); + } else if (_channels.isEmpty && _previousChannelsCache.isNotEmpty) { + // A failed initial sync should not leave the UI empty/spinning forever. + // Restore the pre-sync list so cached channels remain usable. + _channels.addAll(_previousChannelsCache); + _applyChannelOrder(); + _recalculateCachedChannelsUnreadTotal(); } if (isConnected) { @@ -3644,6 +3650,9 @@ class MeshCoreConnector extends ChangeNotifier { } // Keep cache on failure/disconnection for future attempts + if (!completed) { + notifyListeners(); + } } void _startPostChannelInitialQueuedMessageSync() { diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 1475bdef..40726021 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -155,12 +155,13 @@ class _ChannelsScreenState extends State }, child: () { final channels = connector.channels; - final waitingForInitialChannels = - !connector.hasLoadedChannels && !connector.isLoadingChannels; final waitingForFirstChannel = connector.isLoadingChannels && channels.isEmpty; - if (waitingForInitialChannels || waitingForFirstChannel) { + // Only block the list while the first channel is actively loading. + // If the initial sync aborts, show cached/partial channels instead + // of trapping the user behind an idle spinner. + if (waitingForFirstChannel) { return const Center(child: CircularProgressIndicator()); }