Group contacts by last seen

This commit is contained in:
Janez T
2026-03-10 08:54:55 +01:00
parent 07b675dec6
commit 025bd737e7
11 changed files with 902 additions and 110 deletions

View File

@@ -1,4 +1,7 @@
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -19,6 +22,7 @@ import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart'; import 'services/voice_player_service.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/locale_preferences.dart'; import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart'; import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart'; import 'services/wizard_preferences.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available // Shows notification if update is available
_checkForUpdates(); _checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() { setState(() {
_wizardCompleted = wizardCompleted; _wizardCompleted = wizardCompleted;
_isInitialized = true; _isInitialized = true;

View File

@@ -7,6 +7,7 @@ import '../models/contact.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../utils/contact_grouping.dart';
import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart'; import '../widgets/contacts/add_channel_dialog.dart';
@@ -27,6 +28,7 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> { class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition; Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{}; final Set<String> _resolvingAdvertKeys = <String>{};
ContactSortMode _sortMode = ContactSortMode.lastSeen;
@override @override
void initState() { void initState() {
@@ -125,20 +127,22 @@ class _ContactsTabState extends State<ContactsTab> {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
List<Contact> _sortContactsByDistance(List<Contact> contacts) { List<Contact> _sortContacts(List<Contact> contacts) {
final sorted = List<Contact>.from(contacts); final sorted = List<Contact>.from(contacts);
sorted.sort((a, b) { sorted.sort((a, b) {
final distanceA = _distanceFromCurrentPosition(a); if (_sortMode == ContactSortMode.distance) {
final distanceB = _distanceFromCurrentPosition(b); final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (distanceA != null && distanceB != null) { if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB); final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare; if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) { } else if (distanceA != null) {
return -1; return -1;
} else if (distanceB != null) { } else if (distanceB != null) {
return 1; return 1;
}
} }
return b.lastSeenTime.compareTo(a.lastSeenTime); return b.lastSeenTime.compareTo(a.lastSeenTime);
@@ -208,12 +212,10 @@ class _ContactsTabState extends State<ContactsTab> {
return Scaffold( return Scaffold(
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final chatContacts = _sortContactsByDistance( final chatContacts = _sortContacts(contactsProvider.chatContacts);
contactsProvider.chatContacts, final repeaters = _sortContacts(contactsProvider.repeaters);
); final rooms = _sortContacts(contactsProvider.rooms);
final repeaters = _sortContactsByDistance(contactsProvider.repeaters); final channels = _sortContacts(contactsProvider.channels);
final rooms = _sortContactsByDistance(contactsProvider.rooms);
final channels = _sortContactsByDistance(contactsProvider.channels);
final pendingAdverts = contactsProvider.pendingAdverts; final pendingAdverts = contactsProvider.pendingAdverts;
// Check if there are any displayable contacts // Check if there are any displayable contacts
@@ -255,6 +257,17 @@ class _ContactsTabState extends State<ContactsTab> {
child: ListView( child: ListView(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
children: [ children: [
_SortModeSwitcher(
sortMode: _sortMode,
lastSeenLabel: l10n.lastSeen,
distanceLabel: l10n.distance,
onChanged: (sortMode) {
setState(() {
_sortMode = sortMode;
});
},
),
// Pending adverts (public key only; quick resolve) // Pending adverts (public key only; quick resolve)
if (pendingAdverts.isNotEmpty) ...[ if (pendingAdverts.isNotEmpty) ...[
_SectionHeader( _SectionHeader(
@@ -283,16 +296,7 @@ class _ContactsTabState extends State<ContactsTab> {
count: chatContacts.length, count: chatContacts.length,
icon: Icons.people, icon: Icons.people,
), ),
...chatContacts.map( ..._buildContactSectionItems(chatContacts),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -303,16 +307,7 @@ class _ContactsTabState extends State<ContactsTab> {
count: repeaters.length, count: repeaters.length,
icon: Icons.router, icon: Icons.router,
), ),
...repeaters.map( ..._buildContactSectionItems(repeaters),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -323,16 +318,7 @@ class _ContactsTabState extends State<ContactsTab> {
count: rooms.length, count: rooms.length,
icon: Icons.tag, icon: Icons.tag,
), ),
...rooms.map( ..._buildContactSectionItems(rooms),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -343,16 +329,7 @@ class _ContactsTabState extends State<ContactsTab> {
icon: Icons.broadcast_on_personal, icon: Icons.broadcast_on_personal,
), ),
if (channels.isNotEmpty) ...[ if (channels.isNotEmpty) ...[
...channels.map( ..._buildContactSectionItems(channels),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
], ],
// Add Channel Button (visible in both simple and advanced mode, only show when connected) // Add Channel Button (visible in both simple and advanced mode, only show when connected)
@@ -381,8 +358,37 @@ class _ContactsTabState extends State<ContactsTab> {
), ),
); );
} }
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
final items = ContactGrouping.buildItemsFromSorted(contacts);
return items.map((item) {
if (item.isGroup) {
return _InferredContactGroupCard(
label: item.group!.label,
contacts: item.group!.contacts,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}
return ContactTile(
contact: item.contact!,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}).toList();
}
} }
enum ContactSortMode { lastSeen, distance }
class _PendingAdvertTile extends StatelessWidget { class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert; final PendingAdvert advert;
final String subtitle; final String subtitle;
@@ -465,3 +471,135 @@ class _SectionHeader extends StatelessWidget {
); );
} }
} }
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance;
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
const _InferredContactGroupCard({
required this.label,
required this.contacts,
required this.currentPosition,
required this.calculateDistance,
required this.formatDistance,
required this.onNavigateToMap,
required this.onNavigateToMessages,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.folder_copy_outlined,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
label,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
contacts.length.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
const SizedBox(height: 10),
...contacts.map(
(contact) => ContactTile(
contact: contact,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
onNavigateToMap: onNavigateToMap,
onNavigateToMessages: onNavigateToMessages,
),
),
],
),
),
);
}
}
class _SortModeSwitcher extends StatelessWidget {
final ContactSortMode sortMode;
final String lastSeenLabel;
final String distanceLabel;
final ValueChanged<ContactSortMode> onChanged;
const _SortModeSwitcher({
required this.sortMode,
required this.lastSeenLabel,
required this.distanceLabel,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<ContactSortMode>(
segments: [
ButtonSegment<ContactSortMode>(
value: ContactSortMode.lastSeen,
label: Text(lastSeenLabel),
icon: const Icon(Icons.schedule),
),
ButtonSegment<ContactSortMode>(
value: ContactSortMode.distance,
label: Text(distanceLabel),
icon: const Icon(Icons.near_me),
),
],
selected: {sortMode},
onSelectionChanged: (selection) {
final selected = selection.isEmpty ? null : selection.first;
if (selected != null) {
onChanged(selected);
}
},
showSelectedIcon: false,
),
),
);
}
}

View File

@@ -24,6 +24,7 @@ import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart'; import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
enum _HomeTab { messages, contacts, sensors, map } enum _HomeTab { messages, contacts, sensors, map }
@@ -93,6 +94,7 @@ class _HomeScreenState extends State<HomeScreen>
_initTabController(); _initTabController();
_loadRxTxPreference(); _loadRxTxPreference();
_loadDeveloperModePreference(); _loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) { if (widget.shouldShowPermissionDialog) {
@@ -219,6 +221,9 @@ class _HomeScreenState extends State<HomeScreen>
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state; _lifecycleState = state;
_syncFastLocationUiState(); _syncFastLocationUiState();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
} }
Future<void> _loadRxTxPreference() async { Future<void> _loadRxTxPreference() async {
@@ -388,10 +393,14 @@ class _HomeScreenState extends State<HomeScreen>
required int count, required int count,
required bool isActive, required bool isActive,
required Color activeColor, required Color activeColor,
bool compact = false,
}) { }) {
final color = isActive ? activeColor : Colors.grey; final color = isActive ? activeColor : Colors.grey;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 4 : 5,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.12), color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -400,15 +409,15 @@ class _HomeScreenState extends State<HomeScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 7, width: compact ? 6 : 7,
height: 7, height: compact ? 6 : 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color), decoration: BoxDecoration(shape: BoxShape.circle, color: color),
), ),
const SizedBox(width: 6), SizedBox(width: compact ? 5 : 6),
Text( Text(
'$label:$count', '$label:$count',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: color, color: color,
), ),
@@ -1036,17 +1045,19 @@ class _HomeScreenState extends State<HomeScreen>
txActive: provider.txActivity, txActive: provider.txActivity,
) )
: Container( : Container(
constraints: const BoxConstraints(minHeight: 48),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 8,
vertical: 8, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85), .withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(22),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
_buildActivityBadge( _buildActivityBadge(
@@ -1054,13 +1065,15 @@ class _HomeScreenState extends State<HomeScreen>
count: provider.rxPacketCount, count: provider.rxPacketCount,
isActive: provider.rxActivity, isActive: provider.rxActivity,
activeColor: Colors.green, activeColor: Colors.green,
compact: true,
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
_buildActivityBadge( _buildActivityBadge(
label: 'TX', label: 'TX',
count: provider.txPacketCount, count: provider.txPacketCount,
isActive: provider.txActivity, isActive: provider.txActivity,
activeColor: Colors.blue, activeColor: Colors.blue,
compact: true,
), ),
], ],
), ),

View File

@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
@@ -746,22 +747,31 @@ class _DecodedRouteSection extends StatelessWidget {
connectionProvider.deviceInfo.selfName ?? connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName; connectionProvider.deviceInfo.displayName;
return FutureBuilder<int>( return FutureBuilder<List<dynamic>>(
future: RouteHashPreferences.getHashSize(), future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
]),
builder: (context, snapshot) { builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode( final decodedRoute = LogRxRouteDecoder.decode(
log.rawData, log.rawData,
preferredHashSize: snapshot.data, preferredHashSize: snapshot.data?.first as int?,
); );
if (decodedRoute == null) { if (decodedRoute == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final cachedNodes = snapshot.hasData
? snapshot.data![1] as List<MeshMapNode>
: const <MeshMapNode>[];
final resolvedPath = decodedRoute.hopHashes final resolvedPath = decodedRoute.hopHashes
.map( .map(
(hashHex) => LogRxRouteDecoder.resolveHash( (hashHex) => _resolveHashWithFallback(
hashHex, hashHex,
contacts: contacts, contacts: contacts,
cachedNodes: cachedNodes,
ownPublicKey: ownPublicKey, ownPublicKey: ownPublicKey,
ownName: ownName, ownName: ownName,
), ),
@@ -777,6 +787,41 @@ class _DecodedRouteSection extends StatelessWidget {
}, },
); );
} }
ResolvedNodeHash _resolveHashWithFallback(
String hashHex, {
required List<Contact> contacts,
required List<MeshMapNode> cachedNodes,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
final localResolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
);
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
return localResolved;
}
final normalizedHashHex = hashHex.toLowerCase();
final matches = cachedNodes
.where((node) => node.publicKey.startsWith(normalizedHashHex))
.toList();
if (matches.isEmpty) {
return localResolved;
}
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.name,
isOwnNode: false,
isUniqueMatch: matches.length == 1,
matchCount: matches.length,
);
}
} }
class _RouteSection extends StatelessWidget { class _RouteSection extends StatelessWidget {

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -15,6 +16,7 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
@@ -72,6 +74,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
double _fastLocationMovementThresholdMeters = 10.0; double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10; int _fastLocationActiveCadenceSeconds = 10;
bool _isDeveloperModeEnabled = false; bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0; int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService(); final LocationTrackingService _locationService = LocationTrackingService();
@@ -89,6 +93,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
_loadDeveloperMode(); _loadDeveloperMode();
_loadOnlineTraceCacheStatus();
} }
@override @override
@@ -126,6 +131,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _handleVersionTap() async { Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) { if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false); await DeveloperModeService.setEnabled(false);
@@ -829,6 +842,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Future<void> _clearOnlineTraceCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear online trace database'),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_isClearingOnlineTraceCache = true;
});
await MeshMapNodesService.clearCache();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = null;
_isClearingOnlineTraceCache = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Online trace database cleared'),
backgroundColor: Colors.orange,
),
);
}
String _onlineTraceCacheSubtitle() {
final cachedAt = _onlineTraceCacheUpdatedAt;
if (cachedAt == null) {
return 'No cached online database. Refresh runs in background when internet is available.';
}
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int part) => part.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _showRouteHashSizeDialog() async { Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>( final selected = await showDialog<int>(
context: context, context: context,
@@ -1047,6 +1120,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Tracing'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Online trace database'),
subtitle: Text(_onlineTraceCacheSubtitle()),
),
ListTile(
leading: Icon(
Icons.delete_sweep,
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
title: Text(
'Clear online trace database',
style: TextStyle(
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
),
subtitle: const Text(
'Remove the 24-hour cached fallback used when local route matches are incomplete',
),
enabled: !_isClearingOnlineTraceCache,
trailing: _isClearingOnlineTraceCache
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: _isClearingOnlineTraceCache
? null
: _clearOnlineTraceCache,
),
]),
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class MeshMapNode { class MeshMapNode {
final int type; final int type;
@@ -33,25 +35,35 @@ class MeshMapNode {
class MeshMapNodesService { class MeshMapNodesService {
static const String _nodesEndpoint = static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes'; 'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2); static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = Duration(minutes: 10); static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30); static const Duration traceTimeout = Duration(seconds: 30);
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
static List<MeshMapNode>? _cachedNodes; static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt; static DateTime? _cachedAt;
static Future<void>? _ongoingSync;
static Future<List<MeshMapNode>> fetchNodes({ static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false, bool forceRefresh = false,
Duration cacheTtl = _cacheTtl, Duration cacheTtl = _cacheTtl,
bool allowNetwork = true,
http.Client? client,
}) async { }) async {
final now = DateTime.now(); final now = DateTime.now();
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (!forceRefresh && if (!forceRefresh &&
_cachedNodes != null && cached.isNotEmpty &&
_cachedAt != null && _cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) { now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!; return cached;
} }
final response = await http if (!allowNetwork) {
return cached;
}
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint)) .get(Uri.parse(_nodesEndpoint))
.timeout(traceTimeout); .timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
@@ -69,8 +81,131 @@ class MeshMapNodesService {
) )
.toList(); .toList();
_cachedNodes = nodes; await _storeCache(nodes, cachedAt: now);
_cachedAt = now;
return nodes; return nodes;
} }
static Future<List<MeshMapNode>> loadCachedNodes({
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now();
if (_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
}
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
final cachedJson = prefs.getString(_cacheKey);
if (cachedAtMs == null || cachedJson == null) {
_cachedNodes = null;
_cachedAt = null;
return const [];
}
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
if (now.difference(cachedAt) >= cacheTtl) {
_cachedNodes = null;
_cachedAt = cachedAt;
return const [];
}
final decoded = jsonDecode(cachedJson) as List<dynamic>;
final nodes = decoded
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.toList();
_cachedNodes = nodes;
_cachedAt = cachedAt;
return nodes;
}
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
return nodes.isNotEmpty;
}
static Future<void> syncInBackgroundIfStale({
Duration cacheTtl = _cacheTtl,
http.Client? client,
}) async {
final now = DateTime.now();
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
return;
}
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (cached.isNotEmpty && _cachedAt != null) {
return;
}
if (_ongoingSync != null) {
return _ongoingSync!;
}
_ongoingSync = () async {
try {
await fetchNodes(
forceRefresh: true,
cacheTtl: cacheTtl,
allowNetwork: true,
client: client,
);
} catch (_) {
// Background refresh is best-effort only.
} finally {
_ongoingSync = null;
}
}();
return _ongoingSync!;
}
static Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
await prefs.remove(_cacheTimestampKey);
_cachedNodes = null;
_cachedAt = null;
}
static Future<DateTime?> cachedAt() async {
if (_cachedAt != null) return _cachedAt;
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
if (cachedAtMs == null) return null;
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
return _cachedAt;
}
static Future<void> _storeCache(
List<MeshMapNode> nodes, {
required DateTime cachedAt,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_cacheKey,
jsonEncode(
nodes
.map(
(node) => {
'type': node.type,
'name': node.name,
'public_key': node.publicKey,
'latitude': node.latitude,
'longitude': node.longitude,
'updated_at': node.updatedAtMs,
},
)
.toList(),
),
);
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
_cachedNodes = nodes;
_cachedAt = cachedAt;
}
} }

View File

@@ -0,0 +1,116 @@
import '../models/contact.dart';
class InferredContactGroup {
final String key;
final String label;
final List<Contact> contacts;
const InferredContactGroup({
required this.key,
required this.label,
required this.contacts,
});
DateTime get latestSeen => contacts.first.lastSeenTime;
}
class ContactListItem {
final Contact? contact;
final InferredContactGroup? group;
const ContactListItem._({this.contact, this.group});
const ContactListItem.contact(Contact contact) : this._(contact: contact);
const ContactListItem.group(InferredContactGroup group)
: this._(group: group);
bool get isGroup => group != null;
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
}
class ContactGrouping {
static final RegExp _prefixedNamePattern = RegExp(
r'^([A-Za-z0-9]{2,})([-_/:])',
);
static List<Contact> sortByLastSeen(List<Contact> contacts) {
return List<Contact>.from(contacts)
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
}
static List<ContactListItem> buildItems(
List<Contact> contacts, {
int minGroupSize = 4,
}) {
final sortedContacts = sortByLastSeen(contacts);
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
}
static List<ContactListItem> buildItemsFromSorted(
List<Contact> sortedContacts, {
int minGroupSize = 4,
}) {
final groupedContacts = <String, List<Contact>>{};
final groupLabels = <String, String>{};
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
if (prefix == null) continue;
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
}
final eligibleGroups = <String, InferredContactGroup>{};
for (final entry in groupedContacts.entries) {
if (entry.value.length < minGroupSize) continue;
eligibleGroups[entry.key] = InferredContactGroup(
key: entry.key,
label: groupLabels[entry.key] ?? entry.key,
contacts: entry.value,
);
}
final emittedGroups = <String>{};
final items = <ContactListItem>[];
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
final group = prefix == null ? null : eligibleGroups[prefix.key];
if (group == null) {
items.add(ContactListItem.contact(contact));
continue;
}
if (emittedGroups.add(group.key)) {
items.add(ContactListItem.group(group));
}
}
return items;
}
static _GroupPrefix? _extractPrefix(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return null;
final match = _prefixedNamePattern.firstMatch(trimmed);
if (match == null) return null;
final rawPrefix = match.group(1);
final separator = match.group(2);
if (rawPrefix == null || separator == null) return null;
return _GroupPrefix(
key: rawPrefix.toUpperCase(),
label: '$rawPrefix$separator',
);
}
}
class _GroupPrefix {
final String key;
final String label;
const _GroupPrefix({required this.key, required this.label});
}

View File

@@ -49,6 +49,7 @@ class ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel;
final location = contact.displayLocation; final location = contact.displayLocation;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
@@ -115,33 +116,33 @@ class ContactTile extends StatelessWidget {
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final subtitleWidget = Column( final Widget? subtitleWidget = isChannel
crossAxisAlignment: CrossAxisAlignment.start, ? null
children: [ : Column(
if (location != null) ...[ crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 2), children: [
_buildLocationLine( if (location != null) ...[
context, const SizedBox(height: 2),
latitude: location.latitude, _buildLocationLine(
longitude: location.longitude, context,
distanceText: distanceText, latitude: location.latitude,
), longitude: location.longitude,
if (contact.type != ContactType.channel) ...[ distanceText: distanceText,
const SizedBox(height: 6), ),
Row(children: [_buildRoutePill(context, contact)]), const SizedBox(height: 6),
], Row(children: [_buildRoutePill(context, contact)]),
] else ] else
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
AppLocalizations.of(context)!.noGpsData, AppLocalizations.of(context)!.noGpsData,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelSmall?.copyWith(color: Colors.grey), ).textTheme.labelSmall?.copyWith(color: Colors.grey),
), ),
), ),
], ],
); );
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -238,7 +239,8 @@ class ContactTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(timeAgoText, style: timeAgoStyle), if (!isChannel)
Text(timeAgoText, style: timeAgoStyle),
if (isPingInProgress) ...[ if (isPingInProgress) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
SizedBox( SizedBox(
@@ -252,7 +254,7 @@ class ContactTile extends StatelessWidget {
], ],
], ],
), ),
subtitleWidget, ?subtitleWidget,
], ],
), ),
), ),
@@ -276,7 +278,8 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.channel; contact.type == ContactType.channel;
final canSetPath = final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room; contact.type == ContactType.chat || contact.type == ContactType.room;
final canAddToSensors = contact.type == ContactType.chat || final canAddToSensors =
contact.type == ContactType.chat ||
contact.type == ContactType.repeater; contact.type == ContactType.repeater;
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
@@ -384,7 +387,10 @@ class ContactTile extends StatelessWidget {
onNavigateToMap?.call(); onNavigateToMap?.call();
} }
Future<void> _addContactToSensors(BuildContext context, Contact contact) async { Future<void> _addContactToSensors(
BuildContext context,
Contact contact,
) async {
await context.read<SensorsProvider>().addSensor(contact); await context.read<SensorsProvider>().addSensor(contact);
if (!context.mounted) { if (!context.mounted) {
return; return;

View File

@@ -1,6 +1,7 @@
// ignore_for_file: use_null_aware_elements // ignore_for_file: use_null_aware_elements
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -60,7 +61,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return trace; return trace;
} }
final remoteNodes = await MeshMapNodesService.fetchNodes( unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl, cacheTtl: MeshMapNodesService.traceCacheTtl,
); );
trace = _buildTraceResult( trace = _buildTraceResult(

View File

@@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await MeshMapNodesService.clearCache();
});
test('fetchNodes persists nodes and cached lookup works offline', () async {
final client = MockClient((request) async {
expect(
request.url.toString(),
'https://api.meshcore.nz/api/v1/map/nodes',
);
return http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
);
});
final nodes = await MeshMapNodesService.fetchNodes(client: client);
final cached = await MeshMapNodesService.fetchNodes(allowNetwork: false);
expect(nodes, hasLength(1));
expect(cached, hasLength(1));
expect(cached.first.name, 'Alpha');
expect(await MeshMapNodesService.hasFreshCache(), isTrue);
});
test('loadCachedNodes ignores stale persisted cache', () async {
SharedPreferences.setMockInitialValues({
'mesh_map_nodes_cache_v1': jsonEncode([
{
'type': 1,
'name': 'Old node',
'public_key': 'bb22cc33',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
]),
'mesh_map_nodes_cache_timestamp_v1': DateTime.now()
.subtract(const Duration(hours: 25))
.millisecondsSinceEpoch,
});
final nodes = await MeshMapNodesService.loadCachedNodes();
expect(nodes, isEmpty);
expect(await MeshMapNodesService.hasFreshCache(), isFalse);
});
test('clearCache removes persisted online trace database', () async {
final client = MockClient(
(_) async => http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
),
);
await MeshMapNodesService.fetchNodes(client: client);
await MeshMapNodesService.clearCache();
expect(await MeshMapNodesService.loadCachedNodes(), isEmpty);
expect(await MeshMapNodesService.cachedAt(), isNull);
});
}

View File

@@ -0,0 +1,120 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
void main() {
Contact buildContact({
required int seed,
required String name,
required DateTime lastSeen,
}) {
return Contact(
publicKey: Uint8List.fromList(
List<int>.generate(32, (index) => (seed + index) % 255),
),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: lastSeen.millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: lastSeen.millisecondsSinceEpoch ~/ 1000,
);
}
group('ContactGrouping.buildItems', () {
test(
'groups prefixed contacts only when at least four share the prefix',
() {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'OTHER',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.first.group!.label, 'SI-');
expect(
items.first.group!.contacts.map((contact) => contact.displayName),
['SI-1', 'SI-2', 'SI-3', 'SI-4'],
);
expect(items.last.contact!.displayName, 'OTHER');
},
);
test('does not group when only three contacts share a prefix', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
]);
expect(items, hasLength(3));
expect(items.every((item) => !item.isGroup), isTrue);
});
test('orders groups and ungrouped contacts by latest last seen', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(
seed: 1,
name: 'Lone',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
buildContact(
seed: 3,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.last.contact!.displayName, 'Lone');
});
});
}