mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
16 Commits
v2026.0308
...
v2026.0310
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9deb563c05 | ||
|
|
15ac3e2798 | ||
|
|
97addf7ba6 | ||
|
|
025bd737e7 | ||
|
|
07b675dec6 | ||
|
|
d675ce18dd | ||
|
|
a8e31bb2e8 | ||
|
|
3cd96ba961 | ||
|
|
a98fc6314e | ||
|
|
ae1ea71f58 | ||
|
|
4dff79cd7b | ||
|
|
cbcb329c70 | ||
|
|
7abf96abf2 | ||
|
|
13bf74178c | ||
|
|
66a00c9569 | ||
|
|
b60fa008eb |
2
.github/workflows/build-artifacts.yml
vendored
2
.github/workflows/build-artifacts.yml
vendored
@@ -280,7 +280,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build web release
|
||||
run: flutter build web --release --base-href /meshcore_sar_app/
|
||||
run: flutter build web --release --base-href /meshcore-sar/
|
||||
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
|
||||
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
|
||||
|
||||
## Highlights
|
||||
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 106;
|
||||
CURRENT_PROJECT_VERSION = 107;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -511,7 +511,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 106;
|
||||
CURRENT_PROJECT_VERSION = 107;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -530,7 +530,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 106;
|
||||
CURRENT_PROJECT_VERSION = 107;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -547,7 +547,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 106;
|
||||
CURRENT_PROJECT_VERSION = 107;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -679,7 +679,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 106;
|
||||
CURRENT_PROJECT_VERSION = 107;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -702,7 +702,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 106;
|
||||
CURRENT_PROJECT_VERSION = 107;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -8,6 +9,7 @@ import Flutter
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>106</string>
|
||||
<string>107</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -19,6 +22,7 @@ import 'services/voice_codec_service.dart';
|
||||
import 'services/voice_player_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/locale_preferences.dart';
|
||||
import 'services/mesh_map_nodes_service.dart';
|
||||
import 'services/update_checker_service.dart';
|
||||
import 'services/wizard_preferences.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
// Shows notification if update is available
|
||||
_checkForUpdates();
|
||||
|
||||
// Refresh remote mesh node cache in background when the app starts.
|
||||
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||
|
||||
setState(() {
|
||||
_wizardCompleted = wizardCompleted;
|
||||
_isInitialized = true;
|
||||
@@ -158,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
/// Shows notification if update is available
|
||||
Future<void> _checkForUpdates() async {
|
||||
// Only check for updates on Android
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||
return;
|
||||
}
|
||||
@@ -324,7 +331,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
||||
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||
if (Platform.isAndroid) {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
return SafeArea(
|
||||
left: false,
|
||||
right: false,
|
||||
|
||||
@@ -240,6 +240,5 @@ extension ContactLocalization on Contact {
|
||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||
}
|
||||
|
||||
bool get routeSupportsLegacyRawTransport =>
|
||||
routeHasPath && routeSignedPathLen >= 0;
|
||||
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../utils/image_message_parser.dart';
|
||||
import '../utils/media_swarm_protocol.dart';
|
||||
import '../utils/message_airtime_estimator.dart';
|
||||
import '../utils/fast_gps_packet.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
|
||||
class _DirectMessageRouteSession {
|
||||
final PathSelection currentSelection;
|
||||
@@ -1973,14 +1974,13 @@ class AppProvider with ChangeNotifier {
|
||||
if (requester == null ||
|
||||
!requester.routeHasPath ||
|
||||
requester.routeHopCount > _maxDirectPayloadHops ||
|
||||
!requester.routeSupportsLegacyRawTransport ||
|
||||
requester.outPath.isEmpty) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
connectionProvider.sendRawVoicePacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeSignedPathLen,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: availability.encodeBinary(),
|
||||
),
|
||||
);
|
||||
@@ -2050,7 +2050,7 @@ class AppProvider with ChangeNotifier {
|
||||
for (final peer in peers) {
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: peer.outPath,
|
||||
contactPathLen: peer.routeSignedPathLen,
|
||||
contactPathLen: peer.routeEncodedPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
}
|
||||
@@ -2073,7 +2073,6 @@ class AppProvider with ChangeNotifier {
|
||||
if (responder == null ||
|
||||
!responder.routeHasPath ||
|
||||
responder.routeHopCount > _maxDirectPayloadHops ||
|
||||
!responder.routeSupportsLegacyRawTransport ||
|
||||
responder.outPath.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
@@ -2165,7 +2164,7 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: target.outPath,
|
||||
contactPathLen: target.routeSignedPathLen,
|
||||
contactPathLen: target.routeEncodedPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
return true;
|
||||
@@ -2406,9 +2405,6 @@ class AppProvider with ChangeNotifier {
|
||||
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
|
||||
return false;
|
||||
}
|
||||
if (!target.routeSupportsLegacyRawTransport) {
|
||||
return false;
|
||||
}
|
||||
if (target.outPath.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
@@ -2441,7 +2437,7 @@ class AppProvider with ChangeNotifier {
|
||||
);
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: target.outPath,
|
||||
contactPathLen: target.routeSignedPathLen,
|
||||
contactPathLen: target.routeEncodedPathLen,
|
||||
payload: RawRouteProbeRequest(
|
||||
nonce: nonce,
|
||||
requesterKey6: requesterKey6,
|
||||
@@ -2469,7 +2465,7 @@ class AppProvider with ChangeNotifier {
|
||||
if (target.publicKeyHex.isNotEmpty) {
|
||||
return 'pk:${target.publicKeyHex}';
|
||||
}
|
||||
return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
|
||||
return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
|
||||
}
|
||||
|
||||
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
|
||||
@@ -2487,9 +2483,6 @@ class AppProvider with ChangeNotifier {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!requester.routeSupportsLegacyRawTransport) {
|
||||
return;
|
||||
}
|
||||
if (requester.outPath.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -2499,7 +2492,7 @@ class AppProvider with ChangeNotifier {
|
||||
unawaited(
|
||||
connectionProvider.sendRawVoicePacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeSignedPathLen,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
|
||||
),
|
||||
);
|
||||
@@ -2573,12 +2566,10 @@ class AppProvider with ChangeNotifier {
|
||||
if (log.responseCode != 0x88) continue;
|
||||
if (log.rawData.length < 6) continue;
|
||||
|
||||
final raw = log.rawData;
|
||||
final payloadType = (raw[3] >> 2) & 0x0F;
|
||||
final pathLen = raw[4];
|
||||
if (payloadType != expectedPayloadType) continue;
|
||||
if (pathLen != message.pathLen) continue;
|
||||
if (raw.length < 5 + pathLen) continue;
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null) continue;
|
||||
if (decoded.payloadType != expectedPayloadType) continue;
|
||||
if (decoded.hopCount != message.pathLen) continue;
|
||||
|
||||
final deltaMs =
|
||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||
@@ -2594,11 +2585,9 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
||||
if (log == null) return null;
|
||||
final raw = log.rawData;
|
||||
if (raw.length < 6) return null;
|
||||
final pathLen = raw[4];
|
||||
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
|
||||
return raw.sublist(5, 5 + pathLen);
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null || decoded.pathBytes.isEmpty) return null;
|
||||
return decoded.pathBytes;
|
||||
}
|
||||
|
||||
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||
|
||||
@@ -486,6 +486,14 @@ class ConnectionProvider with ChangeNotifier {
|
||||
/// Start scanning for MeshCore devices
|
||||
Future<void> startScan() async {
|
||||
debugPrint('🔍 [Provider] startScan() called');
|
||||
if (_deviceInfo.connectionState == ConnectionState.connecting ||
|
||||
_deviceInfo.connectionState == ConnectionState.connected) {
|
||||
debugPrint(
|
||||
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_isScanning = true;
|
||||
_scannedDevices.clear();
|
||||
_error = null;
|
||||
@@ -559,6 +567,11 @@ class ConnectionProvider with ChangeNotifier {
|
||||
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
||||
);
|
||||
|
||||
if (_isScanning) {
|
||||
debugPrint('🔵 [Provider] Stopping active scan before connect()');
|
||||
await stopScan();
|
||||
}
|
||||
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
deviceId: device.remoteId.toString(),
|
||||
deviceName: device.platformName.isNotEmpty
|
||||
|
||||
@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!requester.routeSupportsLegacyRawTransport) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (requester.outPath.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
try {
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeSignedPathLen,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: encodeBinary(fragment),
|
||||
);
|
||||
servedCount++;
|
||||
|
||||
@@ -765,14 +765,14 @@ class MessagesProvider with ChangeNotifier {
|
||||
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
|
||||
final summary =
|
||||
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
|
||||
return isChannelMessage ? '$senderName\n$summary' : summary;
|
||||
return summary;
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||
if (imageEnvelope != null) {
|
||||
final summary =
|
||||
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
|
||||
return isChannelMessage ? '$senderName\n$summary' : summary;
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (!isChannelMessage && message.recipientPublicKey != null) {
|
||||
@@ -785,10 +785,6 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
if (isChannelMessage) {
|
||||
return '$senderName\n${message.text}';
|
||||
}
|
||||
|
||||
return message.text;
|
||||
}
|
||||
|
||||
@@ -889,6 +885,54 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark unread messages for a specific destination as read.
|
||||
void markDestinationAsRead({
|
||||
required String destinationType,
|
||||
Contact? contact,
|
||||
}) {
|
||||
if (destinationType == 'all') {
|
||||
markAllAsRead();
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasChanges = false;
|
||||
for (int i = 0; i < _messages.length; i++) {
|
||||
final message = _messages[i];
|
||||
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final matchesDestination = switch (destinationType) {
|
||||
'channel' => _isChannelMessageForContact(message, contact),
|
||||
'contact' || 'room' => contact != null && _isMessageForDestination(message, contact),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (!matchesDestination) {
|
||||
continue;
|
||||
}
|
||||
|
||||
_messages[i] = message.copyWith(isRead: true);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isChannelMessageForContact(Message message, Contact? contact) {
|
||||
if (!message.isChannelMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
|
||||
? contact.publicKey[1]
|
||||
: 0;
|
||||
return (message.channelIdx ?? 0) == selectedChannelIdx;
|
||||
}
|
||||
|
||||
/// Mark a specific message as read
|
||||
void markAsRead(String messageId) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
|
||||
@@ -7,6 +7,11 @@ import '../models/contact.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../services/message_destination_preferences.dart';
|
||||
import '../utils/contact_grouping.dart';
|
||||
import '../utils/avatar_label_helper.dart';
|
||||
import '../widgets/common/contact_avatar.dart';
|
||||
import '../widgets/contacts/contact_tile.dart';
|
||||
import '../widgets/contacts/add_channel_dialog.dart';
|
||||
|
||||
@@ -27,6 +32,11 @@ class ContactsTab extends StatefulWidget {
|
||||
class _ContactsTabState extends State<ContactsTab> {
|
||||
Position? _currentPosition;
|
||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||
final Map<ContactSection, ContactSortMode> _sortModes = {
|
||||
ContactSection.teamMembers: ContactSortMode.lastSeen,
|
||||
ContactSection.repeaters: ContactSortMode.lastSeen,
|
||||
ContactSection.rooms: ContactSortMode.lastSeen,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -125,20 +135,31 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
List<Contact> _sortContactsByDistance(List<Contact> contacts) {
|
||||
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
|
||||
final sorted = List<Contact>.from(contacts);
|
||||
if (section == ContactSection.channels) {
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen;
|
||||
|
||||
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 +229,23 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
return Scaffold(
|
||||
body: Consumer<ContactsProvider>(
|
||||
builder: (context, contactsProvider, child) {
|
||||
final chatContacts = _sortContactsByDistance(
|
||||
final messagesProvider = context.watch<MessagesProvider>();
|
||||
final chatContacts = _sortContacts(
|
||||
contactsProvider.chatContacts,
|
||||
ContactSection.teamMembers,
|
||||
);
|
||||
final repeaters = _sortContacts(
|
||||
contactsProvider.repeaters,
|
||||
ContactSection.repeaters,
|
||||
);
|
||||
final rooms = _sortContacts(
|
||||
contactsProvider.rooms,
|
||||
ContactSection.rooms,
|
||||
);
|
||||
final channels = _sortContacts(
|
||||
contactsProvider.channels,
|
||||
ContactSection.channels,
|
||||
);
|
||||
final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
|
||||
final rooms = _sortContactsByDistance(contactsProvider.rooms);
|
||||
final channels = _sortContactsByDistance(contactsProvider.channels);
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
|
||||
// Check if there are any displayable contacts
|
||||
@@ -282,17 +314,12 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
title: l10n.teamMembers,
|
||||
count: chatContacts.length,
|
||||
icon: Icons.people,
|
||||
),
|
||||
...chatContacts.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
trailing: _buildSortMenu(
|
||||
context,
|
||||
ContactSection.teamMembers,
|
||||
),
|
||||
),
|
||||
..._buildContactSectionItems(chatContacts),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
@@ -302,17 +329,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
title: l10n.repeaters,
|
||||
count: repeaters.length,
|
||||
icon: Icons.router,
|
||||
trailing: _buildSortMenu(context, ContactSection.repeaters),
|
||||
),
|
||||
...repeaters.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
..._buildContactSectionItems(repeaters),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
@@ -322,17 +341,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
title: l10n.rooms,
|
||||
count: rooms.length,
|
||||
icon: Icons.tag,
|
||||
trailing: _buildSortMenu(context, ContactSection.rooms),
|
||||
),
|
||||
...rooms.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
..._buildContactSectionItems(rooms),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
@@ -344,12 +355,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
),
|
||||
if (channels.isNotEmpty) ...[
|
||||
...channels.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
(channel) => _ChannelActivityCard(
|
||||
channel: channel,
|
||||
messagesProvider: messagesProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
onNavigateToMessages: widget.onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
@@ -381,8 +390,97 @@ 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();
|
||||
}
|
||||
|
||||
Widget _buildSortMenu(BuildContext context, ContactSection section) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return PopupMenuButton<ContactSortMode>(
|
||||
tooltip: 'Sort',
|
||||
initialValue: selectedMode,
|
||||
onSelected: (sortMode) {
|
||||
setState(() {
|
||||
_sortModes[section] = sortMode;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<ContactSortMode>(
|
||||
value: ContactSortMode.lastSeen,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 18,
|
||||
color: selectedMode == ContactSortMode.lastSeen
|
||||
? colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.lastSeen),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<ContactSortMode>(
|
||||
value: ContactSortMode.distance,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.near_me,
|
||||
size: 18,
|
||||
color: selectedMode == ContactSortMode.distance
|
||||
? colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.distance),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.more_horiz,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ContactSortMode { lastSeen, distance }
|
||||
|
||||
enum ContactSection { teamMembers, repeaters, rooms, channels }
|
||||
|
||||
class _PendingAdvertTile extends StatelessWidget {
|
||||
final PendingAdvert advert;
|
||||
final String subtitle;
|
||||
@@ -427,11 +525,13 @@ class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final IconData icon;
|
||||
final Widget? trailing;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.icon,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -460,6 +560,415 @@ class _SectionHeader extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const Spacer(), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
initiallyExpanded: false,
|
||||
leading: Icon(
|
||||
Icons.folder_copy_outlined,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
...contacts.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: currentPosition,
|
||||
calculateDistance: calculateDistance,
|
||||
formatDistance: formatDistance,
|
||||
onNavigateToMap: onNavigateToMap,
|
||||
onNavigateToMessages: onNavigateToMessages,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelActivityCard extends StatelessWidget {
|
||||
final Contact channel;
|
||||
final MessagesProvider messagesProvider;
|
||||
final ContactsProvider contactsProvider;
|
||||
final VoidCallback? onNavigateToMessages;
|
||||
|
||||
const _ChannelActivityCard({
|
||||
required this.channel,
|
||||
required this.messagesProvider,
|
||||
required this.contactsProvider,
|
||||
required this.onNavigateToMessages,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
||||
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
final participantNames = <String>[];
|
||||
for (final message in channelMessages) {
|
||||
final senderName = message.senderName?.trim();
|
||||
if (senderName == null || senderName.isEmpty) continue;
|
||||
if (!participantNames.contains(senderName)) {
|
||||
participantNames.add(senderName);
|
||||
}
|
||||
}
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.surfaceContainerLow,
|
||||
colorScheme.tertiaryContainer.withValues(alpha: 0.45),
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () async {
|
||||
await MessageDestinationPreferences.setDestination(
|
||||
MessageDestinationPreferences.destinationTypeChannel,
|
||||
recipientPublicKey: channel.publicKeyHex,
|
||||
);
|
||||
messagesProvider.navigateToDestination(
|
||||
MessageDestinationPreferences.destinationTypeChannel,
|
||||
recipientPublicKeyHex: channel.publicKeyHex,
|
||||
);
|
||||
onNavigateToMessages?.call();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ContactAvatar(contact: channel, radius: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
channel.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
height: 1.05,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (participantNames.isNotEmpty)
|
||||
_ExpandableParticipantStack(
|
||||
names: participantNames,
|
||||
contactForName: _findParticipantContact,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'No recent chatters',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_MetricChip(
|
||||
icon: Icons.forum_outlined,
|
||||
label: '${channelMessages.length}',
|
||||
helper: 'messages',
|
||||
),
|
||||
_MetricChip(
|
||||
icon: Icons.group_outlined,
|
||||
label: '${participantNames.length}',
|
||||
helper: 'active',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Contact? _findParticipantContact(String name) {
|
||||
for (final contact in contactsProvider.contacts) {
|
||||
if (!contact.isChannel && contact.advName == name) {
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpandableParticipantStack extends StatefulWidget {
|
||||
final List<String> names;
|
||||
final Contact? Function(String name) contactForName;
|
||||
|
||||
const _ExpandableParticipantStack({
|
||||
required this.names,
|
||||
required this.contactForName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ExpandableParticipantStack> createState() =>
|
||||
_ExpandableParticipantStackState();
|
||||
}
|
||||
|
||||
class _ExpandableParticipantStackState
|
||||
extends State<_ExpandableParticipantStack> {
|
||||
bool _expanded = false;
|
||||
static const int _collapsedVisibleCount = 4;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasOverflow = widget.names.length > _collapsedVisibleCount;
|
||||
final visibleNames = _expanded
|
||||
? widget.names
|
||||
: widget.names.take(_collapsedVisibleCount).toList();
|
||||
final overflowCount = _expanded
|
||||
? 0
|
||||
: widget.names.length - visibleNames.length;
|
||||
final spacing = _expanded ? 20.0 : 16.0;
|
||||
const avatarSize = 24.0;
|
||||
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
|
||||
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: hasOverflow
|
||||
? () {
|
||||
setState(() {
|
||||
_expanded = !_expanded;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
width: width,
|
||||
height: avatarSize,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (var i = 0; i < visibleNames.length; i++)
|
||||
Positioned(
|
||||
left: i * spacing,
|
||||
top: 0,
|
||||
child: _ParticipantAvatar(
|
||||
name: visibleNames[i],
|
||||
contact: widget.contactForName(visibleNames[i]),
|
||||
),
|
||||
),
|
||||
if (overflowCount > 0)
|
||||
Positioned(
|
||||
left: visibleNames.length * spacing,
|
||||
top: 0,
|
||||
child: _OverflowAvatar(count: overflowCount),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverflowAvatar extends StatelessWidget {
|
||||
final int count;
|
||||
|
||||
const _OverflowAvatar({required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colorScheme.surface, width: 2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'+$count',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParticipantAvatar extends StatelessWidget {
|
||||
final String name;
|
||||
final Contact? contact;
|
||||
static const double _size = 24;
|
||||
|
||||
const _ParticipantAvatar({required this.name, required this.contact});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (contact != null) {
|
||||
final surfaceColor = Theme.of(context).colorScheme.surface;
|
||||
return SizedBox(
|
||||
width: _size,
|
||||
height: _size,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: surfaceColor, width: 2),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ClipOval(child: ContactAvatar(contact: contact!, radius: 8)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: _size,
|
||||
height: _size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colorScheme.surface, width: 2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
AvatarLabelHelper.buildLabel(name),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String helper;
|
||||
|
||||
const _MetricChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.helper,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
helper,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
@@ -195,7 +197,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
switch (tab) {
|
||||
case _HomeTab.messages:
|
||||
context.read<MessagesProvider>().markAllAsRead();
|
||||
break;
|
||||
case _HomeTab.contacts:
|
||||
context.read<ContactsProvider>().markAllAsViewed();
|
||||
@@ -220,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 {
|
||||
@@ -389,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),
|
||||
@@ -401,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,
|
||||
),
|
||||
@@ -1037,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(
|
||||
@@ -1055,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -89,9 +89,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
// Load saved message destination
|
||||
_loadSavedDestination();
|
||||
_loadVoiceBitrate();
|
||||
// Mark all messages as read when tab is opened
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MessagesProvider>().markAllAsRead();
|
||||
_checkForNavigationRequest();
|
||||
});
|
||||
}
|
||||
@@ -309,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
_selectedRecipient = recipient;
|
||||
});
|
||||
|
||||
_markCurrentDestinationAsRead();
|
||||
|
||||
_enforceMessageByteLimit();
|
||||
|
||||
// Save to preferences
|
||||
@@ -465,6 +465,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
_markCurrentDestinationAsRead();
|
||||
|
||||
if (!mounted) return;
|
||||
} catch (e) {
|
||||
@@ -1641,6 +1642,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
void _markCurrentDestinationAsRead() {
|
||||
context.read<MessagesProvider>().markDestinationAsRead(
|
||||
destinationType: _destinationType,
|
||||
contact: _selectedRecipient,
|
||||
);
|
||||
}
|
||||
|
||||
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
||||
// Get all recent messages
|
||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||
|
||||
@@ -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:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
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);
|
||||
@@ -470,7 +483,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
/// Check for app updates and show notification or dialog
|
||||
Future<void> _checkForUpdates() async {
|
||||
// Only on Android
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -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) =>
|
||||
@@ -1213,8 +1321,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
]),
|
||||
|
||||
_buildSectionHeader('Network Sharing'),
|
||||
const ConnectionModeSelector(),
|
||||
if (!kIsWeb) ...[
|
||||
_buildSectionHeader('Network Sharing'),
|
||||
const ConnectionModeSelector(),
|
||||
],
|
||||
|
||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||
_buildSettingsCard([
|
||||
@@ -1320,7 +1430,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onTap: () => _showAboutDialog(),
|
||||
),
|
||||
]),
|
||||
if (Platform.isAndroid)
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: FilledButton.icon(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Service for accessing build information from native platform code
|
||||
/// Currently supports Android only - returns "unknown" for other platforms
|
||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
||||
}
|
||||
|
||||
// Only Android has the platform channel implementation
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||
_cachedCommitHash = 'unknown';
|
||||
return _cachedCommitHash!;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MeshMapNode {
|
||||
final int type;
|
||||
@@ -33,25 +35,35 @@ class MeshMapNode {
|
||||
class MeshMapNodesService {
|
||||
static const String _nodesEndpoint =
|
||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
||||
static const Duration _cacheTtl = Duration(hours: 24);
|
||||
static const Duration traceCacheTtl = _cacheTtl;
|
||||
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 DateTime? _cachedAt;
|
||||
static Future<void>? _ongoingSync;
|
||||
|
||||
static Future<List<MeshMapNode>> fetchNodes({
|
||||
bool forceRefresh = false,
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
bool allowNetwork = true,
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
if (!forceRefresh &&
|
||||
_cachedNodes != null &&
|
||||
cached.isNotEmpty &&
|
||||
_cachedAt != null &&
|
||||
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))
|
||||
.timeout(traceTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
@@ -69,8 +81,131 @@ class MeshMapNodesService {
|
||||
)
|
||||
.toList();
|
||||
|
||||
_cachedNodes = nodes;
|
||||
_cachedAt = now;
|
||||
await _storeCache(nodes, cachedAt: now);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
|
||||
File? _file;
|
||||
|
||||
Future<File> _resolveFile() async {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError('Packet capture file storage is not supported on web');
|
||||
}
|
||||
if (_file != null) return _file!;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final file = File('${dir.path}/$_fileName');
|
||||
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
|
||||
|
||||
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||
if (logs.isEmpty) return;
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
final sink = file.openWrite(mode: FileMode.append);
|
||||
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
|
||||
}
|
||||
|
||||
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||
if (kIsWeb) return const [];
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (!await file.exists()) return const [];
|
||||
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
|
||||
}
|
||||
|
||||
Future<int> count() async {
|
||||
if (kIsWeb) return 0;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (!await file.exists()) return 0;
|
||||
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (await file.exists()) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class AvatarLabelHelper {
|
||||
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
|
||||
|
||||
static String buildLabel(String name) {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
|
||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||
}
|
||||
|
||||
final parts = trimmed
|
||||
.split(RegExp(r'[\s_-]+'))
|
||||
final parts = _alnumChunks
|
||||
.allMatches(trimmed)
|
||||
.map((match) => match.group(0)!)
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
if (parts.length >= 2) {
|
||||
final first = _take(parts[0], 1);
|
||||
final second = _take(parts[1], 1);
|
||||
return '$first$second'.toUpperCase();
|
||||
}
|
||||
|
||||
return _take(trimmed, 2).toUpperCase();
|
||||
return _take(parts.first, 2).toUpperCase();
|
||||
}
|
||||
|
||||
static String _take(String value, int count) {
|
||||
|
||||
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});
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import '../models/contact.dart';
|
||||
|
||||
class DecodedLogRxRoute {
|
||||
final int payloadType;
|
||||
final int pathDescriptor;
|
||||
final List<int> pathBytes;
|
||||
final int hashSize;
|
||||
|
||||
const DecodedLogRxRoute({
|
||||
required this.payloadType,
|
||||
required this.pathDescriptor,
|
||||
required this.pathBytes,
|
||||
required this.hashSize,
|
||||
});
|
||||
@@ -63,21 +65,49 @@ class LogRxRouteDecoder {
|
||||
}
|
||||
|
||||
if (rawPacketData.length <= index) return null;
|
||||
final pathLen = rawPacketData[index++];
|
||||
if (rawPacketData.length < index + pathLen) return null;
|
||||
final pathBytes = rawPacketData.sublist(index, index + pathLen);
|
||||
final hashSize = inferHashSize(
|
||||
pathBytes,
|
||||
preferredHashSize: preferredHashSize,
|
||||
);
|
||||
final pathDescriptor = rawPacketData[index++];
|
||||
final pathMode = (pathDescriptor & 0xFF) >> 6;
|
||||
final pathByteLen = pathMode == 0
|
||||
? pathDescriptor
|
||||
: descriptorByteLength(pathDescriptor);
|
||||
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
|
||||
return null;
|
||||
}
|
||||
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
|
||||
final hashSize = pathMode == 0
|
||||
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
|
||||
: (descriptorHashSize(pathDescriptor) ??
|
||||
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
|
||||
|
||||
return DecodedLogRxRoute(
|
||||
payloadType: payloadType,
|
||||
pathDescriptor: pathDescriptor,
|
||||
pathBytes: pathBytes,
|
||||
hashSize: hashSize,
|
||||
);
|
||||
}
|
||||
|
||||
static int? descriptorHashSize(int pathDescriptor) {
|
||||
final normalized = pathDescriptor & 0xFF;
|
||||
final mode = normalized >> 6;
|
||||
if (mode == 3) return null;
|
||||
return mode + 1;
|
||||
}
|
||||
|
||||
static int? descriptorHopCount(int pathDescriptor) {
|
||||
final hashSize = descriptorHashSize(pathDescriptor);
|
||||
if (hashSize == null) return null;
|
||||
return (pathDescriptor & 0xFF) & 0x3F;
|
||||
}
|
||||
|
||||
static int? descriptorByteLength(int pathDescriptor) {
|
||||
final hashSize = descriptorHashSize(pathDescriptor);
|
||||
final hopCount = descriptorHopCount(pathDescriptor);
|
||||
if (hashSize == null || hopCount == null) return null;
|
||||
final byteLen = hopCount * hashSize;
|
||||
return byteLen <= 64 ? byteLen : null;
|
||||
}
|
||||
|
||||
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
|
||||
if (pathBytes.isEmpty) return 1;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
|
||||
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||
final emoji = contact.roleEmoji;
|
||||
|
||||
if (emoji != null && emoji.isNotEmpty) {
|
||||
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
|
||||
return _buildAvatarFrame(
|
||||
backgroundColor: backgroundColor,
|
||||
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
|
||||
bool get _usesSquareShape =>
|
||||
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||
|
||||
bool get _showsLeadingEmoji {
|
||||
final emoji = contact.roleEmoji;
|
||||
if (emoji == null || emoji.isEmpty) return false;
|
||||
|
||||
final effectiveName = (displayName ?? contact.displayName).trimLeft();
|
||||
return effectiveName.startsWith(emoji);
|
||||
}
|
||||
|
||||
Color _getBackgroundColor(BuildContext context) {
|
||||
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
||||
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
|
||||
return TrailColorService.getTrailColor(contact);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
||||
}
|
||||
|
||||
Future<void> _loadLocalIPs() async {
|
||||
if (kIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Set<String> ipsSet = {};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -9,11 +10,11 @@ import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/map_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../providers/sensors_provider.dart';
|
||||
import '../../services/message_destination_preferences.dart';
|
||||
import 'contact_route_dialog.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -48,6 +49,7 @@ class ContactTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isChannel = contact.type == ContactType.channel;
|
||||
final location = contact.displayLocation;
|
||||
// Calculate distance if both positions are available
|
||||
String? distanceText;
|
||||
@@ -114,33 +116,33 @@ class ContactTile extends StatelessWidget {
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final subtitleWidget = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (location != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
_buildLocationLine(
|
||||
context,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
distanceText: distanceText,
|
||||
),
|
||||
if (contact.type != ContactType.channel) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [_buildRoutePill(context, contact)]),
|
||||
],
|
||||
] else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.noGpsData,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final Widget? subtitleWidget = isChannel
|
||||
? null
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (location != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
_buildLocationLine(
|
||||
context,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
distanceText: distanceText,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [_buildRoutePill(context, contact)]),
|
||||
] else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.noGpsData,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@@ -237,7 +239,8 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(timeAgoText, style: timeAgoStyle),
|
||||
if (!isChannel)
|
||||
Text(timeAgoText, style: timeAgoStyle),
|
||||
if (isPingInProgress) ...[
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
@@ -251,7 +254,7 @@ class ContactTile extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitleWidget,
|
||||
?subtitleWidget,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -264,11 +267,6 @@ class ContactTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _handlePrimaryTap(BuildContext context, Contact contact) {
|
||||
if (contact.type == ContactType.repeater) {
|
||||
_showContactDetails(context, contact);
|
||||
return;
|
||||
}
|
||||
|
||||
_showContactActionSheet(context, contact);
|
||||
}
|
||||
|
||||
@@ -280,6 +278,11 @@ class ContactTile extends StatelessWidget {
|
||||
contact.type == ContactType.channel;
|
||||
final canSetPath =
|
||||
contact.type == ContactType.chat || contact.type == ContactType.room;
|
||||
final canAddToSensors =
|
||||
contact.type == ContactType.chat ||
|
||||
contact.type == ContactType.repeater;
|
||||
final sensorsProvider = context.read<SensorsProvider>();
|
||||
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -301,6 +304,46 @@ class ContactTile extends StatelessWidget {
|
||||
await _openMessagesForContact(context, contact);
|
||||
},
|
||||
),
|
||||
if (contact.displayLocation != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.map_outlined),
|
||||
title: Text(l10n.viewOnMap),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showContactOnMap(context, contact);
|
||||
},
|
||||
),
|
||||
if (contact.type == ContactType.room && !contact.isPublicChannel)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.login),
|
||||
title: Text(
|
||||
context
|
||||
.read<ConnectionProvider>()
|
||||
.getRoomLoginState(contact.publicKeyPrefix)
|
||||
?.isLoggedIn ==
|
||||
true
|
||||
? AppLocalizations.of(context)!.reLoginToRoom
|
||||
: AppLocalizations.of(context)!.loginToRoom,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showRoomLoginDialog(context, contact);
|
||||
},
|
||||
),
|
||||
if (canAddToSensors)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isInSensors ? Icons.sensors : Icons.sensors_outlined,
|
||||
),
|
||||
title: Text(isInSensors ? 'In Sensors' : 'Add to Sensors'),
|
||||
enabled: !isInSensors,
|
||||
onTap: isInSensors
|
||||
? null
|
||||
: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await _addContactToSensors(context, contact);
|
||||
},
|
||||
),
|
||||
if (canSetPath)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.alt_route),
|
||||
@@ -332,6 +375,32 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactOnMap(BuildContext context, Contact contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<MapProvider>().navigateToLocation(
|
||||
location: LatLng(location.latitude, location.longitude),
|
||||
);
|
||||
onNavigateToMap?.call();
|
||||
}
|
||||
|
||||
Future<void> _addContactToSensors(
|
||||
BuildContext context,
|
||||
Contact contact,
|
||||
) async {
|
||||
await context.read<SensorsProvider>().addSensor(contact);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${contact.displayName} added to Sensors')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openMessagesForContact(
|
||||
BuildContext context,
|
||||
Contact contact,
|
||||
@@ -422,505 +491,6 @@ class ContactTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _showContactDetails(BuildContext context, Contact contact) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// Get room login state
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final roomLoginState = contact.type == ContactType.room
|
||||
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
||||
: null;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
final contactsProvider = context.watch<ContactsProvider>();
|
||||
final currentContact =
|
||||
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||
final isPingInProgress = context
|
||||
.watch<ConnectionProvider>()
|
||||
.isPingInProgress(contact.publicKey);
|
||||
return Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
ContactAvatar(contact: contact, radius: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_detailRow(l10n.type, contact.type.displayName),
|
||||
if (contact.isChannel) ...[
|
||||
_detailRow(
|
||||
l10n.channel,
|
||||
contact.getLocalizedDisplayName(context),
|
||||
),
|
||||
if (!contact.isPublicChannel)
|
||||
_detailRow(
|
||||
'Slot',
|
||||
'${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}',
|
||||
),
|
||||
] else
|
||||
// Public Key with copy button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'${l10n.publicKey}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(contact.publicKeyShort)),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: contact.publicKeyHex),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.publicKeyCopied),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.copy,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_detailRow(
|
||||
l10n.lastSeen,
|
||||
_getLocalizedTimeSinceLastSeen(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Room Login Status
|
||||
if (roomLoginState != null) ...[
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.roomStatus}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.loginStatus,
|
||||
roomLoginState.isLoggedIn
|
||||
? AppLocalizations.of(context)!.loggedIn
|
||||
: AppLocalizations.of(context)!.notLoggedIn,
|
||||
),
|
||||
if (roomLoginState.isLoggedIn) ...[
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.adminAccess,
|
||||
roomLoginState.isAdmin
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.permissions,
|
||||
roomLoginState.permissions.toString(),
|
||||
),
|
||||
if (roomLoginState.loginDurationFormatted != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.loggedIn,
|
||||
roomLoginState.loginDurationFormatted!,
|
||||
),
|
||||
],
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.passwordSaved,
|
||||
roomLoginState.hasPassword
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (contact.displayLocation != null) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.locationColon,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
// Navigate to map and close modal
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: LatLng(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
|
||||
// Switch to map tab using callback
|
||||
onNavigateToMap?.call();
|
||||
},
|
||||
icon: const Icon(Icons.map, size: 18),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.viewOnMap,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Decimal Degrees (DD)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DD',
|
||||
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
// Degrees Minutes Seconds (DMS)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DMS',
|
||||
_convertToDMS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Degrees Decimal Minutes (DDM)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DDM',
|
||||
_convertToDDM(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// MGRS (Military Grid Reference System)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'MGRS',
|
||||
_convertToMGRS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Google Plus Code
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'Plus Code',
|
||||
formatPlusCode(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (contact.telemetry != null) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.telemetry}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: isPingInProgress
|
||||
? null
|
||||
: () async {
|
||||
final connectionProvider = context
|
||||
.read<ConnectionProvider>();
|
||||
final result = await connectionProvider
|
||||
.smartPing(
|
||||
contactPublicKey: contact.publicKey,
|
||||
hasPath: contact.routeHasPath,
|
||||
);
|
||||
|
||||
if (!context.mounted || result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.pingFailed(contact.displayName),
|
||||
);
|
||||
},
|
||||
icon: isPingInProgress
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.refresh),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (contact.telemetry!.batteryMilliVolts != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.voltage,
|
||||
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
)
|
||||
else if (contact.telemetry!.batteryPercentage != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.battery,
|
||||
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.temperature != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.temperature,
|
||||
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
|
||||
),
|
||||
if (contact.telemetry!.humidity != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.humidity,
|
||||
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.pressure != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.pressure,
|
||||
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
|
||||
),
|
||||
if (contact.telemetry!.gpsLocation != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.gpsTelemetry,
|
||||
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.updated,
|
||||
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
|
||||
),
|
||||
],
|
||||
if (!currentContact.isChannel) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Route',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_detailRow('Mode', currentContact.routeSummary),
|
||||
if (currentContact.routeHopCount > 0)
|
||||
_detailRow('Route', currentContact.routeCanonicalText),
|
||||
if (currentContact.routeHopCount > 0)
|
||||
_detailRow(
|
||||
'Descriptor',
|
||||
'0x${currentContact.routeEncodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
_showSetRouteDialog(context, currentContact),
|
||||
icon: const Icon(Icons.route),
|
||||
label: const Text('Set Route'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: currentContact.isPublicChannel
|
||||
? null
|
||||
: () async {
|
||||
contactsProvider.resetContactRouteLocal(
|
||||
currentContact.publicKey,
|
||||
);
|
||||
try {
|
||||
await connectionProvider.resetPath(
|
||||
currentContact.publicKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.pathResetInfo(
|
||||
currentContact.displayName,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
contactsProvider.setContactRouteLocal(
|
||||
currentContact.publicKey,
|
||||
signedEncodedPathLen:
|
||||
currentContact.routeSignedPathLen,
|
||||
paddedPathBytes:
|
||||
currentContact.outPath,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
'Failed to reset route.',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.resetPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Room Login button for room contacts (except Public Channel)
|
||||
if (contact.type == ContactType.room &&
|
||||
!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Close details first
|
||||
_showRoomLoginDialog(context, contact);
|
||||
},
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(
|
||||
roomLoginState?.isLoggedIn == true
|
||||
? AppLocalizations.of(context)!.reLoginToRoom
|
||||
: AppLocalizations.of(context)!.loginToRoom,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _getTypeColor(
|
||||
contact.type,
|
||||
context,
|
||||
),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Delete Contact button (for all contact types except Public Channel)
|
||||
if (!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
if (contact.isChannel) {
|
||||
_showDeleteChannelDialog(
|
||||
context,
|
||||
contact,
|
||||
closeDetailsSheetOnDelete: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_showDeleteConfirmation(
|
||||
context,
|
||||
contact,
|
||||
closeDetailsSheetOnDelete: true,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
contact.isChannel
|
||||
? AppLocalizations.of(context)!.deleteChannel
|
||||
: AppLocalizations.of(context)!.deleteContact,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: const BorderSide(color: Colors.red),
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showSetRouteDialog(
|
||||
BuildContext context,
|
||||
Contact contact,
|
||||
@@ -996,67 +566,6 @@ class ContactTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _detailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRowWithCopy(BuildContext context, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.copiedToClipboard(label),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.copy,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationMeta(
|
||||
BuildContext context,
|
||||
double latitude,
|
||||
@@ -1124,75 +633,6 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to Degrees Minutes Seconds (DMS) format
|
||||
String _convertToDMS(double lat, double lon) {
|
||||
String latDir = lat >= 0 ? 'N' : 'S';
|
||||
String lonDir = lon >= 0 ? 'E' : 'W';
|
||||
|
||||
lat = lat.abs();
|
||||
lon = lon.abs();
|
||||
|
||||
int latDeg = lat.floor();
|
||||
double latMinDec = (lat - latDeg) * 60;
|
||||
int latMin = latMinDec.floor();
|
||||
double latSec = (latMinDec - latMin) * 60;
|
||||
|
||||
int lonDeg = lon.floor();
|
||||
double lonMinDec = (lon - lonDeg) * 60;
|
||||
int lonMin = lonMinDec.floor();
|
||||
double lonSec = (lonMinDec - lonMin) * 60;
|
||||
|
||||
return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir';
|
||||
}
|
||||
|
||||
/// Convert to Degrees Decimal Minutes (DDM) format
|
||||
String _convertToDDM(double lat, double lon) {
|
||||
String latDir = lat >= 0 ? 'N' : 'S';
|
||||
String lonDir = lon >= 0 ? 'E' : 'W';
|
||||
|
||||
lat = lat.abs();
|
||||
lon = lon.abs();
|
||||
|
||||
int latDeg = lat.floor();
|
||||
double latMin = (lat - latDeg) * 60;
|
||||
|
||||
int lonDeg = lon.floor();
|
||||
double lonMin = (lon - lonDeg) * 60;
|
||||
|
||||
return '$latDeg° ${latMin.toStringAsFixed(4)}\'$latDir, $lonDeg° ${lonMin.toStringAsFixed(4)}\'$lonDir';
|
||||
}
|
||||
|
||||
/// Convert to MGRS (Military Grid Reference System) format
|
||||
/// Simplified implementation - returns approximate grid zone
|
||||
String _convertToMGRS(double lat, double lon) {
|
||||
// Zone number (1-60)
|
||||
int zone = ((lon + 180) / 6).floor() + 1;
|
||||
|
||||
// Zone letter (C-X, excluding I and O)
|
||||
const letters = 'CDEFGHJKLMNPQRSTUVWX';
|
||||
int letterIndex = ((lat + 80) / 8).floor();
|
||||
if (letterIndex < 0) letterIndex = 0;
|
||||
if (letterIndex >= letters.length) letterIndex = letters.length - 1;
|
||||
String letter = letters[letterIndex];
|
||||
|
||||
// Simplified - just show zone designation
|
||||
// Full MGRS would require UTM conversion library
|
||||
return '$zone$letter (approximate)';
|
||||
}
|
||||
|
||||
Color _getTypeColor(ContactType type, BuildContext context) {
|
||||
switch (type) {
|
||||
case ContactType.chat:
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
case ContactType.repeater:
|
||||
return Colors.green;
|
||||
case ContactType.room:
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get room login status color
|
||||
Color _getRoomStatusColor(RoomLoginState state) {
|
||||
if (!state.isLoggedIn) {
|
||||
@@ -1215,41 +655,6 @@ class ContactTile extends StatelessWidget {
|
||||
return Icons.check; // Check for logged in (non-admin)
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final timestampDate = DateTime(
|
||||
timestamp.year,
|
||||
timestamp.month,
|
||||
timestamp.day,
|
||||
);
|
||||
|
||||
if (timestampDate == today) {
|
||||
// Today - show time only
|
||||
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
// Another day - show date and time
|
||||
return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')} ${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimeAgo(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(timestamp);
|
||||
|
||||
if (diff.inSeconds < 60) {
|
||||
return '${diff.inSeconds}s ago';
|
||||
} else if (diff.inMinutes < 60) {
|
||||
return '${diff.inMinutes}m ago';
|
||||
} else if (diff.inHours < 24) {
|
||||
return '${diff.inHours}h ago';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'yesterday';
|
||||
} else {
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
}
|
||||
|
||||
/// Show delete channel confirmation dialog
|
||||
void _showDeleteChannelDialog(
|
||||
BuildContext context,
|
||||
|
||||
@@ -403,15 +403,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Image fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
@@ -462,7 +453,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
);
|
||||
await conn.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
contactPathLen: sender.routeEncodedPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../../utils/log_rx_route_decoder.dart';
|
||||
import '../../models/message_transfer_details.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
@@ -1269,12 +1270,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
|
||||
// Logged frame format:
|
||||
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
|
||||
final raw = log.rawData;
|
||||
final payloadType = (raw[3] >> 2) & 0x0F;
|
||||
final pathLen = raw[4];
|
||||
if (payloadType != expectedPayloadType) continue;
|
||||
if (pathLen != message.pathLen) continue;
|
||||
if (raw.length < 5 + pathLen) continue;
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null) continue;
|
||||
if (decoded.payloadType != expectedPayloadType) continue;
|
||||
if (decoded.hopCount != message.pathLen) continue;
|
||||
|
||||
final deltaMs =
|
||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||
@@ -1290,11 +1289,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
|
||||
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
||||
if (log == null) return null;
|
||||
final raw = log.rawData;
|
||||
if (raw.length < 6) return null;
|
||||
final pathLen = raw[4];
|
||||
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
|
||||
return raw.sublist(5, 5 + pathLen);
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null || decoded.pathBytes.isEmpty) return null;
|
||||
return decoded.pathBytes;
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation(BuildContext context) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ignore_for_file: use_null_aware_elements
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -60,7 +61,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
return trace;
|
||||
}
|
||||
|
||||
final remoteNodes = await MeshMapNodesService.fetchNodes(
|
||||
unawaited(
|
||||
MeshMapNodesService.syncInBackgroundIfStale(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
),
|
||||
);
|
||||
|
||||
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
trace = _buildTraceResult(
|
||||
@@ -516,13 +523,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
for (final log in logs) {
|
||||
if (log.responseCode != 0x88) continue; // pushLogRxData
|
||||
if (log.rawData.length < 6) continue;
|
||||
final raw = log.rawData;
|
||||
final header = raw[3];
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
final pathLen = raw[4];
|
||||
if (payloadType != expectedPayloadType) continue;
|
||||
if (pathLen != message.pathLen) continue;
|
||||
if (raw.length < 5 + pathLen) continue;
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null) continue;
|
||||
if (decoded.payloadType != expectedPayloadType) continue;
|
||||
if (decoded.hopCount != message.pathLen) continue;
|
||||
|
||||
final deltaMs =
|
||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||
@@ -533,9 +537,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
}
|
||||
|
||||
if (bestLog == null || bestDeltaMs > 30000) return null;
|
||||
final raw = bestLog.rawData;
|
||||
final pathLen = raw[4];
|
||||
return raw.sublist(5, 5 + pathLen);
|
||||
final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
|
||||
if (decoded == null || decoded.pathBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return decoded.pathBytes;
|
||||
}
|
||||
|
||||
List<MeshMapNode?> _matchNodesFromPathHashes({
|
||||
|
||||
@@ -366,15 +366,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
@@ -415,10 +406,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
)
|
||||
: VoiceFetchRequest(
|
||||
sessionId: sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
);
|
||||
: VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6);
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
@@ -426,7 +414,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
);
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
contactPathLen: sender.routeEncodedPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
} catch (_) {
|
||||
|
||||
@@ -796,7 +796,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: main
|
||||
resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9
|
||||
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
|
||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
@@ -1265,10 +1265,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+2"
|
||||
version: "2.4.2+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -104,7 +104,7 @@ void main() {
|
||||
expect(contact.routeHashSize, 3);
|
||||
expect(contact.routeHopCount, 2);
|
||||
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
|
||||
expect(contact.routeSupportsLegacyRawTransport, isFalse);
|
||||
expect(contact.routeSupportsLegacyRawTransport, isTrue);
|
||||
});
|
||||
|
||||
test('treats -1 as unknown route', () {
|
||||
|
||||
@@ -47,6 +47,63 @@ void main() {
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('returns false when requester has no learned path', () async {
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
providerLabel: 'TestProvider',
|
||||
sessionId: 'deadbeef',
|
||||
requester: _buildContact(outPathLen: -1),
|
||||
fragments: [
|
||||
_Fragment(0, Uint8List.fromList([1])),
|
||||
],
|
||||
maxDirectPayloadHops: 3,
|
||||
indexOf: (f) => f.index,
|
||||
encodeBinary: (f) => f.payload,
|
||||
sendRawPacket:
|
||||
({
|
||||
required contactPath,
|
||||
required contactPathLen,
|
||||
required payload,
|
||||
}) async {},
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('returns false when requester path payload is empty', () async {
|
||||
final requester = Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: Uint8List(0),
|
||||
advName: 'Requester',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
providerLabel: 'TestProvider',
|
||||
sessionId: 'deadbeef',
|
||||
requester: requester,
|
||||
fragments: [
|
||||
_Fragment(0, Uint8List.fromList([1])),
|
||||
],
|
||||
maxDirectPayloadHops: 3,
|
||||
indexOf: (f) => f.index,
|
||||
encodeBinary: (f) => f.payload,
|
||||
sendRawPacket:
|
||||
({
|
||||
required contactPath,
|
||||
required contactPathLen,
|
||||
required payload,
|
||||
}) async {},
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('sends only requested indices', () async {
|
||||
final sent = <Uint8List>[];
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||
@@ -11,6 +12,30 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Contact _buildContact({
|
||||
required List<int> prefix,
|
||||
required ContactType type,
|
||||
String name = 'Test Contact',
|
||||
}) {
|
||||
final key = Uint8List.fromList([
|
||||
...prefix,
|
||||
...List<int>.generate(26, (index) => index),
|
||||
]);
|
||||
|
||||
return Contact(
|
||||
publicKey: key,
|
||||
type: type,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
group('MessagesProvider voice detection', () {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
@@ -190,5 +215,60 @@ void main() {
|
||||
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
|
||||
expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
|
||||
});
|
||||
|
||||
test('marks only the selected contact destination as read', () {
|
||||
final provider = MessagesProvider();
|
||||
final alice = _buildContact(
|
||||
prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
|
||||
type: ContactType.chat,
|
||||
name: 'Alice',
|
||||
);
|
||||
final bob = _buildContact(
|
||||
prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25],
|
||||
type: ContactType.chat,
|
||||
name: 'Bob',
|
||||
);
|
||||
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'alice-incoming',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000100,
|
||||
text: 'Alice unread',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: alice.publicKey.sublist(0, 6),
|
||||
),
|
||||
);
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'bob-incoming',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000101,
|
||||
text: 'Bob unread',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: bob.publicKey.sublist(0, 6),
|
||||
),
|
||||
);
|
||||
|
||||
provider.markDestinationAsRead(
|
||||
destinationType: 'contact',
|
||||
contact: alice,
|
||||
);
|
||||
|
||||
final aliceMessage = provider.messages.firstWhere(
|
||||
(message) => message.id == 'alice-incoming',
|
||||
);
|
||||
final bobMessage = provider.messages.firstWhere(
|
||||
(message) => message.id == 'bob-incoming',
|
||||
);
|
||||
|
||||
expect(aliceMessage.isRead, isTrue);
|
||||
expect(bobMessage.isRead, isFalse);
|
||||
expect(provider.unreadCount, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -22,5 +22,13 @@ void main() {
|
||||
test('keeps non-hash labels at two characters', () {
|
||||
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
|
||||
});
|
||||
|
||||
test('strips emoji when building fallback initials', () {
|
||||
expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD');
|
||||
});
|
||||
|
||||
test('strips leading emoji when building fallback initials', () {
|
||||
expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -24,12 +24,37 @@ void main() {
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.payloadType, 0x01);
|
||||
expect(decoded.pathDescriptor, 0x04);
|
||||
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
|
||||
expect(decoded.hashSize, 2);
|
||||
expect(decoded.hopHashes, ['c2ba', '5fde']);
|
||||
expect(decoded.originalSenderHashHex, 'c2ba');
|
||||
});
|
||||
|
||||
test('parses encoded descriptor with 2-byte hashes', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
0x37,
|
||||
0xae,
|
||||
0x05,
|
||||
0x42,
|
||||
0xc2,
|
||||
0xba,
|
||||
0x5f,
|
||||
0xde,
|
||||
0x5c,
|
||||
]);
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(packet);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.pathDescriptor, 0x42);
|
||||
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
|
||||
expect(decoded.hashSize, 2);
|
||||
expect(decoded.hopCount, 2);
|
||||
expect(decoded.hopHashes, ['c2ba', '5fde']);
|
||||
});
|
||||
|
||||
test('uses preferred hash size when packet length is ambiguous', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
|
||||
@@ -52,7 +52,7 @@ void main() {
|
||||
contact.publicKeyHex: 3,
|
||||
},
|
||||
currentDestinationType: 'all',
|
||||
onSelect: (_, __) {},
|
||||
onSelect: (selectedContact, destinationType) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user