mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-10 10:37:06 +10:00
Add contact settings and discovery features
- Implemented contact settings in localization files for Swedish, Ukrainian, and Chinese. - Added new DiscoveryContact model to handle discovered contacts. - Created DiscoveryScreen to display discovered contacts with filtering and sorting options. - Integrated contact discovery storage to persist discovered contacts. - Updated settings screen to include options for automatic contact addition. - Enhanced app bar and list filter widgets for better user experience. - Fixed variable naming inconsistencies in contact model.
This commit is contained in:
@@ -26,6 +26,7 @@ import '../widgets/room_login_dialog.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'discovery_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'repeater_hub_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
@@ -318,6 +319,21 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
),
|
||||
onTap: () => _disconnect(context, connector),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person_add_rounded),
|
||||
const SizedBox(width: 8),
|
||||
Text("Discovered Contacts"),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DiscoveryScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/discovery_contact.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
|
||||
enum DiscoverySortOption { lastSeen, name, type }
|
||||
|
||||
class DiscoveryScreen extends StatefulWidget {
|
||||
const DiscoveryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||
}
|
||||
|
||||
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String searchQuery = '';
|
||||
ContactSortOption sortOption = ContactSortOption.lastSeen;
|
||||
bool showUnreadOnly = false;
|
||||
ContactTypeFilter typeFilter = ContactTypeFilter.all;
|
||||
DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
final discoveredContacts = connector.discoveredContacts;
|
||||
final filteredAndSorted = _filterAndSortContacts(
|
||||
discoveredContacts,
|
||||
connector,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AppBarTitle(
|
||||
l10n.discoveredContacts_Title,
|
||||
indicators: false,
|
||||
subtitle: false,
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildFilters(filteredAndSorted, connector),
|
||||
Expanded(
|
||||
child: discoveredContacts.isEmpty
|
||||
? Center(child: Text(l10n.contacts_noContacts))
|
||||
: filteredAndSorted.isEmpty
|
||||
? Center(child: Text(l10n.discoveredContacts_noMatching))
|
||||
: ListView.builder(
|
||||
itemCount: filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredAndSorted[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
contact.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
contact.shortPubKeyHex,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
_formatLastSeen(context, contact.lastSeen),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters(filteredAndSorted, connector) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
String hintText = "";
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
hintText = context.l10n.contacts_searchContacts(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.users:
|
||||
hintText = context.l10n.contacts_searchUsers(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.repeaters:
|
||||
hintText = context.l10n.contacts_searchRepeaters(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.rooms:
|
||||
hintText = context.l10n.contacts_searchRoomServers(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.favorites:
|
||||
hintText = context.l10n.contacts_searchFavorites(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildFilterButton(context, connector),
|
||||
],
|
||||
),
|
||||
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();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
||||
return DiscoveryContactsFilterMenu(
|
||||
sortOption: sortOption,
|
||||
typeFilter: typeFilter,
|
||||
onSortChanged: (value) {
|
||||
setState(() {
|
||||
sortOption = value;
|
||||
});
|
||||
},
|
||||
onTypeFilterChanged: (value) {
|
||||
setState(() {
|
||||
typeFilter = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<DiscoveryContact> _filterAndSortContacts(
|
||||
List<DiscoveryContact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
var filtered = contacts.where((contact) {
|
||||
if (searchQuery.isEmpty) return true;
|
||||
return matchesContactQuery(
|
||||
Contact(
|
||||
publicKey: contact.publicKey,
|
||||
name: contact.name,
|
||||
type: contact.type,
|
||||
pathLength: contact.pathLength,
|
||||
path: contact.path,
|
||||
lastSeen: contact.lastSeen,
|
||||
),
|
||||
searchQuery,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Filter out own node from the list
|
||||
if (connector.selfPublicKey != null) {
|
||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||
filtered = filtered.where((contact) {
|
||||
return contact.publicKeyHex != selfPubKeyHex;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (typeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||
}
|
||||
|
||||
if (showUnreadOnly) {
|
||||
filtered = filtered.where((contact) {
|
||||
return connector.getUnreadCountForContact(
|
||||
Contact(
|
||||
publicKey: contact.publicKey,
|
||||
name: contact.name,
|
||||
type: contact.type,
|
||||
pathLength: contact.pathLength,
|
||||
path: contact.path,
|
||||
lastSeen: contact.lastSeen,
|
||||
),
|
||||
) >
|
||||
0;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
switch (sortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort(
|
||||
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
||||
);
|
||||
break;
|
||||
case ContactSortOption.name:
|
||||
filtered.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(DiscoveryContact contact) {
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
return true;
|
||||
case ContactTypeFilter.users:
|
||||
return contact.type == advTypeChat;
|
||||
case ContactTypeFilter.repeaters:
|
||||
return contact.type == advTypeRepeater;
|
||||
case ContactTypeFilter.rooms:
|
||||
return contact.type == advTypeRoom;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _resolveLastSeen(DiscoveryContact contact) {
|
||||
if (contact.type != advTypeChat) return contact.lastSeen;
|
||||
return contact.lastSeen.isAfter(contact.lastSeen)
|
||||
? contact.lastSeen
|
||||
: contact.lastSeen;
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Icons.chat;
|
||||
case advTypeRepeater:
|
||||
return Icons.cell_tower;
|
||||
case advTypeRoom:
|
||||
return Icons.group;
|
||||
case advTypeSensor:
|
||||
return Icons.sensors;
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTypeColor(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Colors.blue;
|
||||
case advTypeRepeater:
|
||||
return Colors.orange;
|
||||
case advTypeRoom:
|
||||
return Colors.purple;
|
||||
case advTypeSensor:
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
|
||||
if (diff.isNegative || diff.inMinutes < 5) {
|
||||
return context.l10n.contacts_lastSeenNow;
|
||||
}
|
||||
if (diff.inMinutes < 60) {
|
||||
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
|
||||
}
|
||||
if (diff.inHours < 24) {
|
||||
final hours = diff.inHours;
|
||||
return hours == 1
|
||||
? context.l10n.contacts_lastSeenHourAgo
|
||||
: context.l10n.contacts_lastSeenHoursAgo(hours);
|
||||
}
|
||||
final days = diff.inDays;
|
||||
return days == 1
|
||||
? context.l10n.contacts_lastSeenDayAgo
|
||||
: context.l10n.contacts_lastSeenDaysAgo(days);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/radio_settings.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import 'app_settings_screen.dart';
|
||||
import 'app_debug_log_screen.dart';
|
||||
import 'ble_debug_log_screen.dart';
|
||||
@@ -43,8 +44,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AdaptiveAppBarTitle(l10n.settings_title),
|
||||
centerTitle: true,
|
||||
title: AppBarTitle(
|
||||
l10n.settings_title,
|
||||
indicators: false,
|
||||
subtitle: false,
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
@@ -274,6 +278,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onTap: () => _editLocation(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.group_add_outlined),
|
||||
title: Text(l10n.settings_contactSettings),
|
||||
subtitle: Text(l10n.settings_contactSettingsSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _editAutoAddConfig(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility_off_outlined),
|
||||
title: Text(l10n.settings_privacyMode),
|
||||
@@ -849,6 +861,103 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editAutoAddConfig(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
bool autoAddChat = false;
|
||||
bool autoAddRepeater = false;
|
||||
bool autoAddRoomServer = false;
|
||||
bool autoAddSensor = false;
|
||||
bool overwriteOldest = false;
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
autoAddChat = connector.autoAddUsers ?? false;
|
||||
autoAddRepeater = connector.autoAddRepeaters ?? false;
|
||||
autoAddRoomServer = connector.autoAddRoomServers ?? false;
|
||||
autoAddSensor = connector.autoAddSensors ?? false;
|
||||
overwriteOldest = connector.autoAddOverwriteOldest ?? false;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(l10n.contactsSettings_autoAddTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddUsersTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddUsersSubtitle,
|
||||
value: autoAddChat,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddChat = value);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddRepeatersTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddRepeatersSubtitle,
|
||||
value: autoAddRepeater,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddRepeater = value);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddRoomServersTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddRoomServersSubtitle,
|
||||
value: autoAddRoomServer,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddRoomServer = value);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddSensorsTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddSensorsSubtitle,
|
||||
value: autoAddSensor,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddSensor = value);
|
||||
},
|
||||
),
|
||||
Divider(height: 4),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_overwriteOldestTitle,
|
||||
subtitle: l10n.contactsSettings_overwriteOldestSubtitle,
|
||||
value: overwriteOldest,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => overwriteOldest = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final frame = buildSetAutoAddConfigFrame(
|
||||
autoAddChat: autoAddChat,
|
||||
autoAddRepeater: autoAddRepeater,
|
||||
autoAddRoomServer: autoAddRoomServer,
|
||||
autoAddSensor: autoAddSensor,
|
||||
overwriteOldest: overwriteOldest,
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
await connector.sendFrame(buildGetAutoAddFlagsFrame());
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(l10n.common_save),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RadioSettingsDialog extends StatefulWidget {
|
||||
|
||||
Reference in New Issue
Block a user