mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Group contacts by last seen
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
lib/utils/contact_grouping.dart
Normal file
116
lib/utils/contact_grouping.dart
Normal 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});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
97
test/services/mesh_map_nodes_service_test.dart
Normal file
97
test/services/mesh_map_nodes_service_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
120
test/utils/contact_grouping_test.dart
Normal file
120
test/utils/contact_grouping_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user