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
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Build web release
|
- 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
|
- name: Upload pages artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
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.
|
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.
|
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
|
## Highlights
|
||||||
|
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 106;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 106;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 106;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 106;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 106;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 106;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -8,6 +9,7 @@ import Flutter
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>106</string>
|
<string>107</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<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/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -19,6 +22,7 @@ import 'services/voice_codec_service.dart';
|
|||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_service.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/locale_preferences.dart';
|
import 'services/locale_preferences.dart';
|
||||||
|
import 'services/mesh_map_nodes_service.dart';
|
||||||
import 'services/update_checker_service.dart';
|
import 'services/update_checker_service.dart';
|
||||||
import 'services/wizard_preferences.dart';
|
import 'services/wizard_preferences.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Shows notification if update is available
|
// Shows notification if update is available
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
|
|
||||||
|
// Refresh remote mesh node cache in background when the app starts.
|
||||||
|
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_wizardCompleted = wizardCompleted;
|
_wizardCompleted = wizardCompleted;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
@@ -158,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
/// Shows notification if update is available
|
/// Shows notification if update is available
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only check for updates on Android
|
// Only check for updates on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -324,7 +331,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
// 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)
|
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||||
if (Platform.isAndroid) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
left: false,
|
left: false,
|
||||||
right: false,
|
right: false,
|
||||||
|
|||||||
@@ -240,6 +240,5 @@ extension ContactLocalization on Contact {
|
|||||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get routeSupportsLegacyRawTransport =>
|
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||||
routeHasPath && routeSignedPathLen >= 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import '../utils/image_message_parser.dart';
|
|||||||
import '../utils/media_swarm_protocol.dart';
|
import '../utils/media_swarm_protocol.dart';
|
||||||
import '../utils/message_airtime_estimator.dart';
|
import '../utils/message_airtime_estimator.dart';
|
||||||
import '../utils/fast_gps_packet.dart';
|
import '../utils/fast_gps_packet.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
class _DirectMessageRouteSession {
|
class _DirectMessageRouteSession {
|
||||||
final PathSelection currentSelection;
|
final PathSelection currentSelection;
|
||||||
@@ -1973,14 +1974,13 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (requester == null ||
|
if (requester == null ||
|
||||||
!requester.routeHasPath ||
|
!requester.routeHasPath ||
|
||||||
requester.routeHopCount > _maxDirectPayloadHops ||
|
requester.routeHopCount > _maxDirectPayloadHops ||
|
||||||
!requester.routeSupportsLegacyRawTransport ||
|
|
||||||
requester.outPath.isEmpty) {
|
requester.outPath.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(
|
unawaited(
|
||||||
connectionProvider.sendRawVoicePacket(
|
connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.routeSignedPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: availability.encodeBinary(),
|
payload: availability.encodeBinary(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2050,7 +2050,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
for (final peer in peers) {
|
for (final peer in peers) {
|
||||||
await connectionProvider.sendRawVoicePacket(
|
await connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: peer.outPath,
|
contactPath: peer.outPath,
|
||||||
contactPathLen: peer.routeSignedPathLen,
|
contactPathLen: peer.routeEncodedPathLen,
|
||||||
payload: request.encodeBinary(),
|
payload: request.encodeBinary(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2073,7 +2073,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (responder == null ||
|
if (responder == null ||
|
||||||
!responder.routeHasPath ||
|
!responder.routeHasPath ||
|
||||||
responder.routeHopCount > _maxDirectPayloadHops ||
|
responder.routeHopCount > _maxDirectPayloadHops ||
|
||||||
!responder.routeSupportsLegacyRawTransport ||
|
|
||||||
responder.outPath.isEmpty) {
|
responder.outPath.isEmpty) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2165,7 +2164,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
await connectionProvider.sendRawVoicePacket(
|
await connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: target.outPath,
|
contactPath: target.outPath,
|
||||||
contactPathLen: target.routeSignedPathLen,
|
contactPathLen: target.routeEncodedPathLen,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -2406,9 +2405,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
|
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!target.routeSupportsLegacyRawTransport) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (target.outPath.isEmpty) {
|
if (target.outPath.isEmpty) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2441,7 +2437,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
await connectionProvider.sendRawVoicePacket(
|
await connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: target.outPath,
|
contactPath: target.outPath,
|
||||||
contactPathLen: target.routeSignedPathLen,
|
contactPathLen: target.routeEncodedPathLen,
|
||||||
payload: RawRouteProbeRequest(
|
payload: RawRouteProbeRequest(
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
@@ -2469,7 +2465,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (target.publicKeyHex.isNotEmpty) {
|
if (target.publicKeyHex.isNotEmpty) {
|
||||||
return 'pk:${target.publicKeyHex}';
|
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) {
|
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
|
||||||
@@ -2487,9 +2483,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!requester.routeSupportsLegacyRawTransport) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (requester.outPath.isEmpty) {
|
if (requester.outPath.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2499,7 +2492,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
unawaited(
|
unawaited(
|
||||||
connectionProvider.sendRawVoicePacket(
|
connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.routeSignedPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
|
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2573,12 +2566,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (log.responseCode != 0x88) continue;
|
if (log.responseCode != 0x88) continue;
|
||||||
if (log.rawData.length < 6) continue;
|
if (log.rawData.length < 6) continue;
|
||||||
|
|
||||||
final raw = log.rawData;
|
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||||
final payloadType = (raw[3] >> 2) & 0x0F;
|
if (decoded == null) continue;
|
||||||
final pathLen = raw[4];
|
if (decoded.payloadType != expectedPayloadType) continue;
|
||||||
if (payloadType != expectedPayloadType) continue;
|
if (decoded.hopCount != message.pathLen) continue;
|
||||||
if (pathLen != message.pathLen) continue;
|
|
||||||
if (raw.length < 5 + pathLen) continue;
|
|
||||||
|
|
||||||
final deltaMs =
|
final deltaMs =
|
||||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||||
@@ -2594,11 +2585,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
||||||
if (log == null) return null;
|
if (log == null) return null;
|
||||||
final raw = log.rawData;
|
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||||
if (raw.length < 6) return null;
|
if (decoded == null || decoded.pathBytes.isEmpty) return null;
|
||||||
final pathLen = raw[4];
|
return decoded.pathBytes;
|
||||||
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
|
|
||||||
return raw.sublist(5, 5 + pathLen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
// 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
|
/// Start scanning for MeshCore devices
|
||||||
Future<void> startScan() async {
|
Future<void> startScan() async {
|
||||||
debugPrint('🔍 [Provider] startScan() called');
|
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;
|
_isScanning = true;
|
||||||
_scannedDevices.clear();
|
_scannedDevices.clear();
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -559,6 +567,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (_isScanning) {
|
||||||
|
debugPrint('🔵 [Provider] Stopping active scan before connect()');
|
||||||
|
await stopScan();
|
||||||
|
}
|
||||||
|
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
deviceId: device.remoteId.toString(),
|
deviceId: device.remoteId.toString(),
|
||||||
deviceName: device.platformName.isNotEmpty
|
deviceName: device.platformName.isNotEmpty
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
);
|
);
|
||||||
return false;
|
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) {
|
if (requester.outPath.isEmpty) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||||
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
try {
|
try {
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.routeSignedPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
|
|||||||
@@ -765,14 +765,14 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
|
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
|
||||||
final summary =
|
final summary =
|
||||||
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
|
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
|
||||||
return isChannelMessage ? '$senderName\n$summary' : summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||||
if (imageEnvelope != null) {
|
if (imageEnvelope != null) {
|
||||||
final summary =
|
final summary =
|
||||||
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
|
'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) {
|
if (!isChannelMessage && message.recipientPublicKey != null) {
|
||||||
@@ -785,10 +785,6 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isChannelMessage) {
|
|
||||||
return '$senderName\n${message.text}';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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
|
/// Mark a specific message as read
|
||||||
void markAsRead(String messageId) {
|
void markAsRead(String messageId) {
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import '../models/contact.dart';
|
|||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../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/contact_tile.dart';
|
||||||
import '../widgets/contacts/add_channel_dialog.dart';
|
import '../widgets/contacts/add_channel_dialog.dart';
|
||||||
|
|
||||||
@@ -27,6 +32,11 @@ class ContactsTab extends StatefulWidget {
|
|||||||
class _ContactsTabState extends State<ContactsTab> {
|
class _ContactsTabState extends State<ContactsTab> {
|
||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||||
|
final Map<ContactSection, ContactSortMode> _sortModes = {
|
||||||
|
ContactSection.teamMembers: ContactSortMode.lastSeen,
|
||||||
|
ContactSection.repeaters: ContactSortMode.lastSeen,
|
||||||
|
ContactSection.rooms: ContactSortMode.lastSeen,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -125,20 +135,31 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
return l10n.daysAgo(diff.inDays);
|
return l10n.daysAgo(diff.inDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Contact> _sortContactsByDistance(List<Contact> contacts) {
|
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
|
||||||
final sorted = List<Contact>.from(contacts);
|
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) {
|
sorted.sort((a, b) {
|
||||||
final distanceA = _distanceFromCurrentPosition(a);
|
if (sortMode == ContactSortMode.distance) {
|
||||||
final distanceB = _distanceFromCurrentPosition(b);
|
final distanceA = _distanceFromCurrentPosition(a);
|
||||||
|
final distanceB = _distanceFromCurrentPosition(b);
|
||||||
|
|
||||||
if (distanceA != null && distanceB != null) {
|
if (distanceA != null && distanceB != null) {
|
||||||
final distanceCompare = distanceA.compareTo(distanceB);
|
final distanceCompare = distanceA.compareTo(distanceB);
|
||||||
if (distanceCompare != 0) return distanceCompare;
|
if (distanceCompare != 0) return distanceCompare;
|
||||||
} else if (distanceA != null) {
|
} else if (distanceA != null) {
|
||||||
return -1;
|
return -1;
|
||||||
} else if (distanceB != null) {
|
} else if (distanceB != null) {
|
||||||
return 1;
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||||
@@ -208,12 +229,23 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Consumer<ContactsProvider>(
|
body: Consumer<ContactsProvider>(
|
||||||
builder: (context, contactsProvider, child) {
|
builder: (context, contactsProvider, child) {
|
||||||
final chatContacts = _sortContactsByDistance(
|
final messagesProvider = context.watch<MessagesProvider>();
|
||||||
|
final chatContacts = _sortContacts(
|
||||||
contactsProvider.chatContacts,
|
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;
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
|
||||||
// Check if there are any displayable contacts
|
// Check if there are any displayable contacts
|
||||||
@@ -282,17 +314,12 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: l10n.teamMembers,
|
title: l10n.teamMembers,
|
||||||
count: chatContacts.length,
|
count: chatContacts.length,
|
||||||
icon: Icons.people,
|
icon: Icons.people,
|
||||||
),
|
trailing: _buildSortMenu(
|
||||||
...chatContacts.map(
|
context,
|
||||||
(contact) => ContactTile(
|
ContactSection.teamMembers,
|
||||||
contact: contact,
|
|
||||||
currentPosition: _currentPosition,
|
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
onNavigateToMessages: widget.onNavigateToMessages,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
..._buildContactSectionItems(chatContacts),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -302,17 +329,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: l10n.repeaters,
|
title: l10n.repeaters,
|
||||||
count: repeaters.length,
|
count: repeaters.length,
|
||||||
icon: Icons.router,
|
icon: Icons.router,
|
||||||
|
trailing: _buildSortMenu(context, ContactSection.repeaters),
|
||||||
),
|
),
|
||||||
...repeaters.map(
|
..._buildContactSectionItems(repeaters),
|
||||||
(contact) => ContactTile(
|
|
||||||
contact: contact,
|
|
||||||
currentPosition: _currentPosition,
|
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
onNavigateToMessages: widget.onNavigateToMessages,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -322,17 +341,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: l10n.rooms,
|
title: l10n.rooms,
|
||||||
count: rooms.length,
|
count: rooms.length,
|
||||||
icon: Icons.tag,
|
icon: Icons.tag,
|
||||||
|
trailing: _buildSortMenu(context, ContactSection.rooms),
|
||||||
),
|
),
|
||||||
...rooms.map(
|
..._buildContactSectionItems(rooms),
|
||||||
(contact) => ContactTile(
|
|
||||||
contact: contact,
|
|
||||||
currentPosition: _currentPosition,
|
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
onNavigateToMessages: widget.onNavigateToMessages,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -344,12 +355,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
if (channels.isNotEmpty) ...[
|
if (channels.isNotEmpty) ...[
|
||||||
...channels.map(
|
...channels.map(
|
||||||
(contact) => ContactTile(
|
(channel) => _ChannelActivityCard(
|
||||||
contact: contact,
|
channel: channel,
|
||||||
currentPosition: _currentPosition,
|
messagesProvider: messagesProvider,
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
contactsProvider: contactsProvider,
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
onNavigateToMessages: widget.onNavigateToMessages,
|
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 {
|
class _PendingAdvertTile extends StatelessWidget {
|
||||||
final PendingAdvert advert;
|
final PendingAdvert advert;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
@@ -427,11 +525,13 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final int count;
|
final int count;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
const _SectionHeader({
|
const _SectionHeader({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.count,
|
required this.count,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -460,6 +560,415 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.labelSmall,
|
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 '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
import '../services/developer_mode_service.dart';
|
import '../services/developer_mode_service.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
enum _HomeTab { messages, contacts, sensors, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_initTabController();
|
_initTabController();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadDeveloperModePreference();
|
_loadDeveloperModePreference();
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
if (widget.shouldShowPermissionDialog) {
|
if (widget.shouldShowPermissionDialog) {
|
||||||
@@ -195,7 +197,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _HomeTab.messages:
|
case _HomeTab.messages:
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
break;
|
break;
|
||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
context.read<ContactsProvider>().markAllAsViewed();
|
context.read<ContactsProvider>().markAllAsViewed();
|
||||||
@@ -220,6 +221,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
_lifecycleState = state;
|
_lifecycleState = state;
|
||||||
_syncFastLocationUiState();
|
_syncFastLocationUiState();
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadRxTxPreference() async {
|
Future<void> _loadRxTxPreference() async {
|
||||||
@@ -389,10 +393,14 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
required int count,
|
required int count,
|
||||||
required bool isActive,
|
required bool isActive,
|
||||||
required Color activeColor,
|
required Color activeColor,
|
||||||
|
bool compact = false,
|
||||||
}) {
|
}) {
|
||||||
final color = isActive ? activeColor : Colors.grey;
|
final color = isActive ? activeColor : Colors.grey;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 7 : 8,
|
||||||
|
vertical: compact ? 4 : 5,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.12),
|
color: color.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
@@ -401,15 +409,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 7,
|
width: compact ? 6 : 7,
|
||||||
height: 7,
|
height: compact ? 6 : 7,
|
||||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
SizedBox(width: compact ? 5 : 6),
|
||||||
Text(
|
Text(
|
||||||
'$label:$count',
|
'$label:$count',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: compact ? 10 : 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
@@ -1037,17 +1045,19 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
txActive: provider.txActivity,
|
txActive: provider.txActivity,
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 8,
|
||||||
vertical: 8,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh
|
color: theme.colorScheme.surfaceContainerHigh
|
||||||
.withValues(alpha: 0.85),
|
.withValues(alpha: 0.85),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(22),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
_buildActivityBadge(
|
_buildActivityBadge(
|
||||||
@@ -1055,13 +1065,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
count: provider.rxPacketCount,
|
count: provider.rxPacketCount,
|
||||||
isActive: provider.rxActivity,
|
isActive: provider.rxActivity,
|
||||||
activeColor: Colors.green,
|
activeColor: Colors.green,
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 4),
|
||||||
_buildActivityBadge(
|
_buildActivityBadge(
|
||||||
label: 'TX',
|
label: 'TX',
|
||||||
count: provider.txPacketCount,
|
count: provider.txPacketCount,
|
||||||
isActive: provider.txActivity,
|
isActive: provider.txActivity,
|
||||||
activeColor: Colors.blue,
|
activeColor: Colors.blue,
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -89,9 +89,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceBitrate();
|
_loadVoiceBitrate();
|
||||||
// Mark all messages as read when tab is opened
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
_checkForNavigationRequest();
|
_checkForNavigationRequest();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -309,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
@@ -465,6 +465,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
} catch (e) {
|
} 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) {
|
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
||||||
// Get all recent messages
|
// Get all recent messages
|
||||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../utils/log_rx_route_decoder.dart';
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
@@ -746,22 +747,31 @@ class _DecodedRouteSection extends StatelessWidget {
|
|||||||
connectionProvider.deviceInfo.selfName ??
|
connectionProvider.deviceInfo.selfName ??
|
||||||
connectionProvider.deviceInfo.displayName;
|
connectionProvider.deviceInfo.displayName;
|
||||||
|
|
||||||
return FutureBuilder<int>(
|
return FutureBuilder<List<dynamic>>(
|
||||||
future: RouteHashPreferences.getHashSize(),
|
future: Future.wait<dynamic>([
|
||||||
|
RouteHashPreferences.getHashSize(),
|
||||||
|
MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
),
|
||||||
|
]),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final decodedRoute = LogRxRouteDecoder.decode(
|
final decodedRoute = LogRxRouteDecoder.decode(
|
||||||
log.rawData,
|
log.rawData,
|
||||||
preferredHashSize: snapshot.data,
|
preferredHashSize: snapshot.data?.first as int?,
|
||||||
);
|
);
|
||||||
if (decodedRoute == null) {
|
if (decodedRoute == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final cachedNodes = snapshot.hasData
|
||||||
|
? snapshot.data![1] as List<MeshMapNode>
|
||||||
|
: const <MeshMapNode>[];
|
||||||
final resolvedPath = decodedRoute.hopHashes
|
final resolvedPath = decodedRoute.hopHashes
|
||||||
.map(
|
.map(
|
||||||
(hashHex) => LogRxRouteDecoder.resolveHash(
|
(hashHex) => _resolveHashWithFallback(
|
||||||
hashHex,
|
hashHex,
|
||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
|
cachedNodes: cachedNodes,
|
||||||
ownPublicKey: ownPublicKey,
|
ownPublicKey: ownPublicKey,
|
||||||
ownName: ownName,
|
ownName: ownName,
|
||||||
),
|
),
|
||||||
@@ -777,6 +787,41 @@ class _DecodedRouteSection extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ResolvedNodeHash _resolveHashWithFallback(
|
||||||
|
String hashHex, {
|
||||||
|
required List<Contact> contacts,
|
||||||
|
required List<MeshMapNode> cachedNodes,
|
||||||
|
required Uint8List? ownPublicKey,
|
||||||
|
required String? ownName,
|
||||||
|
}) {
|
||||||
|
final localResolved = LogRxRouteDecoder.resolveHash(
|
||||||
|
hashHex,
|
||||||
|
contacts: contacts,
|
||||||
|
ownPublicKey: ownPublicKey,
|
||||||
|
ownName: ownName,
|
||||||
|
);
|
||||||
|
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
|
final matches = cachedNodes
|
||||||
|
.where((node) => node.publicKey.startsWith(normalizedHashHex))
|
||||||
|
.toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
|
return ResolvedNodeHash(
|
||||||
|
hashHex: normalizedHashHex,
|
||||||
|
label: matches.first.name,
|
||||||
|
isOwnNode: false,
|
||||||
|
isUniqueMatch: matches.length == 1,
|
||||||
|
matchCount: matches.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RouteSection extends StatelessWidget {
|
class _RouteSection extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io' show Platform;
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_avif/flutter_avif.dart';
|
import 'package:flutter_avif/flutter_avif.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -15,6 +16,7 @@ import '../providers/app_provider.dart';
|
|||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/locale_preferences.dart';
|
import '../services/locale_preferences.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../services/update_checker_service.dart';
|
import '../services/update_checker_service.dart';
|
||||||
import '../services/voice_codec_service.dart';
|
import '../services/voice_codec_service.dart';
|
||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
@@ -72,6 +74,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
double _fastLocationMovementThresholdMeters = 10.0;
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
int _fastLocationActiveCadenceSeconds = 10;
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
bool _isDeveloperModeEnabled = false;
|
bool _isDeveloperModeEnabled = false;
|
||||||
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
|
bool _isClearingOnlineTraceCache = false;
|
||||||
int _versionTapCount = 0;
|
int _versionTapCount = 0;
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
final LocationTrackingService _locationService = LocationTrackingService();
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
@@ -89,6 +93,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadImagePreferences();
|
_loadImagePreferences();
|
||||||
_loadFastLocationSettings();
|
_loadFastLocationSettings();
|
||||||
_loadDeveloperMode();
|
_loadDeveloperMode();
|
||||||
|
_loadOnlineTraceCacheStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -126,6 +131,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadOnlineTraceCacheStatus() async {
|
||||||
|
final cachedAt = await MeshMapNodesService.cachedAt();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineTraceCacheUpdatedAt = cachedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleVersionTap() async {
|
Future<void> _handleVersionTap() async {
|
||||||
if (_isDeveloperModeEnabled) {
|
if (_isDeveloperModeEnabled) {
|
||||||
await DeveloperModeService.setEnabled(false);
|
await DeveloperModeService.setEnabled(false);
|
||||||
@@ -470,7 +483,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
/// Check for app updates and show notification or dialog
|
/// Check for app updates and show notification or dialog
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only on Android
|
// Only on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
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 {
|
Future<void> _showRouteHashSizeDialog() async {
|
||||||
final selected = await showDialog<int>(
|
final selected = await showDialog<int>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1047,6 +1120,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Tracing'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cloud_sync),
|
||||||
|
title: const Text('Online trace database'),
|
||||||
|
subtitle: Text(_onlineTraceCacheSubtitle()),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.delete_sweep,
|
||||||
|
color: _isClearingOnlineTraceCache ? null : Colors.red,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Clear online trace database',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isClearingOnlineTraceCache ? null : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Remove the 24-hour cached fallback used when local route matches are incomplete',
|
||||||
|
),
|
||||||
|
enabled: !_isClearingOnlineTraceCache,
|
||||||
|
trailing: _isClearingOnlineTraceCache
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: _isClearingOnlineTraceCache
|
||||||
|
? null
|
||||||
|
: _clearOnlineTraceCache,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
_buildSectionHeader('Voice'),
|
_buildSectionHeader('Voice'),
|
||||||
Consumer2<AppProvider, ConnectionProvider>(
|
Consumer2<AppProvider, ConnectionProvider>(
|
||||||
builder: (context, appProvider, connectionProvider, child) =>
|
builder: (context, appProvider, connectionProvider, child) =>
|
||||||
@@ -1213,8 +1321,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
_buildSectionHeader('Network Sharing'),
|
if (!kIsWeb) ...[
|
||||||
const ConnectionModeSelector(),
|
_buildSectionHeader('Network Sharing'),
|
||||||
|
const ConnectionModeSelector(),
|
||||||
|
],
|
||||||
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
@@ -1320,7 +1430,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
onTap: () => _showAboutDialog(),
|
onTap: () => _showAboutDialog(),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
if (Platform.isAndroid)
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: FilledButton.icon(
|
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/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Service for accessing build information from native platform code
|
/// Service for accessing build information from native platform code
|
||||||
/// Currently supports Android only - returns "unknown" for other platforms
|
/// Currently supports Android only - returns "unknown" for other platforms
|
||||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only Android has the platform channel implementation
|
// Only Android has the platform channel implementation
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||||
_cachedCommitHash = 'unknown';
|
_cachedCommitHash = 'unknown';
|
||||||
return _cachedCommitHash!;
|
return _cachedCommitHash!;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class MeshMapNode {
|
class MeshMapNode {
|
||||||
final int type;
|
final int type;
|
||||||
@@ -33,25 +35,35 @@ class MeshMapNode {
|
|||||||
class MeshMapNodesService {
|
class MeshMapNodesService {
|
||||||
static const String _nodesEndpoint =
|
static const String _nodesEndpoint =
|
||||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
static const Duration _cacheTtl = Duration(hours: 24);
|
||||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
static const Duration traceCacheTtl = _cacheTtl;
|
||||||
static const Duration traceTimeout = Duration(seconds: 30);
|
static const Duration traceTimeout = Duration(seconds: 30);
|
||||||
|
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
|
||||||
|
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
|
||||||
static List<MeshMapNode>? _cachedNodes;
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
static DateTime? _cachedAt;
|
static DateTime? _cachedAt;
|
||||||
|
static Future<void>? _ongoingSync;
|
||||||
|
|
||||||
static Future<List<MeshMapNode>> fetchNodes({
|
static Future<List<MeshMapNode>> fetchNodes({
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
Duration cacheTtl = _cacheTtl,
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
bool allowNetwork = true,
|
||||||
|
http.Client? client,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
_cachedNodes != null &&
|
cached.isNotEmpty &&
|
||||||
_cachedAt != null &&
|
_cachedAt != null &&
|
||||||
now.difference(_cachedAt!) < cacheTtl) {
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
return _cachedNodes!;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http
|
if (!allowNetwork) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await (client ?? http.Client())
|
||||||
.get(Uri.parse(_nodesEndpoint))
|
.get(Uri.parse(_nodesEndpoint))
|
||||||
.timeout(traceTimeout);
|
.timeout(traceTimeout);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
@@ -69,8 +81,131 @@ class MeshMapNodesService {
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
_cachedNodes = nodes;
|
await _storeCache(nodes, cachedAt: now);
|
||||||
_cachedAt = now;
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<MeshMapNode>> loadCachedNodes({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedNodes != null &&
|
||||||
|
_cachedAt != null &&
|
||||||
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return _cachedNodes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
final cachedJson = prefs.getString(_cacheKey);
|
||||||
|
if (cachedAtMs == null || cachedJson == null) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
if (now.difference(cachedAt) >= cacheTtl) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(cachedJson) as List<dynamic>;
|
||||||
|
final nodes = decoded
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(MeshMapNode.fromJson)
|
||||||
|
.where(
|
||||||
|
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
|
||||||
|
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
return nodes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> syncInBackgroundIfStale({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
http.Client? client,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
if (cached.isNotEmpty && _cachedAt != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ongoingSync != null) {
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ongoingSync = () async {
|
||||||
|
try {
|
||||||
|
await fetchNodes(
|
||||||
|
forceRefresh: true,
|
||||||
|
cacheTtl: cacheTtl,
|
||||||
|
allowNetwork: true,
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Background refresh is best-effort only.
|
||||||
|
} finally {
|
||||||
|
_ongoingSync = null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearCache() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_cacheKey);
|
||||||
|
await prefs.remove(_cacheTimestampKey);
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<DateTime?> cachedAt() async {
|
||||||
|
if (_cachedAt != null) return _cachedAt;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
if (cachedAtMs == null) return null;
|
||||||
|
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
return _cachedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _storeCache(
|
||||||
|
List<MeshMapNode> nodes, {
|
||||||
|
required DateTime cachedAt,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_cacheKey,
|
||||||
|
jsonEncode(
|
||||||
|
nodes
|
||||||
|
.map(
|
||||||
|
(node) => {
|
||||||
|
'type': node.type,
|
||||||
|
'name': node.name,
|
||||||
|
'public_key': node.publicKey,
|
||||||
|
'latitude': node.latitude,
|
||||||
|
'longitude': node.longitude,
|
||||||
|
'updated_at': node.updatedAtMs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
|
|||||||
File? _file;
|
File? _file;
|
||||||
|
|
||||||
Future<File> _resolveFile() async {
|
Future<File> _resolveFile() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('Packet capture file storage is not supported on web');
|
||||||
|
}
|
||||||
if (_file != null) return _file!;
|
if (_file != null) return _file!;
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final file = File('${dir.path}/$_fileName');
|
final file = File('${dir.path}/$_fileName');
|
||||||
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
|
|||||||
|
|
||||||
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||||
if (logs.isEmpty) return;
|
if (logs.isEmpty) return;
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
final sink = file.openWrite(mode: FileMode.append);
|
final sink = file.openWrite(mode: FileMode.append);
|
||||||
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||||
|
if (kIsWeb) return const [];
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return const [];
|
if (!await file.exists()) return const [];
|
||||||
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> count() async {
|
Future<int> count() async {
|
||||||
|
if (kIsWeb) return 0;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return 0;
|
if (!await file.exists()) return 0;
|
||||||
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class AvatarLabelHelper {
|
class AvatarLabelHelper {
|
||||||
|
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
|
||||||
|
|
||||||
static String buildLabel(String name) {
|
static String buildLabel(String name) {
|
||||||
final trimmed = name.trim();
|
final trimmed = name.trim();
|
||||||
if (trimmed.isEmpty) return '?';
|
if (trimmed.isEmpty) return '?';
|
||||||
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
|
|||||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
final parts = trimmed
|
final parts = _alnumChunks
|
||||||
.split(RegExp(r'[\s_-]+'))
|
.allMatches(trimmed)
|
||||||
|
.map((match) => match.group(0)!)
|
||||||
.where((part) => part.isNotEmpty)
|
.where((part) => part.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final first = _take(parts[0], 1);
|
final first = _take(parts[0], 1);
|
||||||
final second = _take(parts[1], 1);
|
final second = _take(parts[1], 1);
|
||||||
return '$first$second'.toUpperCase();
|
return '$first$second'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _take(trimmed, 2).toUpperCase();
|
return _take(parts.first, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _take(String value, int count) {
|
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 {
|
class DecodedLogRxRoute {
|
||||||
final int payloadType;
|
final int payloadType;
|
||||||
|
final int pathDescriptor;
|
||||||
final List<int> pathBytes;
|
final List<int> pathBytes;
|
||||||
final int hashSize;
|
final int hashSize;
|
||||||
|
|
||||||
const DecodedLogRxRoute({
|
const DecodedLogRxRoute({
|
||||||
required this.payloadType,
|
required this.payloadType,
|
||||||
|
required this.pathDescriptor,
|
||||||
required this.pathBytes,
|
required this.pathBytes,
|
||||||
required this.hashSize,
|
required this.hashSize,
|
||||||
});
|
});
|
||||||
@@ -63,21 +65,49 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rawPacketData.length <= index) return null;
|
if (rawPacketData.length <= index) return null;
|
||||||
final pathLen = rawPacketData[index++];
|
final pathDescriptor = rawPacketData[index++];
|
||||||
if (rawPacketData.length < index + pathLen) return null;
|
final pathMode = (pathDescriptor & 0xFF) >> 6;
|
||||||
final pathBytes = rawPacketData.sublist(index, index + pathLen);
|
final pathByteLen = pathMode == 0
|
||||||
final hashSize = inferHashSize(
|
? pathDescriptor
|
||||||
pathBytes,
|
: descriptorByteLength(pathDescriptor);
|
||||||
preferredHashSize: preferredHashSize,
|
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(
|
return DecodedLogRxRoute(
|
||||||
payloadType: payloadType,
|
payloadType: payloadType,
|
||||||
|
pathDescriptor: pathDescriptor,
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
hashSize: hashSize,
|
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}) {
|
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
|
||||||
if (pathBytes.isEmpty) return 1;
|
if (pathBytes.isEmpty) return 1;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
final foregroundColor = _getForegroundColor(backgroundColor);
|
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||||
final emoji = contact.roleEmoji;
|
final emoji = contact.roleEmoji;
|
||||||
|
|
||||||
if (emoji != null && emoji.isNotEmpty) {
|
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
|
||||||
return _buildAvatarFrame(
|
return _buildAvatarFrame(
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||||
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
bool get _usesSquareShape =>
|
bool get _usesSquareShape =>
|
||||||
contact.type == ContactType.channel || contact.type == ContactType.room;
|
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) {
|
Color _getBackgroundColor(BuildContext context) {
|
||||||
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
|
||||||
return TrailColorService.getTrailColor(contact);
|
return TrailColorService.getTrailColor(contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLocalIPs() async {
|
Future<void> _loadLocalIPs() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final Set<String> ipsSet = {};
|
final Set<String> ipsSet = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -9,11 +10,11 @@ import '../../providers/connection_provider.dart';
|
|||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/map_provider.dart';
|
import '../../providers/map_provider.dart';
|
||||||
import '../../providers/messages_provider.dart';
|
import '../../providers/messages_provider.dart';
|
||||||
|
import '../../providers/sensors_provider.dart';
|
||||||
import '../../services/message_destination_preferences.dart';
|
import '../../services/message_destination_preferences.dart';
|
||||||
import 'contact_route_dialog.dart';
|
import 'contact_route_dialog.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
import '../../utils/location_formats.dart';
|
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isChannel = contact.type == ContactType.channel;
|
||||||
final location = contact.displayLocation;
|
final location = contact.displayLocation;
|
||||||
// Calculate distance if both positions are available
|
// Calculate distance if both positions are available
|
||||||
String? distanceText;
|
String? distanceText;
|
||||||
@@ -114,33 +116,33 @@ class ContactTile extends StatelessWidget {
|
|||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
);
|
);
|
||||||
final subtitleWidget = Column(
|
final Widget? subtitleWidget = isChannel
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? null
|
||||||
children: [
|
: Column(
|
||||||
if (location != null) ...[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 2),
|
children: [
|
||||||
_buildLocationLine(
|
if (location != null) ...[
|
||||||
context,
|
const SizedBox(height: 2),
|
||||||
latitude: location.latitude,
|
_buildLocationLine(
|
||||||
longitude: location.longitude,
|
context,
|
||||||
distanceText: distanceText,
|
latitude: location.latitude,
|
||||||
),
|
longitude: location.longitude,
|
||||||
if (contact.type != ContactType.channel) ...[
|
distanceText: distanceText,
|
||||||
const SizedBox(height: 6),
|
),
|
||||||
Row(children: [_buildRoutePill(context, contact)]),
|
const SizedBox(height: 6),
|
||||||
],
|
Row(children: [_buildRoutePill(context, contact)]),
|
||||||
] else
|
] else
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context)!.noGpsData,
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -237,7 +239,8 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(timeAgoText, style: timeAgoStyle),
|
if (!isChannel)
|
||||||
|
Text(timeAgoText, style: timeAgoStyle),
|
||||||
if (isPingInProgress) ...[
|
if (isPingInProgress) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -251,7 +254,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitleWidget,
|
?subtitleWidget,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -264,11 +267,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handlePrimaryTap(BuildContext context, Contact contact) {
|
void _handlePrimaryTap(BuildContext context, Contact contact) {
|
||||||
if (contact.type == ContactType.repeater) {
|
|
||||||
_showContactDetails(context, contact);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_showContactActionSheet(context, contact);
|
_showContactActionSheet(context, contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +278,11 @@ class ContactTile extends StatelessWidget {
|
|||||||
contact.type == ContactType.channel;
|
contact.type == ContactType.channel;
|
||||||
final canSetPath =
|
final canSetPath =
|
||||||
contact.type == ContactType.chat || contact.type == ContactType.room;
|
contact.type == ContactType.chat || contact.type == ContactType.room;
|
||||||
|
final canAddToSensors =
|
||||||
|
contact.type == ContactType.chat ||
|
||||||
|
contact.type == ContactType.repeater;
|
||||||
|
final sensorsProvider = context.read<SensorsProvider>();
|
||||||
|
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -301,6 +304,46 @@ class ContactTile extends StatelessWidget {
|
|||||||
await _openMessagesForContact(context, contact);
|
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)
|
if (canSetPath)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.alt_route),
|
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(
|
Future<void> _openMessagesForContact(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact,
|
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(
|
Future<void> _showSetRouteDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact,
|
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(
|
Widget _buildLocationMeta(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
double latitude,
|
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
|
/// Get room login status color
|
||||||
Color _getRoomStatusColor(RoomLoginState state) {
|
Color _getRoomStatusColor(RoomLoginState state) {
|
||||||
if (!state.isLoggedIn) {
|
if (!state.isLoggedIn) {
|
||||||
@@ -1215,41 +655,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
return Icons.check; // Check for logged in (non-admin)
|
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
|
/// Show delete channel confirmation dialog
|
||||||
void _showDeleteChannelDialog(
|
void _showDeleteChannelDialog(
|
||||||
BuildContext context,
|
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) {
|
if (sender.routeHopCount >= 2) {
|
||||||
_showToast(
|
_showToast(
|
||||||
'Image fetch over ${sender.routeHopCount} hops may take a while.',
|
'Image fetch over ${sender.routeHopCount} hops may take a while.',
|
||||||
@@ -462,7 +453,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
);
|
);
|
||||||
await conn.sendRawVoicePacket(
|
await conn.sendRawVoicePacket(
|
||||||
contactPath: sender.outPath,
|
contactPath: sender.outPath,
|
||||||
contactPathLen: sender.routeSignedPathLen,
|
contactPathLen: sender.routeEncodedPathLen,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
|
|||||||
import '../../utils/location_formats.dart';
|
import '../../utils/location_formats.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
|
import '../../utils/log_rx_route_decoder.dart';
|
||||||
import '../../models/message_transfer_details.dart';
|
import '../../models/message_transfer_details.dart';
|
||||||
import 'voice_message_bubble.dart';
|
import 'voice_message_bubble.dart';
|
||||||
import 'image_message_bubble.dart';
|
import 'image_message_bubble.dart';
|
||||||
@@ -1269,12 +1270,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
|
|
||||||
// Logged frame format:
|
// Logged frame format:
|
||||||
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
|
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
|
||||||
final raw = log.rawData;
|
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||||
final payloadType = (raw[3] >> 2) & 0x0F;
|
if (decoded == null) continue;
|
||||||
final pathLen = raw[4];
|
if (decoded.payloadType != expectedPayloadType) continue;
|
||||||
if (payloadType != expectedPayloadType) continue;
|
if (decoded.hopCount != message.pathLen) continue;
|
||||||
if (pathLen != message.pathLen) continue;
|
|
||||||
if (raw.length < 5 + pathLen) continue;
|
|
||||||
|
|
||||||
final deltaMs =
|
final deltaMs =
|
||||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||||
@@ -1290,11 +1289,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
|
|
||||||
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
|
||||||
if (log == null) return null;
|
if (log == null) return null;
|
||||||
final raw = log.rawData;
|
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||||
if (raw.length < 6) return null;
|
if (decoded == null || decoded.pathBytes.isEmpty) return null;
|
||||||
final pathLen = raw[4];
|
return decoded.pathBytes;
|
||||||
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
|
|
||||||
return raw.sublist(5, 5 + pathLen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showDeleteConfirmation(BuildContext context) {
|
void _showDeleteConfirmation(BuildContext context) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ignore_for_file: use_null_aware_elements
|
// ignore_for_file: use_null_aware_elements
|
||||||
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -60,7 +61,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
return trace;
|
return trace;
|
||||||
}
|
}
|
||||||
|
|
||||||
final remoteNodes = await MeshMapNodesService.fetchNodes(
|
unawaited(
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
|
||||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
);
|
);
|
||||||
trace = _buildTraceResult(
|
trace = _buildTraceResult(
|
||||||
@@ -516,13 +523,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
for (final log in logs) {
|
for (final log in logs) {
|
||||||
if (log.responseCode != 0x88) continue; // pushLogRxData
|
if (log.responseCode != 0x88) continue; // pushLogRxData
|
||||||
if (log.rawData.length < 6) continue;
|
if (log.rawData.length < 6) continue;
|
||||||
final raw = log.rawData;
|
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||||
final header = raw[3];
|
if (decoded == null) continue;
|
||||||
final payloadType = (header >> 2) & 0x0F;
|
if (decoded.payloadType != expectedPayloadType) continue;
|
||||||
final pathLen = raw[4];
|
if (decoded.hopCount != message.pathLen) continue;
|
||||||
if (payloadType != expectedPayloadType) continue;
|
|
||||||
if (pathLen != message.pathLen) continue;
|
|
||||||
if (raw.length < 5 + pathLen) continue;
|
|
||||||
|
|
||||||
final deltaMs =
|
final deltaMs =
|
||||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||||
@@ -533,9 +537,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (bestLog == null || bestDeltaMs > 30000) return null;
|
if (bestLog == null || bestDeltaMs > 30000) return null;
|
||||||
final raw = bestLog.rawData;
|
final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
|
||||||
final pathLen = raw[4];
|
if (decoded == null || decoded.pathBytes.isEmpty) {
|
||||||
return raw.sublist(5, 5 + pathLen);
|
return null;
|
||||||
|
}
|
||||||
|
return decoded.pathBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MeshMapNode?> _matchNodesFromPathHashes({
|
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) {
|
if (sender.routeHopCount >= 2) {
|
||||||
_showToast(
|
_showToast(
|
||||||
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
|
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
|
||||||
@@ -415,10 +406,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
missingIndices: missing,
|
missingIndices: missing,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
)
|
)
|
||||||
: VoiceFetchRequest(
|
: VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6);
|
||||||
sessionId: sessionId,
|
|
||||||
requesterKey6: requesterKey6,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -426,7 +414,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
);
|
);
|
||||||
await connectionProvider.sendRawVoicePacket(
|
await connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: sender.outPath,
|
contactPath: sender.outPath,
|
||||||
contactPathLen: sender.routeSignedPathLen,
|
contactPathLen: sender.routeEncodedPathLen,
|
||||||
payload: request.encodeBinary(),
|
payload: request.encodeBinary(),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9
|
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
@@ -1265,10 +1265,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_android
|
name: sqflite_android
|
||||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2+2"
|
version: "2.4.2+3"
|
||||||
sqflite_common:
|
sqflite_common:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ void main() {
|
|||||||
expect(contact.routeHashSize, 3);
|
expect(contact.routeHashSize, 3);
|
||||||
expect(contact.routeHopCount, 2);
|
expect(contact.routeHopCount, 2);
|
||||||
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
|
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
|
||||||
expect(contact.routeSupportsLegacyRawTransport, isFalse);
|
expect(contact.routeSupportsLegacyRawTransport, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('treats -1 as unknown route', () {
|
test('treats -1 as unknown route', () {
|
||||||
|
|||||||
@@ -47,6 +47,63 @@ void main() {
|
|||||||
expect(ok, isFalse);
|
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 {
|
test('sends only requested indices', () async {
|
||||||
final sent = <Uint8List>[];
|
final sent = <Uint8List>[];
|
||||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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.dart';
|
||||||
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
@@ -11,6 +12,30 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
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', () {
|
group('MessagesProvider voice detection', () {
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
@@ -190,5 +215,60 @@ void main() {
|
|||||||
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
|
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
|
||||||
expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
|
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', () {
|
test('keeps non-hash labels at two characters', () {
|
||||||
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
|
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, isNotNull);
|
||||||
expect(decoded!.payloadType, 0x01);
|
expect(decoded!.payloadType, 0x01);
|
||||||
|
expect(decoded.pathDescriptor, 0x04);
|
||||||
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
|
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
|
||||||
expect(decoded.hashSize, 2);
|
expect(decoded.hashSize, 2);
|
||||||
expect(decoded.hopHashes, ['c2ba', '5fde']);
|
expect(decoded.hopHashes, ['c2ba', '5fde']);
|
||||||
expect(decoded.originalSenderHashHex, 'c2ba');
|
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', () {
|
test('uses preferred hash size when packet length is ambiguous', () {
|
||||||
final packet = Uint8List.fromList([
|
final packet = Uint8List.fromList([
|
||||||
0x88,
|
0x88,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ void main() {
|
|||||||
contact.publicKeyHex: 3,
|
contact.publicKeyHex: 3,
|
||||||
},
|
},
|
||||||
currentDestinationType: 'all',
|
currentDestinationType: 'all',
|
||||||
onSelect: (_, __) {},
|
onSelect: (selectedContact, destinationType) {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user