|
|
|
@ -12,8 +12,9 @@ import '../l10n/l10n.dart';
|
|
|
|
import '../connector/meshcore_protocol.dart';
|
|
|
|
import '../connector/meshcore_protocol.dart';
|
|
|
|
import '../models/contact.dart';
|
|
|
|
import '../models/contact.dart';
|
|
|
|
import '../models/contact_group.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 '../utils/contact_search.dart';
|
|
|
|
|
|
|
|
import '../storage/contact_group_store.dart';
|
|
|
|
import '../utils/dialog_utils.dart';
|
|
|
|
import '../utils/dialog_utils.dart';
|
|
|
|
import '../utils/disconnect_navigation_mixin.dart';
|
|
|
|
import '../utils/disconnect_navigation_mixin.dart';
|
|
|
|
import '../utils/emoji_utils.dart';
|
|
|
|
import '../utils/emoji_utils.dart';
|
|
|
|
@ -47,12 +48,10 @@ class ContactsScreen extends StatefulWidget {
|
|
|
|
class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
with DisconnectNavigationMixin {
|
|
|
|
with DisconnectNavigationMixin {
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
String _searchQuery = '';
|
|
|
|
|
|
|
|
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
|
|
|
|
|
|
|
bool _showUnreadOnly = false;
|
|
|
|
|
|
|
|
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
|
|
|
|
|
|
|
|
final ContactGroupStore _groupStore = ContactGroupStore();
|
|
|
|
final ContactGroupStore _groupStore = ContactGroupStore();
|
|
|
|
|
|
|
|
MeshCoreConnector? _scopeSyncConnector;
|
|
|
|
List<ContactGroup> _groups = [];
|
|
|
|
List<ContactGroup> _groups = [];
|
|
|
|
|
|
|
|
String _loadedGroupScopeKeyHex = '';
|
|
|
|
Timer? _searchDebounce;
|
|
|
|
Timer? _searchDebounce;
|
|
|
|
|
|
|
|
|
|
|
|
final Set<ContactOperationType> _pendingOperations = {};
|
|
|
|
final Set<ContactOperationType> _pendingOperations = {};
|
|
|
|
@ -62,30 +61,91 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
super.initState();
|
|
|
|
|
|
|
|
_searchController.text = context
|
|
|
|
|
|
|
|
.read<UiViewStateService>()
|
|
|
|
|
|
|
|
.contactsSearchText;
|
|
|
|
_loadGroups();
|
|
|
|
_loadGroups();
|
|
|
|
_setupFrameListener();
|
|
|
|
_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
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
void dispose() {
|
|
|
|
_searchDebounce?.cancel();
|
|
|
|
_searchDebounce?.cancel();
|
|
|
|
_searchController.dispose();
|
|
|
|
_searchController.dispose();
|
|
|
|
_frameSubscription?.cancel();
|
|
|
|
_frameSubscription?.cancel();
|
|
|
|
|
|
|
|
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
|
|
|
|
super.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
void _handleConnectorScopeChange() {
|
|
|
|
|
|
|
|
final connector = _scopeSyncConnector;
|
|
|
|
|
|
|
|
if (connector == null) return;
|
|
|
|
|
|
|
|
_syncGroupScopeIfNeeded(connector);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _loadGroups() async {
|
|
|
|
Future<void> _loadGroups() async {
|
|
|
|
|
|
|
|
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
|
|
|
|
|
|
|
if (selfPublicKeyHex.isEmpty) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
|
|
|
final groups = await _groupStore.loadGroups();
|
|
|
|
final groups = await _groupStore.loadGroups();
|
|
|
|
if (!mounted) return;
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_loadedGroupScopeKeyHex = selfPublicKeyHex;
|
|
|
|
_groups = groups;
|
|
|
|
_groups = groups;
|
|
|
|
|
|
|
|
_ensureValidSelectedGroup();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _saveGroups() async {
|
|
|
|
Future<void> _saveGroups() async {
|
|
|
|
|
|
|
|
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
|
|
|
|
|
|
|
|
if (selfPublicKeyHex.isEmpty) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
_groupStore.setPublicKeyHex = selfPublicKeyHex;
|
|
|
|
await _groupStore.saveGroups(_groups);
|
|
|
|
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() {
|
|
|
|
void _setupFrameListener() {
|
|
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
|
|
// Listen for incoming text messages from the repeater
|
|
|
|
// Listen for incoming text messages from the repeater
|
|
|
|
@ -375,31 +435,163 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
await showDisconnectDialog(context, connector);
|
|
|
|
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(
|
|
|
|
return ContactsFilterMenu(
|
|
|
|
sortOption: _sortOption,
|
|
|
|
sortOption: viewState.contactsSortOption,
|
|
|
|
typeFilter: _typeFilter,
|
|
|
|
typeFilter: viewState.contactsTypeFilter,
|
|
|
|
showUnreadOnly: _showUnreadOnly,
|
|
|
|
showUnreadOnly: viewState.contactsShowUnreadOnly,
|
|
|
|
onSortChanged: (value) {
|
|
|
|
onSortChanged: (value) {
|
|
|
|
setState(() {
|
|
|
|
viewState.setContactsSortOption(value);
|
|
|
|
_sortOption = value;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
},
|
|
|
|
onTypeFilterChanged: (value) {
|
|
|
|
onTypeFilterChanged: (value) {
|
|
|
|
setState(() {
|
|
|
|
viewState.setContactsTypeFilter(value);
|
|
|
|
_typeFilter = value;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
},
|
|
|
|
onUnreadOnlyChanged: (value) {
|
|
|
|
onUnreadOnlyChanged: (value) {
|
|
|
|
setState(() {
|
|
|
|
viewState.setContactsShowUnreadOnly(value);
|
|
|
|
_showUnreadOnly = 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) {
|
|
|
|
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
|
|
|
|
|
|
|
final viewState = context.watch<UiViewStateService>();
|
|
|
|
final contacts = connector.contacts;
|
|
|
|
final contacts = connector.contacts;
|
|
|
|
final shouldShowStartupSpinner =
|
|
|
|
final shouldShowStartupSpinner =
|
|
|
|
contacts.isEmpty &&
|
|
|
|
contacts.isEmpty &&
|
|
|
|
@ -421,92 +613,171 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
|
|
|
final filteredAndSorted = _filterAndSortContacts(
|
|
|
|
final filteredGroups = _showUnreadOnly
|
|
|
|
contacts,
|
|
|
|
? const <ContactGroup>[]
|
|
|
|
connector,
|
|
|
|
: _filterAndSortGroups(_groups, contacts);
|
|
|
|
viewState,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
String hintText = "";
|
|
|
|
String hintText = "";
|
|
|
|
|
|
|
|
|
|
|
|
switch (_typeFilter) {
|
|
|
|
switch (viewState.contactsTypeFilter) {
|
|
|
|
case ContactTypeFilter.all:
|
|
|
|
case ContactTypeFilter.all:
|
|
|
|
hintText = context.l10n.contacts_searchContacts(
|
|
|
|
hintText = context.l10n.contacts_searchContacts(
|
|
|
|
filteredAndSorted.length,
|
|
|
|
filteredAndSorted.length,
|
|
|
|
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
|
|
|
viewState.contactsShowUnreadOnly
|
|
|
|
|
|
|
|
? " ${context.l10n.contacts_unread}"
|
|
|
|
|
|
|
|
: "",
|
|
|
|
);
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
break;
|
|
|
|
case ContactTypeFilter.users:
|
|
|
|
case ContactTypeFilter.users:
|
|
|
|
hintText = context.l10n.contacts_searchUsers(
|
|
|
|
hintText = context.l10n.contacts_searchUsers(
|
|
|
|
filteredAndSorted.length,
|
|
|
|
filteredAndSorted.length,
|
|
|
|
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
|
|
|
viewState.contactsShowUnreadOnly
|
|
|
|
|
|
|
|
? " ${context.l10n.contacts_unread}"
|
|
|
|
|
|
|
|
: "",
|
|
|
|
);
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
break;
|
|
|
|
case ContactTypeFilter.repeaters:
|
|
|
|
case ContactTypeFilter.repeaters:
|
|
|
|
hintText = context.l10n.contacts_searchRepeaters(
|
|
|
|
hintText = context.l10n.contacts_searchRepeaters(
|
|
|
|
filteredAndSorted.length,
|
|
|
|
filteredAndSorted.length,
|
|
|
|
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
|
|
|
viewState.contactsShowUnreadOnly
|
|
|
|
|
|
|
|
? " ${context.l10n.contacts_unread}"
|
|
|
|
|
|
|
|
: "",
|
|
|
|
);
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
break;
|
|
|
|
case ContactTypeFilter.rooms:
|
|
|
|
case ContactTypeFilter.rooms:
|
|
|
|
hintText = context.l10n.contacts_searchRoomServers(
|
|
|
|
hintText = context.l10n.contacts_searchRoomServers(
|
|
|
|
filteredAndSorted.length,
|
|
|
|
filteredAndSorted.length,
|
|
|
|
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
|
|
|
viewState.contactsShowUnreadOnly
|
|
|
|
|
|
|
|
? " ${context.l10n.contacts_unread}"
|
|
|
|
|
|
|
|
: "",
|
|
|
|
);
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
break;
|
|
|
|
case ContactTypeFilter.favorites:
|
|
|
|
case ContactTypeFilter.favorites:
|
|
|
|
hintText = context.l10n.contacts_searchFavorites(
|
|
|
|
hintText = context.l10n.contacts_searchFavorites(
|
|
|
|
filteredAndSorted.length,
|
|
|
|
filteredAndSorted.length,
|
|
|
|
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
|
|
|
viewState.contactsShowUnreadOnly
|
|
|
|
|
|
|
|
? " ${context.l10n.contacts_unread}"
|
|
|
|
|
|
|
|
: "",
|
|
|
|
);
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
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(
|
|
|
|
return Column(
|
|
|
|
children: [
|
|
|
|
children: [
|
|
|
|
Padding(
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
child: TextField(
|
|
|
|
child: Row(
|
|
|
|
controller: _searchController,
|
|
|
|
children: [
|
|
|
|
decoration: InputDecoration(
|
|
|
|
Expanded(
|
|
|
|
hintText: hintText,
|
|
|
|
child: _buildGroupButton(
|
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
|
|
context,
|
|
|
|
suffixIcon: Row(
|
|
|
|
connector,
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
viewState,
|
|
|
|
children: [
|
|
|
|
contacts,
|
|
|
|
if (_searchQuery.isNotEmpty)
|
|
|
|
sortedGroups,
|
|
|
|
IconButton(
|
|
|
|
),
|
|
|
|
icon: const Icon(Icons.clear),
|
|
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
|
|
_searchController.clear();
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_searchQuery = '';
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
_buildFilterButton(context, connector),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
border: OutlineInputBorder(
|
|
|
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
|
|
|
),
|
|
|
|
),
|
|
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
horizontal: 16,
|
|
|
|
AnimatedContainer(
|
|
|
|
vertical: 12,
|
|
|
|
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,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
onChanged: (value) {
|
|
|
|
|
|
|
|
_searchDebounce?.cancel();
|
|
|
|
|
|
|
|
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
|
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
_searchQuery = value.toLowerCase();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Expanded(
|
|
|
|
Expanded(
|
|
|
|
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
|
|
|
child: filteredAndSorted.isEmpty
|
|
|
|
? Center(
|
|
|
|
? Center(
|
|
|
|
child: Column(
|
|
|
|
child: Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
@ -514,7 +785,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
|
|
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
Text(
|
|
|
|
Text(
|
|
|
|
_showUnreadOnly
|
|
|
|
viewState.contactsShowUnreadOnly
|
|
|
|
? context.l10n.contacts_noUnreadContacts
|
|
|
|
? context.l10n.contacts_noUnreadContacts
|
|
|
|
: context.l10n.contacts_noContactsFound,
|
|
|
|
: context.l10n.contacts_noContactsFound,
|
|
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
|
|
@ -525,14 +796,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
: RefreshIndicator(
|
|
|
|
: RefreshIndicator(
|
|
|
|
onRefresh: () => connector.getContacts(),
|
|
|
|
onRefresh: () => connector.getContacts(),
|
|
|
|
child: ListView.builder(
|
|
|
|
child: ListView.builder(
|
|
|
|
itemCount: filteredGroups.length + filteredAndSorted.length,
|
|
|
|
itemCount: filteredAndSorted.length,
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
if (index < filteredGroups.length) {
|
|
|
|
final contact = filteredAndSorted[index];
|
|
|
|
final group = filteredGroups[index];
|
|
|
|
|
|
|
|
return _buildGroupTile(context, group, contacts);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
final contact =
|
|
|
|
|
|
|
|
filteredAndSorted[index - filteredGroups.length];
|
|
|
|
|
|
|
|
final unreadCount = connector.getUnreadCountForContact(
|
|
|
|
final unreadCount = connector.getUnreadCountForContact(
|
|
|
|
contact,
|
|
|
|
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> _filterAndSortContacts(
|
|
|
|
List<Contact> contacts,
|
|
|
|
List<Contact> contacts,
|
|
|
|
MeshCoreConnector connector,
|
|
|
|
MeshCoreConnector connector,
|
|
|
|
|
|
|
|
UiViewStateService viewState,
|
|
|
|
) {
|
|
|
|
) {
|
|
|
|
var filtered = contacts.where((contact) {
|
|
|
|
var filtered = contacts.where((contact) {
|
|
|
|
if (_searchQuery.isEmpty) return true;
|
|
|
|
if (viewState.contactsSearchText.isEmpty) return true;
|
|
|
|
return matchesContactQuery(contact, _searchQuery);
|
|
|
|
return matchesContactQuery(contact, viewState.contactsSearchText);
|
|
|
|
}).toList();
|
|
|
|
}).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
|
|
|
|
// Filter out own node from the list
|
|
|
|
if (connector.selfPublicKey != null) {
|
|
|
|
if (connector.selfPublicKey != null) {
|
|
|
|
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
|
|
|
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
|
|
|
@ -610,17 +847,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
}).toList();
|
|
|
|
}).toList();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_typeFilter != ContactTypeFilter.all) {
|
|
|
|
if (viewState.contactsTypeFilter != ContactTypeFilter.all) {
|
|
|
|
filtered = filtered.where(_matchesTypeFilter).toList();
|
|
|
|
filtered = filtered
|
|
|
|
|
|
|
|
.where(
|
|
|
|
|
|
|
|
(contact) =>
|
|
|
|
|
|
|
|
_matchesTypeFilter(contact, viewState.contactsTypeFilter),
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
.toList();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_showUnreadOnly) {
|
|
|
|
if (viewState.contactsShowUnreadOnly) {
|
|
|
|
filtered = filtered.where((contact) {
|
|
|
|
filtered = filtered.where((contact) {
|
|
|
|
return connector.getUnreadCountForContact(contact) > 0;
|
|
|
|
return connector.getUnreadCountForContact(contact) > 0;
|
|
|
|
}).toList();
|
|
|
|
}).toList();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (_sortOption) {
|
|
|
|
switch (viewState.contactsSortOption) {
|
|
|
|
case ContactSortOption.lastSeen:
|
|
|
|
case ContactSortOption.lastSeen:
|
|
|
|
filtered.sort(
|
|
|
|
filtered.sort(
|
|
|
|
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
|
|
|
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
|
|
|
@ -649,8 +891,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
return filtered;
|
|
|
|
return filtered;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool _matchesTypeFilter(Contact contact) {
|
|
|
|
bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) {
|
|
|
|
switch (_typeFilter) {
|
|
|
|
switch (typeFilter) {
|
|
|
|
case ContactTypeFilter.all:
|
|
|
|
case ContactTypeFilter.all:
|
|
|
|
return true;
|
|
|
|
return true;
|
|
|
|
case ContactTypeFilter.favorites:
|
|
|
|
case ContactTypeFilter.favorites:
|
|
|
|
@ -671,57 +913,6 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
: contact.lastSeen;
|
|
|
|
: 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) {
|
|
|
|
void _openChat(BuildContext context, Contact contact) {
|
|
|
|
// Check if this is a repeater
|
|
|
|
// Check if this is a repeater
|
|
|
|
if (contact.type == advTypeRepeater) {
|
|
|
|
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) {
|
|
|
|
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
|
|
|
|
|
|
|
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
|
|
|
|
|
|
|
_showGroupsUnavailableMessage(context);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
showDialog(
|
|
|
|
showDialog(
|
|
|
|
context: context,
|
|
|
|
context: context,
|
|
|
|
builder: (dialogContext) => AlertDialog(
|
|
|
|
builder: (dialogContext) => AlertDialog(
|
|
|
|
@ -866,6 +1010,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
Navigator.pop(dialogContext);
|
|
|
|
Navigator.pop(dialogContext);
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
_groups.removeWhere((g) => g.name == group.name);
|
|
|
|
_groups.removeWhere((g) => g.name == group.name);
|
|
|
|
|
|
|
|
_ensureValidSelectedGroup();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
await _saveGroups();
|
|
|
|
await _saveGroups();
|
|
|
|
},
|
|
|
|
},
|
|
|
|
@ -884,6 +1029,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
List<Contact> contacts, {
|
|
|
|
List<Contact> contacts, {
|
|
|
|
ContactGroup? group,
|
|
|
|
ContactGroup? group,
|
|
|
|
}) {
|
|
|
|
}) {
|
|
|
|
|
|
|
|
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
|
|
|
|
|
|
|
|
_showGroupsUnavailableMessage(context);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
final isEditing = group != null;
|
|
|
|
final isEditing = group != null;
|
|
|
|
final nameController = TextEditingController(text: group?.name ?? '');
|
|
|
|
final nameController = TextEditingController(text: group?.name ?? '');
|
|
|
|
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
|
|
|
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
|
|
|
@ -910,64 +1059,70 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
),
|
|
|
|
),
|
|
|
|
content: SizedBox(
|
|
|
|
content: SizedBox(
|
|
|
|
width: double.maxFinite,
|
|
|
|
width: double.maxFinite,
|
|
|
|
child: Column(
|
|
|
|
child: ConstrainedBox(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
constraints: BoxConstraints(
|
|
|
|
children: [
|
|
|
|
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
|
|
|
TextField(
|
|
|
|
),
|
|
|
|
controller: nameController,
|
|
|
|
child: Column(
|
|
|
|
decoration: InputDecoration(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
labelText: context.l10n.contacts_groupName,
|
|
|
|
children: [
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
TextField(
|
|
|
|
|
|
|
|
controller: nameController,
|
|
|
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
|
|
|
labelText: context.l10n.contacts_groupName,
|
|
|
|
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
TextField(
|
|
|
|
TextField(
|
|
|
|
decoration: InputDecoration(
|
|
|
|
decoration: InputDecoration(
|
|
|
|
hintText: context.l10n.contacts_filterContacts,
|
|
|
|
hintText: context.l10n.contacts_filterContacts,
|
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
isDense: true,
|
|
|
|
isDense: true,
|
|
|
|
),
|
|
|
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
|
|
|
setDialogState(() {
|
|
|
|
|
|
|
|
filterQuery = value.toLowerCase();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
onChanged: (value) {
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
setDialogState(() {
|
|
|
|
Expanded(
|
|
|
|
filterQuery = value.toLowerCase();
|
|
|
|
child: filteredContacts.isEmpty
|
|
|
|
});
|
|
|
|
? Center(
|
|
|
|
},
|
|
|
|
child: Text(
|
|
|
|
),
|
|
|
|
context.l10n.contacts_noContactsMatchFilter,
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
),
|
|
|
|
SizedBox(
|
|
|
|
)
|
|
|
|
height: 240,
|
|
|
|
: ListView.builder(
|
|
|
|
child: filteredContacts.isEmpty
|
|
|
|
itemCount: filteredContacts.length,
|
|
|
|
? Center(
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
child: Text(
|
|
|
|
final contact = filteredContacts[index];
|
|
|
|
context.l10n.contacts_noContactsMatchFilter,
|
|
|
|
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: [
|
|
|
|
actions: [
|
|
|
|
@ -986,6 +1141,15 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (name.toLowerCase() ==
|
|
|
|
|
|
|
|
contactsAllGroupsValue.toLowerCase()) {
|
|
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
|
|
SnackBar(
|
|
|
|
|
|
|
|
content: Text(context.l10n.contacts_groupNameReserved),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
final exists = _groups.any((g) {
|
|
|
|
final exists = _groups.any((g) {
|
|
|
|
if (isEditing && g.name == group.name) return false;
|
|
|
|
if (isEditing && g.name == group.name) return false;
|
|
|
|
return g.name.toLowerCase() == name.toLowerCase();
|
|
|
|
return g.name.toLowerCase() == name.toLowerCase();
|
|
|
|
@ -1001,15 +1165,21 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
final viewState = context.read<UiViewStateService>();
|
|
|
|
if (isEditing) {
|
|
|
|
if (isEditing) {
|
|
|
|
final index = _groups.indexWhere(
|
|
|
|
final index = _groups.indexWhere(
|
|
|
|
(g) => g.name == group.name,
|
|
|
|
(g) => g.name == group.name,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
if (index != -1) {
|
|
|
|
if (index != -1) {
|
|
|
|
|
|
|
|
final wasSelected =
|
|
|
|
|
|
|
|
viewState.contactsSelectedGroupName == group.name;
|
|
|
|
_groups[index] = ContactGroup(
|
|
|
|
_groups[index] = ContactGroup(
|
|
|
|
name: name,
|
|
|
|
name: name,
|
|
|
|
memberKeys: selectedKeys.toList(),
|
|
|
|
memberKeys: selectedKeys.toList(),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
if (wasSelected) {
|
|
|
|
|
|
|
|
viewState.setContactsSelectedGroupName(name);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
_groups.add(
|
|
|
|
_groups.add(
|
|
|
|
@ -1018,7 +1188,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|
|
|
memberKeys: selectedKeys.toList(),
|
|
|
|
memberKeys: selectedKeys.toList(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
viewState.setContactsSelectedGroupName(name);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_ensureValidSelectedGroup();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
await _saveGroups();
|
|
|
|
await _saveGroups();
|
|
|
|
if (dialogContext.mounted) {
|
|
|
|
if (dialogContext.mounted) {
|
|
|
|
|