mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-19 17:05:33 +10:00
d529ce9228
wraps MaterialApp in WithForegroundService to keep alive when swiped away persists last connected device and clears on manual disconnect to allow reconnect after kill added lifecycle tracking to iOS and keep android notification alive with heartbeat add notification navigation change screen tests to be less brittle address PR commnets
497 lines
16 KiB
Dart
497 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../models/contact.dart';
|
|
import '../services/notification_service.dart';
|
|
import '../utils/contact_search.dart';
|
|
import '../utils/platform_info.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 initState() {
|
|
super.initState();
|
|
_clearAdvertNotifications();
|
|
}
|
|
|
|
void _clearAdvertNotifications() {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final ids = connector.allContacts.map((c) => c.publicKeyHex).toList();
|
|
final ns = NotificationService();
|
|
ns.clearAllAdvertNotifications();
|
|
ns.clearAdvertNotifications(ids);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_searchDebounce?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
DateTime _resolveLastSeen(Contact contact) {
|
|
if (contact.type != advTypeChat) return contact.lastSeen;
|
|
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
|
? contact.lastMessageAt
|
|
: contact.lastSeen;
|
|
}
|
|
|
|
@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,
|
|
actions: [
|
|
PopupMenuButton(
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.delete, color: Colors.red),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.discoveredContacts_deleteContactAll),
|
|
],
|
|
),
|
|
onTap: () {
|
|
_deleteContacts(context, connector);
|
|
},
|
|
),
|
|
],
|
|
icon: const Icon(Icons.more_vert),
|
|
),
|
|
],
|
|
),
|
|
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];
|
|
final tile = 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,
|
|
),
|
|
// Clamp text scaling in trailing section to prevent overflow while
|
|
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
|
trailing: MediaQuery(
|
|
data: MediaQuery.of(context).copyWith(
|
|
textScaler: TextScaler.linear(
|
|
MediaQuery.textScalerOf(
|
|
context,
|
|
).scale(1.0).clamp(1.0, 1.3),
|
|
),
|
|
),
|
|
child: SizedBox(
|
|
width: 120,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
_formatLastSeen(
|
|
context,
|
|
_resolveLastSeen(contact),
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (contact.hasLocation)
|
|
Icon(
|
|
Icons.location_on,
|
|
size: 14,
|
|
color: Colors.grey[400],
|
|
),
|
|
if (contact.rawPacket != null)
|
|
const SizedBox(width: 2),
|
|
if (contact.rawPacket != null)
|
|
Icon(
|
|
Icons.cell_tower,
|
|
size: 14,
|
|
color: Colors.grey[400],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
onTap: () {
|
|
connector.importDiscoveredContact(contact);
|
|
},
|
|
onLongPress: () =>
|
|
_showContactContextMenu(contact, connector),
|
|
);
|
|
if (PlatformInfo.isDesktop) {
|
|
return GestureDetector(
|
|
onSecondaryTapUp: (_) =>
|
|
_showContactContextMenu(contact, connector),
|
|
child: tile,
|
|
);
|
|
}
|
|
return tile;
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showContactContextMenu(
|
|
Contact contact,
|
|
MeshCoreConnector connector,
|
|
) async {
|
|
final action = await showModalBottomSheet<String>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) {
|
|
final l10n = context.l10n;
|
|
return SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.add_reaction_sharp),
|
|
title: Text(l10n.discoveredContacts_addContact),
|
|
onTap: () => Navigator.of(sheetContext).pop('import_contact'),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.copy),
|
|
title: Text(l10n.discoveredContacts_copyContact),
|
|
onTap: () => Navigator.of(sheetContext).pop('copy_contact'),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.delete),
|
|
title: Text(l10n.discoveredContacts_deleteContact),
|
|
onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (!mounted || action == null) return;
|
|
|
|
switch (action) {
|
|
case 'import_contact':
|
|
connector.importDiscoveredContact(contact);
|
|
break;
|
|
case 'copy_contact':
|
|
if (contact.rawPacket == null) return;
|
|
final hexString = pubKeyToHex(contact.rawPacket!);
|
|
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
|
|
);
|
|
break;
|
|
case 'delete_contact':
|
|
connector.removeDiscoveredContact(contact);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _deleteContacts(BuildContext context, MeshCoreConnector connector) {
|
|
final l10n = context.l10n;
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.common_deleteAll),
|
|
content: Text(l10n.discoveredContacts_deleteContactAllContent),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
connector.removeAllDiscoveredContacts();
|
|
},
|
|
child: Text(l10n.common_deleteAll),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilters(
|
|
List<Contact> filteredAndSorted,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
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<Contact> _filterAndSortContacts(
|
|
List<Contact> contacts,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
var filtered = contacts.where((contact) {
|
|
if (searchQuery.isEmpty) return true;
|
|
return matchesDiscoveryContactQuery(contact, searchQuery);
|
|
}).toList();
|
|
|
|
filtered = filtered.where((contact) {
|
|
return !connector.knownContactKeys.contains(contact.publicKeyHex);
|
|
}).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();
|
|
}
|
|
|
|
switch (sortOption) {
|
|
case ContactSortOption.lastSeen:
|
|
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
|
break;
|
|
case ContactSortOption.name:
|
|
filtered.sort(
|
|
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
|
);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
bool _matchesTypeFilter(Contact 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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|