diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index c5acba9..ba97771 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Нова група", "contacts_groupName": "Група", "contacts_groupNameRequired": "Името на групата е задължително.", + "contacts_groupNameReserved": "Това име на група е запазено", "contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2eed922..3b623ba 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Neue Gruppe", "contacts_groupName": "Gruppenname", "contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.", + "contacts_groupNameReserved": "Dieser Gruppenname ist reserviert", "contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 58569e4..96060a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -416,6 +416,7 @@ "contacts_newGroup": "New Group", "contacts_groupName": "Group name", "contacts_groupNameRequired": "Group name is required", + "contacts_groupNameReserved": "This group name is reserved", "contacts_groupAlreadyExists": "Group \"{name}\" already exists", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 25e0345..7cf7898 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nuevo Grupo", "contacts_groupName": "Nombre del grupo", "contacts_groupNameRequired": "El nombre del grupo es obligatorio", + "contacts_groupNameReserved": "Este nombre de grupo está reservado", "contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5d586f4..bbd488c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nouveau Groupe", "contacts_groupName": "Nom du groupe", "contacts_groupNameRequired": "Le nom du groupe est obligatoire.", + "contacts_groupNameReserved": "Ce nom de groupe est réservé", "contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 4de9e9d..06dbd12 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nuovo Gruppo", "contacts_groupName": "Nome gruppo", "contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.", + "contacts_groupNameReserved": "Questo nome del gruppo è riservato", "contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3690bf2..b278e36 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1714,6 +1714,12 @@ abstract class AppLocalizations { /// **'Group name is required'** String get contacts_groupNameRequired; + /// No description provided for @contacts_groupNameReserved. + /// + /// In en, this message translates to: + /// **'This group name is reserved'** + String get contacts_groupNameReserved; + /// No description provided for @contacts_groupAlreadyExists. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 785f373..8b6c121 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -902,6 +902,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get contacts_groupNameRequired => 'Името на групата е задължително.'; + @override + String get contacts_groupNameReserved => 'Това име на група е запазено'; + @override String contacts_groupAlreadyExists(String name) { return 'Групата \"$name\" вече съществува.'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 708e0ab..1aa2109 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -902,6 +902,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.'; + @override + String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert'; + @override String contacts_groupAlreadyExists(String name) { return 'Die Gruppe \"$name\" existiert bereits.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4938dd4..255f12b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -889,6 +889,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contacts_groupNameRequired => 'Group name is required'; + @override + String get contacts_groupNameReserved => 'This group name is reserved'; + @override String contacts_groupAlreadyExists(String name) { return 'Group \"$name\" already exists'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 2d4e2fb..36efdb3 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -901,6 +901,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio'; + @override + String get contacts_groupNameReserved => + 'Este nombre de grupo está reservado'; + @override String contacts_groupAlreadyExists(String name) { return 'El grupo \"$name\" ya existe'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 28bbab3..ee76be3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -905,6 +905,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.'; + @override + String get contacts_groupNameReserved => 'Ce nom de groupe est réservé'; + @override String contacts_groupAlreadyExists(String name) { return 'Le groupe \"$name\" existe déjà.'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index b510bc1..6566d6a 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -901,6 +901,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.'; + @override + String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato'; + @override String contacts_groupAlreadyExists(String name) { return 'Il gruppo \"$name\" esiste già.'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 7c054dd..99ca553 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -895,6 +895,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get contacts_groupNameRequired => 'De groepnaam is verplicht.'; + @override + String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd'; + @override String contacts_groupAlreadyExists(String name) { return 'De groep \"$name\" bestaat al.'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index dec6583..353f448 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -904,6 +904,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana'; + @override + String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona'; + @override String contacts_groupAlreadyExists(String name) { return 'Grupa \"$name\" już istnieje'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4d8d20e..8427a49 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -903,6 +903,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.'; + @override + String get contacts_groupNameReserved => 'Este nome de grupo está reservado'; + @override String contacts_groupAlreadyExists(String name) { return 'O grupo \"$name\" já existe'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 60aa486..74036a2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -902,6 +902,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contacts_groupNameRequired => 'Имя группы обязательно'; + @override + String get contacts_groupNameReserved => 'Это имя группы зарезервировано'; + @override String contacts_groupAlreadyExists(String name) { return 'Группа \"$name\" уже существует'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 4e11719..de01520 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -894,6 +894,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get contacts_groupNameRequired => 'Skupina musí mať názov.'; + @override + String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný'; + @override String contacts_groupAlreadyExists(String name) { return 'Skupina \"$name\" už existuje'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f967db4..cfe7427 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -892,6 +892,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_groupNameRequired => 'Ime skupine je obvezno.'; + @override + String get contacts_groupNameReserved => 'To ime skupine je rezervirano'; + @override String contacts_groupAlreadyExists(String name) { return 'Skupina \"$name\" že obstaja'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 200bdbe..93b8917 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -888,6 +888,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt'; + @override + String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat'; + @override String contacts_groupAlreadyExists(String name) { return 'Gruppen \"$name\" finns redan.'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8dfe123..0db473c 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -898,6 +898,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_groupNameRequired => 'Назва групи обов\'язкова.'; + @override + String get contacts_groupNameReserved => 'Ця назва групи зарезервована'; + @override String contacts_groupAlreadyExists(String name) { return 'Група «$name» вже існує.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ecd6813..55a4063 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -845,6 +845,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contacts_groupNameRequired => '请输入群聊名称'; + @override + String get contacts_groupNameReserved => '该群组名称已被保留'; + @override String contacts_groupAlreadyExists(String name) { return '名为 \"$name\" 的群聊已存在'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d38fb4c..427b998 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nieuwe Groep", "contacts_groupName": "Groepnaam", "contacts_groupNameRequired": "De groepnaam is verplicht.", + "contacts_groupNameReserved": "Deze groepsnaam is gereserveerd", "contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 9dc3b33..ab980ff 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nowa Grupa", "contacts_groupName": "Nazwa grupy", "contacts_groupNameRequired": "Nazwa grupy jest wymagana", + "contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona", "contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index cded31f..e53649a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Novo Grupo", "contacts_groupName": "Nome do grupo", "contacts_groupNameRequired": "O nome do grupo é obrigatório.", + "contacts_groupNameReserved": "Este nome de grupo está reservado", "contacts_groupAlreadyExists": "O grupo \"{name}\" já existe", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 43e1b9a..00b71d0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -212,6 +212,7 @@ "contacts_newGroup": "Новая группа", "contacts_groupName": "Имя группы", "contacts_groupNameRequired": "Имя группы обязательно", + "contacts_groupNameReserved": "Это имя группы зарезервировано", "contacts_groupAlreadyExists": "Группа \"{name}\" уже существует", "contacts_filterContacts": "Фильтр контактов...", "contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index f03d276..c05a171 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nová skupina", "contacts_groupName": "Názov skupiny", "contacts_groupNameRequired": "Skupina musí mať názov.", + "contacts_groupNameReserved": "Tento názov skupiny je rezervovaný", "contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 4a4b5cb..e521ed2 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nova skupina", "contacts_groupName": "Ime skupine", "contacts_groupNameRequired": "Ime skupine je obvezno.", + "contacts_groupNameReserved": "To ime skupine je rezervirano", "contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 6a33e11..c2a538d 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Ny grupp", "contacts_groupName": "Gruppnamn", "contacts_groupNameRequired": "Gruppnamnet är obligatoriskt", + "contacts_groupNameReserved": "Detta gruppnamn är reserverat", "contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c179ca3..3d265dc 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -286,6 +286,7 @@ "contacts_newGroup": "Нова група", "contacts_groupName": "Назва групи", "contacts_groupNameRequired": "Назва групи обов'язкова.", + "contacts_groupNameReserved": "Ця назва групи зарезервована", "contacts_groupAlreadyExists": "Група «{name}» вже існує.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cac4b79..26fc6e6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -300,6 +300,7 @@ "contacts_newGroup": "新建群聊", "contacts_groupName": "群聊名称", "contacts_groupNameRequired": "请输入群聊名称", + "contacts_groupNameReserved": "该群组名称已被保留", "contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/main.dart b/lib/main.dart index 9e53e21..1ad1989 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; import 'services/chat_text_scale_service.dart'; +import 'services/ui_view_state_service.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -39,6 +40,7 @@ void main() async { final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); final chatTextScaleService = ChatTextScaleService(); + final uiViewStateService = UiViewStateService(); // Load settings await appSettingsService.loadSettings(); @@ -56,6 +58,7 @@ void main() async { _registerThirdPartyLicenses(); await chatTextScaleService.initialize(); + await uiViewStateService.initialize(); // Wire up connector with services connector.initialize( @@ -86,6 +89,7 @@ void main() async { appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, chatTextScaleService: chatTextScaleService, + uiViewStateService: uiViewStateService, ), ); } @@ -121,6 +125,7 @@ class MeshCoreApp extends StatelessWidget { final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; final ChatTextScaleService chatTextScaleService; + final UiViewStateService uiViewStateService; const MeshCoreApp({ super.key, @@ -133,6 +138,7 @@ class MeshCoreApp extends StatelessWidget { required this.appDebugLogService, required this.mapTileCacheService, required this.chatTextScaleService, + required this.uiViewStateService, }); @override @@ -146,6 +152,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: chatTextScaleService), + ChangeNotifierProvider.value(value: uiViewStateService), Provider.value(value: storage), Provider.value(value: mapTileCacheService), ], diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index b56b563..98694be 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../services/app_settings_service.dart'; +import '../services/ui_view_state_service.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; @@ -28,8 +29,6 @@ import 'contacts_screen.dart'; import 'map_screen.dart'; import 'settings_screen.dart'; -enum ChannelSortOption { manual, name, latestMessages, unread } - class ChannelsScreen extends StatefulWidget { final bool hideBackButton; @@ -43,9 +42,7 @@ class _ChannelsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); final CommunityStore _communityStore = CommunityStore(); - String _searchQuery = ''; Timer? _searchDebounce; - ChannelSortOption _sortOption = ChannelSortOption.manual; List _communities = []; // Cache of PSK hex -> Community for quick lookup @@ -56,6 +53,9 @@ class _ChannelsScreenState extends State @override void initState() { super.initState(); + _searchController.text = context + .read() + .channelsSearchText; WidgetsBinding.instance.addPostFrameCallback((_) { context.read().getChannels(); _loadCommunities(); @@ -110,6 +110,7 @@ class _ChannelsScreenState extends State @override Widget build(BuildContext context) { final connector = context.watch(); + final viewState = context.watch(); final channelMessageStore = ChannelMessageStore(); channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; @@ -205,6 +206,7 @@ class _ChannelsScreenState extends State final filteredChannels = _filterAndSortChannels( channels, connector, + viewState, ); return Column( @@ -219,17 +221,19 @@ class _ChannelsScreenState extends State suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - if (_searchQuery.isNotEmpty) + if (viewState.channelsSearchText.isNotEmpty) IconButton( icon: const Icon(Icons.clear), onPressed: () { + _searchDebounce?.cancel(); + _searchDebounce = null; _searchController.clear(); - setState(() { - _searchQuery = ''; - }); + context + .read() + .setChannelsSearchText(''); }, ), - _buildFilterButton(), + _buildFilterButton(viewState), ], ), border: OutlineInputBorder( @@ -246,9 +250,9 @@ class _ChannelsScreenState extends State const Duration(milliseconds: 300), () { if (!mounted) return; - setState(() { - _searchQuery = value.toLowerCase(); - }); + context + .read() + .setChannelsSearchText(value); }, ); }, @@ -283,8 +287,9 @@ class _ChannelsScreenState extends State ), ], ) - : (_sortOption == ChannelSortOption.manual && - _searchQuery.isEmpty) + : (viewState.channelsSortOption == + ChannelSortOption.manual && + viewState.channelsSearchText.isEmpty) ? ReorderableListView.builder( padding: const EdgeInsets.only( left: 16, @@ -584,59 +589,40 @@ class _ChannelsScreenState extends State await showDisconnectDialog(context, connector); } - Widget _buildFilterButton() { - const actionSortManual = 0; - const actionSortName = 1; - const actionSortLatest = 2; - const actionSortUnread = 3; - - return SortFilterMenu( + Widget _buildFilterButton(UiViewStateService viewState) { + return SortFilterMenu( tooltip: context.l10n.listFilter_tooltip, sections: [ - SortFilterMenuSection( + SortFilterMenuSection( title: context.l10n.channels_sortBy, options: [ - SortFilterMenuOption( - value: actionSortManual, + SortFilterMenuOption( + value: ChannelSortOption.manual, label: context.l10n.channels_sortManual, - checked: _sortOption == ChannelSortOption.manual, + checked: viewState.channelsSortOption == ChannelSortOption.manual, ), - SortFilterMenuOption( - value: actionSortName, + SortFilterMenuOption( + value: ChannelSortOption.name, label: context.l10n.channels_sortAZ, - checked: _sortOption == ChannelSortOption.name, + checked: viewState.channelsSortOption == ChannelSortOption.name, ), - SortFilterMenuOption( - value: actionSortLatest, + SortFilterMenuOption( + value: ChannelSortOption.latestMessages, label: context.l10n.channels_sortLatestMessages, - checked: _sortOption == ChannelSortOption.latestMessages, + checked: + viewState.channelsSortOption == + ChannelSortOption.latestMessages, ), - SortFilterMenuOption( - value: actionSortUnread, + SortFilterMenuOption( + value: ChannelSortOption.unread, label: context.l10n.channels_sortUnread, - checked: _sortOption == ChannelSortOption.unread, + checked: viewState.channelsSortOption == ChannelSortOption.unread, ), ], ), ], - onSelected: (action) { - setState(() { - switch (action) { - case actionSortManual: - _sortOption = ChannelSortOption.manual; - break; - case actionSortLatest: - _sortOption = ChannelSortOption.latestMessages; - break; - case actionSortUnread: - _sortOption = ChannelSortOption.unread; - break; - case actionSortName: - default: - _sortOption = ChannelSortOption.name; - break; - } - }); + onSelected: (sortOption) { + viewState.setChannelsSortOption(sortOption); }, ); } @@ -644,11 +630,14 @@ class _ChannelsScreenState extends State List _filterAndSortChannels( List channels, MeshCoreConnector connector, + UiViewStateService viewState, ) { var filtered = channels.where((channel) { - if (_searchQuery.isEmpty) return true; + if (viewState.channelsSearchText.isEmpty) return true; final label = _normalizeChannelName(channel); - return label.toLowerCase().contains(_searchQuery); + return label.toLowerCase().contains( + viewState.channelsSearchText.toLowerCase(), + ); }).toList(); int compareByName(Channel a, Channel b) { @@ -657,7 +646,7 @@ class _ChannelsScreenState extends State return nameA.toLowerCase().compareTo(nameB.toLowerCase()); } - switch (_sortOption) { + switch (viewState.channelsSortOption) { case ChannelSortOption.manual: break; case ChannelSortOption.latestMessages: diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 3fef9ec..abb29fa 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -12,8 +12,9 @@ import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; import '../models/contact.dart'; import '../models/contact_group.dart'; -import '../storage/contact_group_store.dart'; +import '../services/ui_view_state_service.dart'; import '../utils/contact_search.dart'; +import '../storage/contact_group_store.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; @@ -47,12 +48,10 @@ class ContactsScreen extends StatefulWidget { class _ContactsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); - String _searchQuery = ''; - ContactSortOption _sortOption = ContactSortOption.lastSeen; - bool _showUnreadOnly = false; - ContactTypeFilter _typeFilter = ContactTypeFilter.all; final ContactGroupStore _groupStore = ContactGroupStore(); + MeshCoreConnector? _scopeSyncConnector; List _groups = []; + String _loadedGroupScopeKeyHex = ''; Timer? _searchDebounce; final Set _pendingOperations = {}; @@ -62,30 +61,91 @@ class _ContactsScreenState extends State @override void initState() { super.initState(); + _searchController.text = context + .read() + .contactsSearchText; _loadGroups(); _setupFrameListener(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final connector = context.read(); + if (!identical(_scopeSyncConnector, connector)) { + _scopeSyncConnector?.removeListener(_handleConnectorScopeChange); + _scopeSyncConnector = connector; + _scopeSyncConnector?.addListener(_handleConnectorScopeChange); + } + _handleConnectorScopeChange(); + } + @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); _frameSubscription?.cancel(); + _scopeSyncConnector?.removeListener(_handleConnectorScopeChange); super.dispose(); } + void _handleConnectorScopeChange() { + final connector = _scopeSyncConnector; + if (connector == null) return; + _syncGroupScopeIfNeeded(connector); + } + Future _loadGroups() async { + final selfPublicKeyHex = context.read().selfPublicKeyHex; + if (selfPublicKeyHex.isEmpty) { + return; + } + _groupStore.setPublicKeyHex = selfPublicKeyHex; final groups = await _groupStore.loadGroups(); if (!mounted) return; setState(() { + _loadedGroupScopeKeyHex = selfPublicKeyHex; _groups = groups; + _ensureValidSelectedGroup(); }); } Future _saveGroups() async { + final selfPublicKeyHex = context.read().selfPublicKeyHex; + if (selfPublicKeyHex.isEmpty) { + return; + } + _groupStore.setPublicKeyHex = selfPublicKeyHex; await _groupStore.saveGroups(_groups); } + bool _hasGroupStoreScope(MeshCoreConnector connector) { + return connector.selfPublicKeyHex.isNotEmpty; + } + + void _syncGroupScopeIfNeeded(MeshCoreConnector connector) { + final selfPublicKeyHex = connector.selfPublicKeyHex; + if (selfPublicKeyHex.isEmpty || + selfPublicKeyHex == _loadedGroupScopeKeyHex) { + return; + } + _loadGroups(); + } + + void _collapseContactsSearch(UiViewStateService viewState) { + _searchDebounce?.cancel(); + _searchDebounce = null; + _searchController.clear(); + viewState.setContactsSearchText(''); + viewState.setContactsSearchExpanded(false); + } + + void _showGroupsUnavailableMessage(BuildContext context) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.common_loading))); + } + void _setupFrameListener() { final connector = Provider.of(context, listen: false); // Listen for incoming text messages from the repeater @@ -375,31 +435,163 @@ class _ContactsScreenState extends State await showDisconnectDialog(context, connector); } - Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) { + ContactGroup? _selectedGroupForName(String selectedGroupName) { + if (selectedGroupName == contactsAllGroupsValue) return null; + for (final group in _groups) { + if (group.name == selectedGroupName) return group; + } + return null; + } + + void _ensureValidSelectedGroup() { + final viewState = context.read(); + if (viewState.contactsSelectedGroupName == contactsAllGroupsValue) return; + final exists = _groups.any( + (group) => group.name == viewState.contactsSelectedGroupName, + ); + if (!exists) { + viewState.setContactsSelectedGroupName(contactsAllGroupsValue); + } + } + + void _closeDropdownAndRun(BuildContext context, VoidCallback action) { + Navigator.of(context).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + action(); + }); + } + + Widget _buildFilterButton( + BuildContext context, + UiViewStateService viewState, + ) { return ContactsFilterMenu( - sortOption: _sortOption, - typeFilter: _typeFilter, - showUnreadOnly: _showUnreadOnly, + sortOption: viewState.contactsSortOption, + typeFilter: viewState.contactsTypeFilter, + showUnreadOnly: viewState.contactsShowUnreadOnly, onSortChanged: (value) { - setState(() { - _sortOption = value; - }); + viewState.setContactsSortOption(value); }, onTypeFilterChanged: (value) { - setState(() { - _typeFilter = value; - }); + viewState.setContactsTypeFilter(value); }, onUnreadOnlyChanged: (value) { - setState(() { - _showUnreadOnly = value; - }); + viewState.setContactsShowUnreadOnly(value); }, - onNewGroup: () => _showGroupEditor(context, connector.contacts), + ); + } + + Widget _buildGroupButton( + BuildContext context, + MeshCoreConnector connector, + UiViewStateService viewState, + List contacts, + List sortedGroups, + ) { + final canManageGroups = _hasGroupStoreScope(connector); + final selectedGroupName = + _selectedGroupForName(viewState.contactsSelectedGroupName)?.name ?? + context.l10n.listFilter_all; + final double menuWidth = (MediaQuery.sizeOf(context).width - 16).clamp( + 0.0, + double.infinity, + ); + + return PopupMenuButton( + position: PopupMenuPosition.under, + constraints: BoxConstraints.tightFor(width: menuWidth), + onSelected: (String value) { + viewState.setContactsSelectedGroupName(value); + }, + itemBuilder: (menuContext) => [ + PopupMenuItem( + value: contactsAllGroupsValue, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(menuContext.l10n.listFilter_all), + IconButton( + tooltip: menuContext.l10n.contacts_newGroup, + icon: const Icon(Icons.group_add, size: 20), + onPressed: canManageGroups + ? () => _closeDropdownAndRun( + menuContext, + () => _showGroupEditor(this.context, contacts), + ) + : () => _closeDropdownAndRun( + menuContext, + () => _showGroupsUnavailableMessage(this.context), + ), + ), + ], + ), + ), + ...sortedGroups.map((group) { + return PopupMenuItem( + value: group.name, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(group.name, overflow: TextOverflow.ellipsis), + ), + IconButton( + tooltip: menuContext.l10n.contacts_editGroup, + icon: const Icon(Icons.edit, size: 20), + onPressed: canManageGroups + ? () => _closeDropdownAndRun( + menuContext, + () => _showGroupEditor( + this.context, + contacts, + group: group, + ), + ) + : () => _closeDropdownAndRun( + menuContext, + () => _showGroupsUnavailableMessage(this.context), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: menuContext.l10n.contacts_deleteGroup, + icon: const Icon(Icons.delete, size: 20, color: Colors.red), + onPressed: canManageGroups + ? () => _closeDropdownAndRun( + menuContext, + () => _confirmDeleteGroup(this.context, group), + ) + : () => _closeDropdownAndRun( + menuContext, + () => _showGroupsUnavailableMessage(this.context), + ), + ), + ], + ), + ); + }), + ], + child: SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text(selectedGroupName, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), ); } Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { + final viewState = context.watch(); final contacts = connector.contacts; final shouldShowStartupSpinner = contacts.isEmpty && @@ -421,92 +613,171 @@ class _ContactsScreenState extends State ); } - final filteredAndSorted = _filterAndSortContacts(contacts, connector); - final filteredGroups = _showUnreadOnly - ? const [] - : _filterAndSortGroups(_groups, contacts); + final filteredAndSorted = _filterAndSortContacts( + contacts, + connector, + viewState, + ); String hintText = ""; - switch (_typeFilter) { + switch (viewState.contactsTypeFilter) { case ContactTypeFilter.all: hintText = context.l10n.contacts_searchContacts( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.users: hintText = context.l10n.contacts_searchUsers( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.repeaters: hintText = context.l10n.contacts_searchRepeaters( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.rooms: hintText = context.l10n.contacts_searchRoomServers( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.favorites: hintText = context.l10n.contacts_searchFavorites( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; } + final groupsByName = {}; + for (final group in _groups) { + groupsByName.putIfAbsent(group.name, () => group); + } + final sortedGroups = groupsByName.values.toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + final screenWidth = MediaQuery.sizeOf(context).width; + final searchExpandedWidth = (screenWidth * 0.52).clamp( + 97.0, + double.infinity, + ); // allow expansion up to 52% of screen width, but not less than the collapsed width + final searchCollapsedWidth = (screenWidth * 0.22).clamp( + 97.0, + 120.0, + ); //two 48px icon buttons + 1px divider + return Column( children: [ Padding( padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: hintText, - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_searchQuery.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - setState(() { - _searchQuery = ''; - }); - }, + child: Row( + children: [ + Expanded( + child: _buildGroupButton( + context, + connector, + viewState, + contacts, + sortedGroups, + ), + ), + const SizedBox(width: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: viewState.contactsSearchExpanded + ? searchExpandedWidth + : searchCollapsedWidth, + height: 48, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outline, ), - _buildFilterButton(context, connector), - ], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: viewState.contactsSearchExpanded + ? TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + onChanged: (value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 300), + () { + if (!mounted) return; + context + .read() + .setContactsSearchText(value); + }, + ); + }, + ) + : const SizedBox.shrink(), + ), + SizedBox( + width: 48, + height: 48, + child: IconButton( + onPressed: () { + if (viewState.contactsSearchExpanded) { + _collapseContactsSearch(viewState); + return; + } + viewState.setContactsSearchExpanded(true); + }, + icon: Icon( + viewState.contactsSearchExpanded + ? Icons.close + : Icons.search, + ), + ), + ), + Container( + width: 1, + height: 24, + color: Theme.of(context).colorScheme.outlineVariant, + ), + SizedBox( + width: 48, + height: 48, + child: _buildFilterButton(context, viewState), + ), + ], + ), + ), ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - _searchDebounce?.cancel(); - _searchDebounce = Timer(const Duration(milliseconds: 300), () { - if (!mounted) return; - setState(() { - _searchQuery = value.toLowerCase(); - }); - }); - }, + ], ), ), Expanded( - child: filteredAndSorted.isEmpty && filteredGroups.isEmpty + child: filteredAndSorted.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -514,7 +785,7 @@ class _ContactsScreenState extends State Icon(Icons.search_off, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( - _showUnreadOnly + viewState.contactsShowUnreadOnly ? context.l10n.contacts_noUnreadContacts : context.l10n.contacts_noContactsFound, style: TextStyle(fontSize: 16, color: Colors.grey[600]), @@ -525,14 +796,9 @@ class _ContactsScreenState extends State : RefreshIndicator( onRefresh: () => connector.getContacts(), child: ListView.builder( - itemCount: filteredGroups.length + filteredAndSorted.length, + itemCount: filteredAndSorted.length, itemBuilder: (context, index) { - if (index < filteredGroups.length) { - final group = filteredGroups[index]; - return _buildGroupTile(context, group, contacts); - } - final contact = - filteredAndSorted[index - filteredGroups.length]; + final contact = filteredAndSorted[index]; final unreadCount = connector.getUnreadCountForContact( contact, ); @@ -553,55 +819,26 @@ class _ContactsScreenState extends State ); } - List _filterAndSortGroups( - List groups, - List contacts, - ) { - final query = _searchQuery.trim().toLowerCase(); - final contactsByKey = {}; - for (final contact in contacts) { - contactsByKey[contact.publicKeyHex] = contact; - } - - final filtered = groups - .where((group) { - if (query.isEmpty) return true; - if (group.name.toLowerCase().contains(query)) return true; - for (final key in group.memberKeys) { - final contact = contactsByKey[key]; - if (contact != null && matchesContactQuery(contact, query)) { - return true; - } - } - return false; - }) - .where((group) { - if (_typeFilter == ContactTypeFilter.all) return true; - // Groups don't have a favorite flag, so hide them under favorites filter - if (_typeFilter == ContactTypeFilter.favorites) return false; - for (final key in group.memberKeys) { - final contact = contactsByKey[key]; - if (contact != null && _matchesTypeFilter(contact)) return true; - } - return false; - }) - .toList(); - - filtered.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - return filtered; - } - List _filterAndSortContacts( List contacts, MeshCoreConnector connector, + UiViewStateService viewState, ) { var filtered = contacts.where((contact) { - if (_searchQuery.isEmpty) return true; - return matchesContactQuery(contact, _searchQuery); + if (viewState.contactsSearchText.isEmpty) return true; + return matchesContactQuery(contact, viewState.contactsSearchText); }).toList(); + final selectedGroup = _selectedGroupForName( + viewState.contactsSelectedGroupName, + ); + if (selectedGroup != null) { + final memberKeys = selectedGroup.memberKeys.toSet(); + filtered = filtered + .where((contact) => memberKeys.contains(contact.publicKeyHex)) + .toList(); + } + // Filter out own node from the list if (connector.selfPublicKey != null) { final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!); @@ -610,17 +847,22 @@ class _ContactsScreenState extends State }).toList(); } - if (_typeFilter != ContactTypeFilter.all) { - filtered = filtered.where(_matchesTypeFilter).toList(); + if (viewState.contactsTypeFilter != ContactTypeFilter.all) { + filtered = filtered + .where( + (contact) => + _matchesTypeFilter(contact, viewState.contactsTypeFilter), + ) + .toList(); } - if (_showUnreadOnly) { + if (viewState.contactsShowUnreadOnly) { filtered = filtered.where((contact) { return connector.getUnreadCountForContact(contact) > 0; }).toList(); } - switch (_sortOption) { + switch (viewState.contactsSortOption) { case ContactSortOption.lastSeen: filtered.sort( (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), @@ -649,8 +891,8 @@ class _ContactsScreenState extends State return filtered; } - bool _matchesTypeFilter(Contact contact) { - switch (_typeFilter) { + bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) { + switch (typeFilter) { case ContactTypeFilter.all: return true; case ContactTypeFilter.favorites: @@ -671,57 +913,6 @@ class _ContactsScreenState extends State : contact.lastSeen; } - Widget _buildGroupTile( - BuildContext context, - ContactGroup group, - List contacts, - ) { - final memberContacts = _resolveGroupContacts(group, contacts); - final subtitle = _formatGroupMembers(context, memberContacts); - return ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.teal, - child: Icon(Icons.group, color: Colors.white, size: 20), - ), - title: Text(group.name), - subtitle: Text(subtitle), - trailing: Text( - memberContacts.length.toString(), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - onTap: () => _showGroupOptions(context, group, contacts), - onLongPress: () => _showGroupOptions(context, group, contacts), - ); - } - - List _resolveGroupContacts( - ContactGroup group, - List contacts, - ) { - final byKey = {}; - for (final contact in contacts) { - byKey[contact.publicKeyHex] = contact; - } - final resolved = []; - for (final key in group.memberKeys) { - final contact = byKey[key]; - if (contact != null) { - resolved.add(contact); - } - } - resolved.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - return resolved; - } - - String _formatGroupMembers(BuildContext context, List members) { - if (members.isEmpty) return context.l10n.contacts_noMembers; - final names = members.map((c) => c.name).toList(); - if (names.length <= 2) return names.join(', '); - return '${names.take(2).join(', ')} +${names.length - 2}'; - } - void _openChat(BuildContext context, Contact contact) { // Check if this is a repeater if (contact.type == advTypeRepeater) { @@ -799,58 +990,11 @@ class _ContactsScreenState extends State ); } - void _showGroupOptions( - BuildContext context, - ContactGroup group, - List contacts, - ) { - final members = _resolveGroupContacts(group, contacts); - showModalBottomSheet( - context: context, - builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.edit), - title: Text(context.l10n.contacts_editGroup), - onTap: () { - Navigator.pop(sheetContext); - _showGroupEditor(context, contacts, group: group); - }, - ), - ListTile( - leading: const Icon(Icons.delete, color: Colors.red), - title: Text( - context.l10n.contacts_deleteGroup, - style: const TextStyle(color: Colors.red), - ), - onTap: () { - Navigator.pop(sheetContext); - _confirmDeleteGroup(context, group); - }, - ), - if (members.isNotEmpty) const Divider(), - ...members.map((member) { - return ListTile( - leading: const Icon(Icons.person), - title: Text(member.name), - subtitle: Text(member.typeLabel), - onTap: () { - Navigator.pop(sheetContext); - _openChat(context, member); - }, - ); - }), - ], - ), - ), - ), - ); - } - void _confirmDeleteGroup(BuildContext context, ContactGroup group) { + if (!_hasGroupStoreScope(context.read())) { + _showGroupsUnavailableMessage(context); + return; + } showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -866,6 +1010,7 @@ class _ContactsScreenState extends State Navigator.pop(dialogContext); setState(() { _groups.removeWhere((g) => g.name == group.name); + _ensureValidSelectedGroup(); }); await _saveGroups(); }, @@ -884,6 +1029,10 @@ class _ContactsScreenState extends State List contacts, { ContactGroup? group, }) { + if (!_hasGroupStoreScope(context.read())) { + _showGroupsUnavailableMessage(context); + return; + } final isEditing = group != null; final nameController = TextEditingController(text: group?.name ?? ''); final selectedKeys = {...group?.memberKeys ?? []}; @@ -910,64 +1059,70 @@ class _ContactsScreenState extends State ), content: SizedBox( width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: InputDecoration( - labelText: context.l10n.contacts_groupName, - border: const OutlineInputBorder(), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: InputDecoration( + labelText: context.l10n.contacts_groupName, + border: const OutlineInputBorder(), + ), ), - ), - const SizedBox(height: 12), - TextField( - decoration: InputDecoration( - hintText: context.l10n.contacts_filterContacts, - prefixIcon: const Icon(Icons.search), - border: const OutlineInputBorder(), - isDense: true, + const SizedBox(height: 12), + TextField( + decoration: InputDecoration( + hintText: context.l10n.contacts_filterContacts, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + isDense: true, + ), + onChanged: (value) { + setDialogState(() { + filterQuery = value.toLowerCase(); + }); + }, ), - onChanged: (value) { - setDialogState(() { - filterQuery = value.toLowerCase(); - }); - }, - ), - const SizedBox(height: 12), - SizedBox( - height: 240, - child: filteredContacts.isEmpty - ? Center( - child: Text( - context.l10n.contacts_noContactsMatchFilter, + const SizedBox(height: 12), + Expanded( + child: filteredContacts.isEmpty + ? Center( + child: Text( + context.l10n.contacts_noContactsMatchFilter, + ), + ) + : ListView.builder( + itemCount: filteredContacts.length, + itemBuilder: (context, index) { + final contact = filteredContacts[index]; + final isSelected = selectedKeys.contains( + contact.publicKeyHex, + ); + return CheckboxListTile( + value: isSelected, + title: Text(contact.name), + subtitle: Text(contact.typeLabel), + onChanged: (value) { + setDialogState(() { + if (value == true) { + selectedKeys.add(contact.publicKeyHex); + } else { + selectedKeys.remove( + contact.publicKeyHex, + ); + } + }); + }, + ); + }, ), - ) - : ListView.builder( - itemCount: filteredContacts.length, - itemBuilder: (context, index) { - final contact = filteredContacts[index]; - final isSelected = selectedKeys.contains( - contact.publicKeyHex, - ); - return CheckboxListTile( - value: isSelected, - title: Text(contact.name), - subtitle: Text(contact.typeLabel), - onChanged: (value) { - setDialogState(() { - if (value == true) { - selectedKeys.add(contact.publicKeyHex); - } else { - selectedKeys.remove(contact.publicKeyHex); - } - }); - }, - ); - }, - ), - ), - ], + ), + ], + ), ), ), actions: [ @@ -986,6 +1141,15 @@ class _ContactsScreenState extends State ); return; } + if (name.toLowerCase() == + contactsAllGroupsValue.toLowerCase()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_groupNameReserved), + ), + ); + return; + } final exists = _groups.any((g) { if (isEditing && g.name == group.name) return false; return g.name.toLowerCase() == name.toLowerCase(); @@ -1001,15 +1165,21 @@ class _ContactsScreenState extends State return; } setState(() { + final viewState = context.read(); if (isEditing) { final index = _groups.indexWhere( (g) => g.name == group.name, ); if (index != -1) { + final wasSelected = + viewState.contactsSelectedGroupName == group.name; _groups[index] = ContactGroup( name: name, memberKeys: selectedKeys.toList(), ); + if (wasSelected) { + viewState.setContactsSelectedGroupName(name); + } } } else { _groups.add( @@ -1018,7 +1188,9 @@ class _ContactsScreenState extends State memberKeys: selectedKeys.toList(), ), ); + viewState.setContactsSelectedGroupName(name); } + _ensureValidSelectedGroup(); }); await _saveGroups(); if (dialogContext.mounted) { diff --git a/lib/services/chat_text_scale_service.dart b/lib/services/chat_text_scale_service.dart index 21d6a5f..205c258 100644 --- a/lib/services/chat_text_scale_service.dart +++ b/lib/services/chat_text_scale_service.dart @@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier { void _commitScale() { _saveTimer?.cancel(); - PrefsManager.instance.setDouble(_prefKey, _scale); + unawaited(PrefsManager.instance.setDouble(_prefKey, _scale)); } double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble(); diff --git a/lib/services/ui_view_state_service.dart b/lib/services/ui_view_state_service.dart new file mode 100644 index 0000000..7f2a03a --- /dev/null +++ b/lib/services/ui_view_state_service.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../storage/prefs_manager.dart'; +import '../utils/contact_search.dart'; + +const String contactsAllGroupsValue = '__all__'; + +enum ChannelSortOption { manual, name, latestMessages, unread } + +class UiViewStateService extends ChangeNotifier { + static const _keyContactsSelectedGroupName = 'ui_contacts_selected_group'; + static const _keyContactsSortOption = 'ui_contacts_sort_option'; + static const _keyContactsShowUnreadOnly = 'ui_contacts_show_unread_only'; + static const _keyContactsTypeFilter = 'ui_contacts_type_filter'; + static const _keyChannelsSortOption = 'ui_channels_sort_option'; + static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index'; + + String _contactsSelectedGroupName = contactsAllGroupsValue; + String _contactsSearchText = ''; + bool _contactsSearchExpanded = false; + ContactSortOption _contactsSortOption = ContactSortOption.lastSeen; + bool _contactsShowUnreadOnly = false; + ContactTypeFilter _contactsTypeFilter = ContactTypeFilter.all; + + String _channelsSearchText = ''; + ChannelSortOption _channelsSortOption = ChannelSortOption.manual; + + String get contactsSelectedGroupName => _contactsSelectedGroupName; + String get contactsSearchText => _contactsSearchText; + bool get contactsSearchExpanded => _contactsSearchExpanded; + ContactSortOption get contactsSortOption => _contactsSortOption; + bool get contactsShowUnreadOnly => _contactsShowUnreadOnly; + ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter; + String get channelsSearchText => _channelsSearchText; + ChannelSortOption get channelsSortOption => _channelsSortOption; + + Future initialize() async { + final prefs = PrefsManager.instance; + + final selectedGroupName = prefs.getString(_keyContactsSelectedGroupName); + if (selectedGroupName != null && selectedGroupName.isNotEmpty) { + _contactsSelectedGroupName = selectedGroupName; + } + + final sortStr = prefs.getString(_keyContactsSortOption); + if (sortStr != null) { + _contactsSortOption = ContactSortOption.values.firstWhere( + (e) => e.name == sortStr, + orElse: () => ContactSortOption.lastSeen, + ); + } + + _contactsShowUnreadOnly = + prefs.getBool(_keyContactsShowUnreadOnly) ?? false; + + final typeStr = prefs.getString(_keyContactsTypeFilter); + if (typeStr != null) { + _contactsTypeFilter = ContactTypeFilter.values.firstWhere( + (e) => e.name == typeStr, + orElse: () => ContactTypeFilter.all, + ); + } + + final channelSortStr = prefs.getString(_keyChannelsSortOption); + if (channelSortStr != null) { + _channelsSortOption = ChannelSortOption.values.firstWhere( + (e) => e.name == channelSortStr, + orElse: () => ChannelSortOption.manual, + ); + return; + } + + // Backward compatibility for old persisted index format. + switch (prefs.getInt(_keyChannelsSortIndexLegacy) ?? 0) { + case 0: + _channelsSortOption = ChannelSortOption.manual; + break; + case 1: + _channelsSortOption = ChannelSortOption.name; + break; + case 2: + _channelsSortOption = ChannelSortOption.latestMessages; + break; + case 3: + _channelsSortOption = ChannelSortOption.unread; + break; + default: + _channelsSortOption = ChannelSortOption.manual; + } + } + + void setContactsSelectedGroupName(String value) { + if (_contactsSelectedGroupName == value) return; + _contactsSelectedGroupName = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyContactsSelectedGroupName, value), + ); + } + + void setContactsSearchText(String value) { + if (_contactsSearchText == value) return; + _contactsSearchText = value; + notifyListeners(); + } + + void setContactsSearchExpanded(bool value) { + if (_contactsSearchExpanded == value) return; + _contactsSearchExpanded = value; + notifyListeners(); + } + + void setContactsSortOption(ContactSortOption value) { + if (_contactsSortOption == value) return; + _contactsSortOption = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyContactsSortOption, value.name), + ); + } + + void setContactsShowUnreadOnly(bool value) { + if (_contactsShowUnreadOnly == value) return; + _contactsShowUnreadOnly = value; + notifyListeners(); + unawaited(PrefsManager.instance.setBool(_keyContactsShowUnreadOnly, value)); + } + + void setContactsTypeFilter(ContactTypeFilter value) { + if (_contactsTypeFilter == value) return; + _contactsTypeFilter = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyContactsTypeFilter, value.name), + ); + } + + void setChannelsSearchText(String value) { + if (_channelsSearchText == value) return; + _channelsSearchText = value; + notifyListeners(); + } + + void setChannelsSortOption(ChannelSortOption value) { + if (_channelsSortOption == value) return; + _channelsSortOption = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyChannelsSortOption, value.name), + ); + } +} diff --git a/lib/utils/contact_search.dart b/lib/utils/contact_search.dart index 1f05fdc..849172a 100644 --- a/lib/utils/contact_search.dart +++ b/lib/utils/contact_search.dart @@ -1,5 +1,9 @@ import '../models/contact.dart'; +enum ContactSortOption { lastSeen, recentMessages, name } + +enum ContactTypeFilter { all, favorites, users, repeaters, rooms } + bool matchesContactQuery(Contact contact, String query) { final normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.isEmpty) return true; diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index ee6fcd4..8b2874b 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -1,12 +1,9 @@ import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; +import '../utils/contact_search.dart'; -enum ContactSortOption { lastSeen, recentMessages, name } - -enum ContactTypeFilter { all, favorites, users, repeaters, rooms } - -class SortFilterMenuOption { - final int value; +class SortFilterMenuOption { + final T value; final String label; final bool? checked; @@ -17,16 +14,16 @@ class SortFilterMenuOption { }); } -class SortFilterMenuSection { +class SortFilterMenuSection { final String title; - final List options; + final List> options; const SortFilterMenuSection({required this.title, required this.options}); } -class SortFilterMenu extends StatelessWidget { - final List sections; - final ValueChanged onSelected; +class SortFilterMenu extends StatelessWidget { + final List> sections; + final ValueChanged onSelected; final String tooltip; final Widget icon; @@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return PopupMenuButton( + return PopupMenuButton( icon: icon, tooltip: tooltip, onSelected: onSelected, @@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget { final visibleSections = sections .where((section) => section.options.isNotEmpty) .toList(); - final entries = >[]; + final entries = >[]; for (int i = 0; i < visibleSections.length; i++) { final section = visibleSections[i]; entries.add( - PopupMenuItem( + PopupMenuItem( enabled: false, child: Text(section.title, style: labelStyle), ), @@ -65,14 +62,14 @@ class SortFilterMenu extends StatelessWidget { for (final option in section.options) { if (option.checked == null) { entries.add( - PopupMenuItem( + PopupMenuItem( value: option.value, child: Text(option.label), ), ); } else { entries.add( - CheckedPopupMenuItem( + CheckedPopupMenuItem( value: option.value, checked: option.checked ?? false, child: Text(option.label), @@ -99,7 +96,6 @@ const int _actionFilterUsers = 6; const int _actionFilterRepeaters = 7; const int _actionFilterRooms = 8; const int _actionToggleUnreadOnly = 9; -const int _actionNewGroup = 10; class ContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; @@ -108,7 +104,6 @@ class ContactsFilterMenu extends StatelessWidget { final ValueChanged onSortChanged; final ValueChanged onTypeFilterChanged; final ValueChanged onUnreadOnlyChanged; - final VoidCallback onNewGroup; const ContactsFilterMenu({ super.key, @@ -118,7 +113,6 @@ class ContactsFilterMenu extends StatelessWidget { required this.onSortChanged, required this.onTypeFilterChanged, required this.onUnreadOnlyChanged, - required this.onNewGroup, }); @override @@ -180,10 +174,6 @@ class ContactsFilterMenu extends StatelessWidget { label: l10n.listFilter_unreadOnly, checked: showUnreadOnly, ), - SortFilterMenuOption( - value: _actionNewGroup, - label: l10n.listFilter_newGroup, - ), ], ), ], @@ -216,9 +206,6 @@ class ContactsFilterMenu extends StatelessWidget { case _actionToggleUnreadOnly: onUnreadOnlyChanged(!showUnreadOnly); break; - case _actionNewGroup: - onNewGroup(); - break; } }, ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d2ea57e..4084d9b 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 path_provider_foundation import share_plus import shared_preferences_foundation import sqflite_darwin @@ -20,6 +21,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")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))