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:
@@ -7,6 +7,7 @@ import '../models/contact.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../utils/contact_grouping.dart';
|
||||
import '../widgets/contacts/contact_tile.dart';
|
||||
import '../widgets/contacts/add_channel_dialog.dart';
|
||||
|
||||
@@ -27,6 +28,7 @@ class ContactsTab extends StatefulWidget {
|
||||
class _ContactsTabState extends State<ContactsTab> {
|
||||
Position? _currentPosition;
|
||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||
ContactSortMode _sortMode = ContactSortMode.lastSeen;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -125,20 +127,22 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
List<Contact> _sortContactsByDistance(List<Contact> contacts) {
|
||||
List<Contact> _sortContacts(List<Contact> contacts) {
|
||||
final sorted = List<Contact>.from(contacts);
|
||||
|
||||
sorted.sort((a, b) {
|
||||
final distanceA = _distanceFromCurrentPosition(a);
|
||||
final distanceB = _distanceFromCurrentPosition(b);
|
||||
if (_sortMode == ContactSortMode.distance) {
|
||||
final distanceA = _distanceFromCurrentPosition(a);
|
||||
final distanceB = _distanceFromCurrentPosition(b);
|
||||
|
||||
if (distanceA != null && distanceB != null) {
|
||||
final distanceCompare = distanceA.compareTo(distanceB);
|
||||
if (distanceCompare != 0) return distanceCompare;
|
||||
} else if (distanceA != null) {
|
||||
return -1;
|
||||
} else if (distanceB != null) {
|
||||
return 1;
|
||||
if (distanceA != null && distanceB != null) {
|
||||
final distanceCompare = distanceA.compareTo(distanceB);
|
||||
if (distanceCompare != 0) return distanceCompare;
|
||||
} else if (distanceA != null) {
|
||||
return -1;
|
||||
} else if (distanceB != null) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||
@@ -208,12 +212,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
return Scaffold(
|
||||
body: Consumer<ContactsProvider>(
|
||||
builder: (context, contactsProvider, child) {
|
||||
final chatContacts = _sortContactsByDistance(
|
||||
contactsProvider.chatContacts,
|
||||
);
|
||||
final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
|
||||
final rooms = _sortContactsByDistance(contactsProvider.rooms);
|
||||
final channels = _sortContactsByDistance(contactsProvider.channels);
|
||||
final chatContacts = _sortContacts(contactsProvider.chatContacts);
|
||||
final repeaters = _sortContacts(contactsProvider.repeaters);
|
||||
final rooms = _sortContacts(contactsProvider.rooms);
|
||||
final channels = _sortContacts(contactsProvider.channels);
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
|
||||
// Check if there are any displayable contacts
|
||||
@@ -255,6 +257,17 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
_SortModeSwitcher(
|
||||
sortMode: _sortMode,
|
||||
lastSeenLabel: l10n.lastSeen,
|
||||
distanceLabel: l10n.distance,
|
||||
onChanged: (sortMode) {
|
||||
setState(() {
|
||||
_sortMode = sortMode;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// Pending adverts (public key only; quick resolve)
|
||||
if (pendingAdverts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
@@ -283,16 +296,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
count: chatContacts.length,
|
||||
icon: Icons.people,
|
||||
),
|
||||
...chatContacts.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
..._buildContactSectionItems(chatContacts),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
@@ -303,16 +307,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
count: repeaters.length,
|
||||
icon: Icons.router,
|
||||
),
|
||||
...repeaters.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
..._buildContactSectionItems(repeaters),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
@@ -323,16 +318,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
count: rooms.length,
|
||||
icon: Icons.tag,
|
||||
),
|
||||
...rooms.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
..._buildContactSectionItems(rooms),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
@@ -343,16 +329,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
icon: Icons.broadcast_on_personal,
|
||||
),
|
||||
if (channels.isNotEmpty) ...[
|
||||
...channels.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
..._buildContactSectionItems(channels),
|
||||
],
|
||||
|
||||
// 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 {
|
||||
final PendingAdvert advert;
|
||||
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 '../utils/battery_display_helper.dart';
|
||||
import '../services/developer_mode_service.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
|
||||
enum _HomeTab { messages, contacts, sensors, map }
|
||||
|
||||
@@ -93,6 +94,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_initTabController();
|
||||
_loadRxTxPreference();
|
||||
_loadDeveloperModePreference();
|
||||
MeshMapNodesService.syncInBackgroundIfStale();
|
||||
|
||||
// Show permission dialog after the first frame if needed
|
||||
if (widget.shouldShowPermissionDialog) {
|
||||
@@ -219,6 +221,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleState = state;
|
||||
_syncFastLocationUiState();
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
MeshMapNodesService.syncInBackgroundIfStale();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRxTxPreference() async {
|
||||
@@ -388,10 +393,14 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
required int count,
|
||||
required bool isActive,
|
||||
required Color activeColor,
|
||||
bool compact = false,
|
||||
}) {
|
||||
final color = isActive ? activeColor : Colors.grey;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 7 : 8,
|
||||
vertical: compact ? 4 : 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
@@ -400,15 +409,15 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
width: compact ? 6 : 7,
|
||||
height: compact ? 6 : 7,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(width: compact ? 5 : 6),
|
||||
Text(
|
||||
'$label:$count',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: compact ? 10 : 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
@@ -1036,17 +1045,19 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
txActive: provider.txActivity,
|
||||
)
|
||||
: Container(
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh
|
||||
.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_buildActivityBadge(
|
||||
@@ -1054,13 +1065,15 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
count: provider.rxPacketCount,
|
||||
isActive: provider.rxActivity,
|
||||
activeColor: Colors.green,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
_buildActivityBadge(
|
||||
label: 'TX',
|
||||
count: provider.txPacketCount,
|
||||
isActive: provider.txActivity,
|
||||
activeColor: Colors.blue,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../services/route_hash_preferences.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
|
||||
@@ -746,22 +747,31 @@ class _DecodedRouteSection extends StatelessWidget {
|
||||
connectionProvider.deviceInfo.selfName ??
|
||||
connectionProvider.deviceInfo.displayName;
|
||||
|
||||
return FutureBuilder<int>(
|
||||
future: RouteHashPreferences.getHashSize(),
|
||||
return FutureBuilder<List<dynamic>>(
|
||||
future: Future.wait<dynamic>([
|
||||
RouteHashPreferences.getHashSize(),
|
||||
MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
),
|
||||
]),
|
||||
builder: (context, snapshot) {
|
||||
final decodedRoute = LogRxRouteDecoder.decode(
|
||||
log.rawData,
|
||||
preferredHashSize: snapshot.data,
|
||||
preferredHashSize: snapshot.data?.first as int?,
|
||||
);
|
||||
if (decodedRoute == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final cachedNodes = snapshot.hasData
|
||||
? snapshot.data![1] as List<MeshMapNode>
|
||||
: const <MeshMapNode>[];
|
||||
final resolvedPath = decodedRoute.hopHashes
|
||||
.map(
|
||||
(hashHex) => LogRxRouteDecoder.resolveHash(
|
||||
(hashHex) => _resolveHashWithFallback(
|
||||
hashHex,
|
||||
contacts: contacts,
|
||||
cachedNodes: cachedNodes,
|
||||
ownPublicKey: ownPublicKey,
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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_avif/flutter_avif.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -15,6 +16,7 @@ import '../providers/app_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/locale_preferences.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../services/update_checker_service.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../services/voice_bitrate_preferences.dart';
|
||||
@@ -72,6 +74,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
double _fastLocationMovementThresholdMeters = 10.0;
|
||||
int _fastLocationActiveCadenceSeconds = 10;
|
||||
bool _isDeveloperModeEnabled = false;
|
||||
DateTime? _onlineTraceCacheUpdatedAt;
|
||||
bool _isClearingOnlineTraceCache = false;
|
||||
int _versionTapCount = 0;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
final LocationTrackingService _locationService = LocationTrackingService();
|
||||
@@ -89,6 +93,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_loadImagePreferences();
|
||||
_loadFastLocationSettings();
|
||||
_loadDeveloperMode();
|
||||
_loadOnlineTraceCacheStatus();
|
||||
}
|
||||
|
||||
@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 {
|
||||
if (_isDeveloperModeEnabled) {
|
||||
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 {
|
||||
final selected = await showDialog<int>(
|
||||
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'),
|
||||
Consumer2<AppProvider, ConnectionProvider>(
|
||||
builder: (context, appProvider, connectionProvider, child) =>
|
||||
|
||||
Reference in New Issue
Block a user