squashed commit of ez_group_dropdown

This commit is contained in:
ericz 2026-03-15 00:34:09 +01:00
parent e90742be25
commit 86e9b7fe01
39 changed files with 743 additions and 361 deletions

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Нова група",
"contacts_groupName": "Група",
"contacts_groupNameRequired": "Името на групата е задължително.",
"contacts_groupNameReserved": "Това име на група е запазено",
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
"@contacts_groupAlreadyExists": {
"placeholders": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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:

View file

@ -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\" вече съществува.';

View file

@ -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.';

View file

@ -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';

View file

@ -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';

View file

@ -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à.';

View file

@ -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à.';

View file

@ -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.';

View file

@ -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';

View file

@ -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';

View file

@ -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\" уже существует';

View file

@ -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';

View file

@ -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';

View file

@ -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.';

View file

@ -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» вже існує.';

View file

@ -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\" 的群聊已存在';

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -212,6 +212,7 @@
"contacts_newGroup": "Новая группа",
"contacts_groupName": "Имя группы",
"contacts_groupNameRequired": "Имя группы обязательно",
"contacts_groupNameReserved": "Это имя группы зарезервировано",
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
"contacts_filterContacts": "Фильтр контактов...",
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -286,6 +286,7 @@
"contacts_newGroup": "Нова група",
"contacts_groupName": "Назва групи",
"contacts_groupNameRequired": "Назва групи обов'язкова.",
"contacts_groupNameReserved": "Ця назва групи зарезервована",
"contacts_groupAlreadyExists": "Група «{name}» вже існує.",
"@contacts_groupAlreadyExists": {
"placeholders": {

View file

@ -300,6 +300,7 @@
"contacts_newGroup": "新建群聊",
"contacts_groupName": "群聊名称",
"contacts_groupNameRequired": "请输入群聊名称",
"contacts_groupNameReserved": "该群组名称已被保留",
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
"@contacts_groupAlreadyExists": {
"placeholders": {

View file

@ -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),
],

View file

@ -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<ChannelsScreen>
with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore();
String _searchQuery = '';
Timer? _searchDebounce;
ChannelSortOption _sortOption = ChannelSortOption.manual;
List<Community> _communities = [];
// Cache of PSK hex -> Community for quick lookup
@ -56,6 +53,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override
void initState() {
super.initState();
_searchController.text = context
.read<UiViewStateService>()
.channelsSearchText;
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels();
_loadCommunities();
@ -110,6 +110,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final viewState = context.watch<UiViewStateService>();
final channelMessageStore = ChannelMessageStore();
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
@ -205,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final filteredChannels = _filterAndSortChannels(
channels,
connector,
viewState,
);
return Column(
@ -219,17 +221,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
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<UiViewStateService>()
.setChannelsSearchText('');
},
),
_buildFilterButton(),
_buildFilterButton(viewState),
],
),
border: OutlineInputBorder(
@ -246,9 +250,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const Duration(milliseconds: 300),
() {
if (!mounted) return;
setState(() {
_searchQuery = value.toLowerCase();
});
context
.read<UiViewStateService>()
.setChannelsSearchText(value);
},
);
},
@ -283,8 +287,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
],
)
: (_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<ChannelsScreen>
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<ChannelSortOption>(
tooltip: context.l10n.listFilter_tooltip,
sections: [
SortFilterMenuSection(
SortFilterMenuSection<ChannelSortOption>(
title: context.l10n.channels_sortBy,
options: [
SortFilterMenuOption(
value: actionSortManual,
SortFilterMenuOption<ChannelSortOption>(
value: ChannelSortOption.manual,
label: context.l10n.channels_sortManual,
checked: _sortOption == ChannelSortOption.manual,
checked: viewState.channelsSortOption == ChannelSortOption.manual,
),
SortFilterMenuOption(
value: actionSortName,
SortFilterMenuOption<ChannelSortOption>(
value: ChannelSortOption.name,
label: context.l10n.channels_sortAZ,
checked: _sortOption == ChannelSortOption.name,
checked: viewState.channelsSortOption == ChannelSortOption.name,
),
SortFilterMenuOption(
value: actionSortLatest,
SortFilterMenuOption<ChannelSortOption>(
value: ChannelSortOption.latestMessages,
label: context.l10n.channels_sortLatestMessages,
checked: _sortOption == ChannelSortOption.latestMessages,
checked:
viewState.channelsSortOption ==
ChannelSortOption.latestMessages,
),
SortFilterMenuOption(
value: actionSortUnread,
SortFilterMenuOption<ChannelSortOption>(
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<ChannelsScreen>
List<Channel> _filterAndSortChannels(
List<Channel> 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<ChannelsScreen>
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
}
switch (_sortOption) {
switch (viewState.channelsSortOption) {
case ChannelSortOption.manual:
break;
case ChannelSortOption.latestMessages:

View file

@ -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<ContactsScreen>
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<ContactGroup> _groups = [];
String _loadedGroupScopeKeyHex = '';
Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {};
@ -62,30 +61,91 @@ class _ContactsScreenState extends State<ContactsScreen>
@override
void initState() {
super.initState();
_searchController.text = context
.read<UiViewStateService>()
.contactsSearchText;
_loadGroups();
_setupFrameListener();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final connector = context.read<MeshCoreConnector>();
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<void> _loadGroups() async {
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
if (selfPublicKeyHex.isEmpty) {
return;
}
_groupStore.setPublicKeyHex = selfPublicKeyHex;
final groups = await _groupStore.loadGroups();
if (!mounted) return;
setState(() {
_loadedGroupScopeKeyHex = selfPublicKeyHex;
_groups = groups;
_ensureValidSelectedGroup();
});
}
Future<void> _saveGroups() async {
final selfPublicKeyHex = context.read<MeshCoreConnector>().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<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
@ -375,31 +435,163 @@ class _ContactsScreenState extends State<ContactsScreen>
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<UiViewStateService>();
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<Contact> contacts,
List<ContactGroup> 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<String>(
position: PopupMenuPosition.under,
constraints: BoxConstraints.tightFor(width: menuWidth),
onSelected: (String value) {
viewState.setContactsSelectedGroupName(value);
},
itemBuilder: (menuContext) => [
PopupMenuItem<String>(
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<String>(
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<UiViewStateService>();
final contacts = connector.contacts;
final shouldShowStartupSpinner =
contacts.isEmpty &&
@ -421,92 +613,171 @@ class _ContactsScreenState extends State<ContactsScreen>
);
}
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
final filteredGroups = _showUnreadOnly
? const <ContactGroup>[]
: _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 = <String, ContactGroup>{};
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<UiViewStateService>()
.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<ContactsScreen>
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<ContactsScreen>
: 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<ContactsScreen>
);
}
List<ContactGroup> _filterAndSortGroups(
List<ContactGroup> groups,
List<Contact> contacts,
) {
final query = _searchQuery.trim().toLowerCase();
final contactsByKey = <String, Contact>{};
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<Contact> _filterAndSortContacts(
List<Contact> 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<ContactsScreen>
}).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<ContactsScreen>
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<ContactsScreen>
: contact.lastSeen;
}
Widget _buildGroupTile(
BuildContext context,
ContactGroup group,
List<Contact> 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<Contact> _resolveGroupContacts(
ContactGroup group,
List<Contact> contacts,
) {
final byKey = <String, Contact>{};
for (final contact in contacts) {
byKey[contact.publicKeyHex] = contact;
}
final resolved = <Contact>[];
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<Contact> 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<ContactsScreen>
);
}
void _showGroupOptions(
BuildContext context,
ContactGroup group,
List<Contact> 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<MeshCoreConnector>())) {
_showGroupsUnavailableMessage(context);
return;
}
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
@ -866,6 +1010,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(dialogContext);
setState(() {
_groups.removeWhere((g) => g.name == group.name);
_ensureValidSelectedGroup();
});
await _saveGroups();
},
@ -884,6 +1029,10 @@ class _ContactsScreenState extends State<ContactsScreen>
List<Contact> contacts, {
ContactGroup? group,
}) {
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
_showGroupsUnavailableMessage(context);
return;
}
final isEditing = group != null;
final nameController = TextEditingController(text: group?.name ?? '');
final selectedKeys = <String>{...group?.memberKeys ?? []};
@ -910,64 +1059,70 @@ class _ContactsScreenState extends State<ContactsScreen>
),
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<ContactsScreen>
);
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<ContactsScreen>
return;
}
setState(() {
final viewState = context.read<UiViewStateService>();
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<ContactsScreen>
memberKeys: selectedKeys.toList(),
),
);
viewState.setContactsSelectedGroupName(name);
}
_ensureValidSelectedGroup();
});
await _saveGroups();
if (dialogContext.mounted) {

View file

@ -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();

View file

@ -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<void> 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),
);
}
}

View file

@ -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;

View file

@ -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<T> {
final T value;
final String label;
final bool? checked;
@ -17,16 +14,16 @@ class SortFilterMenuOption {
});
}
class SortFilterMenuSection {
class SortFilterMenuSection<T> {
final String title;
final List<SortFilterMenuOption> options;
final List<SortFilterMenuOption<T>> options;
const SortFilterMenuSection({required this.title, required this.options});
}
class SortFilterMenu extends StatelessWidget {
final List<SortFilterMenuSection> sections;
final ValueChanged<int> onSelected;
class SortFilterMenu<T> extends StatelessWidget {
final List<SortFilterMenuSection<T>> sections;
final ValueChanged<T> onSelected;
final String tooltip;
final Widget icon;
@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<int>(
return PopupMenuButton<T>(
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 = <PopupMenuEntry<int>>[];
final entries = <PopupMenuEntry<T>>[];
for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i];
entries.add(
PopupMenuItem<int>(
PopupMenuItem<T>(
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<int>(
PopupMenuItem<T>(
value: option.value,
child: Text(option.label),
),
);
} else {
entries.add(
CheckedPopupMenuItem<int>(
CheckedPopupMenuItem<T>(
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<ContactSortOption> onSortChanged;
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
final ValueChanged<bool> 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;
}
},
);

View file

@ -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"))