Compare commits

..

49 Commits

Author SHA1 Message Date
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
Janez T
d113f7a881 Match sensors bg and hide counts 2026-03-08 21:03:44 +01:00
Janez T
2ab8681ac4 Add multilingual localization 2026-03-08 20:50:53 +01:00
Janez T
de5fb1e164 Translate missing localization 2026-03-08 20:46:09 +01:00
Janez T
dae7e67a8a Translate missing strings 2026-03-08 20:42:21 +01:00
Janez T
d51924021f Update German localization 2026-03-08 20:38:41 +01:00
Janez T
0f91292fd7 Update messaging options and UI 2026-03-08 20:34:38 +01:00
Janez T
b471ab0990 Add message font scale setting 2026-03-08 20:29:55 +01:00
Janez T
771cddb80d Display unread counts per channel 2026-03-08 20:22:10 +01:00
Janez T
bfd15065f0 Add message filters and counts 2026-03-08 20:15:48 +01:00
Janez T
f72db76538 Add reply shortcut for messages 2026-03-08 20:10:51 +01:00
Janez T
a96f96ca4c Add nearestrelay fallback settings 2026-03-08 20:05:24 +01:00
Janez T
9f62055eef Enable route hash settings 2026-03-08 17:00:54 +01:00
Janez T
6b2f18130c Add nearest relay fallback toggle 2026-03-08 16:56:57 +01:00
Janez T
922b40b755 Implement meshcore route rotation 2026-03-08 16:46:46 +01:00
Janez T
17d7c43745 Implement meshcore-open route reload 2026-03-08 14:26:05 +01:00
Janez T
e026c1dbbd Fix invalid simulator slice upload 2026-03-08 09:42:07 +01:00
Janez T
9ff24c1fcb Fix invalid objective_c framework 2026-03-08 09:40:10 +01:00
Janez Troha
948a22c4d2 Merge pull request #16 from MGJ520/main
Update Chinese translation
2026-03-08 09:33:25 +01:00
MGJ
aa0777d4a3 Update Chinese translation 2026-03-08 12:39:57 +08:00
102 changed files with 29383 additions and 22407 deletions

View File

@@ -280,7 +280,7 @@ jobs:
run: flutter pub get
- name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/
run: flutter build web --release --base-href /meshcore-sar/
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3

View File

@@ -16,7 +16,7 @@
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
## Highlights

View File

@@ -61,6 +61,9 @@ PODS:
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- record_ios (1.2.0):
@@ -97,6 +100,7 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -141,6 +145,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/nsd_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios:
@@ -173,6 +179,7 @@ SPEC CHECKSUMS:
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 105;
CURRENT_PROJECT_VERSION = 111;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 105;
CURRENT_PROJECT_VERSION = 111;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 105;
CURRENT_PROJECT_VERSION = 111;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 105;
CURRENT_PROJECT_VERSION = 111;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 105;
CURRENT_PROJECT_VERSION = 111;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 105;
CURRENT_PROJECT_VERSION = 111;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -1,5 +1,6 @@
import UIKit
import Flutter
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,7 @@ import Flutter
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>105</string>
<string>111</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -102,6 +102,7 @@
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>UILaunchStoryboardName</key>

View File

@@ -5,17 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001342">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.40706">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.924905">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="70.83361">
<testcase classname="fastlane.lanes" name="2: build_app" time="113.366384">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="755.542625">
</testcase>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_pl.arb Normal file

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_pt.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_tr.arb Normal file

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_uk.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
import 'dart:io' show Platform;
import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
@@ -19,6 +22,7 @@ import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart';
import 'screens/home_screen.dart';
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available
_checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() {
_wizardCompleted = wizardCompleted;
_isInitialized = true;
@@ -158,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available
Future<void> _checkForUpdates() async {
// Only check for updates on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)');
return;
}
@@ -324,7 +331,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea(
left: false,
right: false,

View File

@@ -232,7 +232,7 @@ extension ContactLocalization on Contact {
String get routeSummary {
if (routeIsUnknown) {
return 'Unknown';
return 'Flood/Unknown';
}
if (!routeHasPath || routeHopCount == 0) {
return 'Direct';
@@ -240,6 +240,5 @@ extension ContactLocalization on Contact {
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport =>
routeHasPath && routeSignedPathLen >= 0;
bool get routeSupportsLegacyRawTransport => routeHasPath;
}

View File

@@ -0,0 +1,79 @@
import 'path_selection.dart';
class MessageRouteMetadata {
final PathSelectionMode mode;
final bool routerFallbackAttempted;
final String? relayName;
final String? relayKey6;
final String? canonicalPath;
final int? hopCount;
const MessageRouteMetadata({
required this.mode,
required this.routerFallbackAttempted,
this.relayName,
this.relayKey6,
this.canonicalPath,
this.hopCount,
});
factory MessageRouteMetadata.fromSelection(
PathSelection selection, {
required bool routerFallbackAttempted,
}) {
return MessageRouteMetadata(
mode: selection.mode,
routerFallbackAttempted: routerFallbackAttempted,
relayName: selection.relayName,
relayKey6: selection.relayKey6,
canonicalPath: selection.canonicalPath.isEmpty
? null
: selection.canonicalPath,
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
);
}
String get modeLabel {
switch (mode) {
case PathSelectionMode.directCurrent:
return 'Current direct path';
case PathSelectionMode.directHistorical:
return 'Rotated direct path';
case PathSelectionMode.flood:
return 'Flood route';
case PathSelectionMode.nearestRouter:
final suffix = relayName?.trim().isNotEmpty == true
? ' via $relayName'
: relayKey6?.trim().isNotEmpty == true
? ' via $relayKey6'
: '';
return 'Nearest router$suffix';
}
}
Map<String, dynamic> toJson() {
return {
'mode': mode.name,
'router_fallback_attempted': routerFallbackAttempted,
'relay_name': relayName,
'relay_key6': relayKey6,
'canonical_path': canonicalPath,
'hop_count': hopCount,
};
}
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
return MessageRouteMetadata(
mode: PathSelectionMode.values.firstWhere(
(value) => value.name == json['mode'],
orElse: () => PathSelectionMode.directCurrent,
),
routerFallbackAttempted:
json['router_fallback_attempted'] as bool? ?? false,
relayName: json['relay_name'] as String?,
relayKey6: json['relay_key6'] as String?,
canonicalPath: json['canonical_path'] as String?,
hopCount: json['hop_count'] as int?,
);
}
}

View File

@@ -0,0 +1,182 @@
class PathRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime lastUsedAt;
const PathRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
});
String get signature =>
pathBytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
double get successRate =>
(successCount + 1) / (successCount + failureCount + 2);
PathRecord copyWith({
List<int>? pathBytes,
int? hopCount,
int? hashSize,
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
}) {
return PathRecord(
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
}
Map<String, dynamic> toJson() {
return {
'path_bytes': pathBytes,
'hop_count': hopCount,
'hash_size': hashSize,
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(),
};
}
factory PathRecord.fromJson(Map<String, dynamic> json) {
return PathRecord(
pathBytes: (json['path_bytes'] as List<dynamic>? ?? const <dynamic>[])
.map((value) => value as int)
.toList(),
hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1,
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
lastUsedAt:
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
class FloodPathStats {
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime? lastUsedAt;
const FloodPathStats({
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
});
const FloodPathStats.empty()
: successCount = 0,
failureCount = 0,
lastRoundTripTimeMs = 0,
lastUsedAt = null;
FloodPathStats copyWith({
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
}) {
return FloodPathStats(
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
}
Map<String, dynamic> toJson() {
return {
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt?.toIso8601String(),
};
}
factory FloodPathStats.fromJson(Map<String, dynamic> json) {
return FloodPathStats(
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
lastUsedAt: DateTime.tryParse(json['last_used_at'] as String? ?? ''),
);
}
}
class ContactPathHistory {
final String contactPublicKeyHex;
final List<PathRecord> directPaths;
final FloodPathStats floodStats;
final int rotationIndex;
const ContactPathHistory({
required this.contactPublicKeyHex,
required this.directPaths,
required this.floodStats,
required this.rotationIndex,
});
const ContactPathHistory.empty(this.contactPublicKeyHex)
: directPaths = const <PathRecord>[],
floodStats = const FloodPathStats.empty(),
rotationIndex = 0;
ContactPathHistory copyWith({
List<PathRecord>? directPaths,
FloodPathStats? floodStats,
int? rotationIndex,
}) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: directPaths ?? this.directPaths,
floodStats: floodStats ?? this.floodStats,
rotationIndex: rotationIndex ?? this.rotationIndex,
);
}
Map<String, dynamic> toJson() {
return {
'direct_paths': directPaths.map((record) => record.toJson()).toList(),
'flood_stats': floodStats.toJson(),
'rotation_index': rotationIndex,
};
}
factory ContactPathHistory.fromJson(
String contactPublicKeyHex,
Map<String, dynamic> json,
) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: (json['direct_paths'] as List<dynamic>? ?? const <dynamic>[])
.whereType<Map<String, dynamic>>()
.map(PathRecord.fromJson)
.toList(),
floodStats: json['flood_stats'] is Map<String, dynamic>
? FloodPathStats.fromJson(json['flood_stats'] as Map<String, dynamic>)
: const FloodPathStats.empty(),
rotationIndex: json['rotation_index'] as int? ?? 0,
);
}
}

View File

@@ -0,0 +1,65 @@
import 'dart:typed_data';
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
class PathSelection {
final PathSelectionMode mode;
final Uint8List pathBytes;
final int hopCount;
final int hashSize;
final String? relayName;
final String? relayKey6;
const PathSelection({
required this.mode,
required this.pathBytes,
required this.hopCount,
required this.hashSize,
this.relayName,
this.relayKey6,
});
PathSelection.flood()
: mode = PathSelectionMode.flood,
pathBytes = Uint8List(0),
hopCount = -1,
hashSize = 1,
relayName = null,
relayKey6 = null;
bool get usesFlood => mode == PathSelectionMode.flood;
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
String get canonicalPath {
if (!hasDirectPath) return '';
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
PathSelection copyWith({
PathSelectionMode? mode,
Uint8List? pathBytes,
int? hopCount,
int? hashSize,
String? relayName,
String? relayKey6,
}) {
return PathSelection(
mode: mode ?? this.mode,
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
relayName: relayName ?? this.relayName,
relayKey6: relayKey6 ?? this.relayKey6,
);
}
}

View File

@@ -12,10 +12,15 @@ import 'image_provider.dart' as ip;
import 'helpers/fragment_ack_wait_registry.dart';
import 'helpers/session_metadata_restore.dart';
import '../services/location_tracking_service.dart';
import '../services/messaging_route_preferences.dart';
import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart';
import '../services/path_history_service.dart';
import '../services/route_hash_preferences.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import '../models/path_selection.dart';
import '../models/message_reception_details.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart';
@@ -24,6 +29,32 @@ import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/log_rx_route_decoder.dart';
class _DirectMessageRouteSession {
final PathSelection currentSelection;
final ParsedContactRoute? originalRoute;
final bool routerFallbackAttempted;
const _DirectMessageRouteSession({
required this.currentSelection,
required this.originalRoute,
required this.routerFallbackAttempted,
});
_DirectMessageRouteSession copyWith({
PathSelection? currentSelection,
ParsedContactRoute? originalRoute,
bool? routerFallbackAttempted,
}) {
return _DirectMessageRouteSession(
currentSelection: currentSelection ?? this.currentSelection,
originalRoute: originalRoute ?? this.originalRoute,
routerFallbackAttempted:
routerFallbackAttempted ?? this.routerFallbackAttempted,
);
}
}
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
@@ -60,8 +91,24 @@ class AppProvider with ChangeNotifier {
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false;
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
bool _clearPathOnMaxRetry =
MessagingRoutePreferences.defaultClearPathOnMaxRetry;
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
bool _nearestRelayFallbackEnabled =
MessagingRoutePreferences.defaultNearestRelayFallbackEnabled;
bool get nearestRelayFallbackEnabled => _nearestRelayFallbackEnabled;
final PathHistoryService _pathHistoryService = PathHistoryService();
final NearestRouterSelector _nearestRouterSelector =
const NearestRouterSelector();
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
{};
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
@@ -81,6 +128,9 @@ class AppProvider with ChangeNotifier {
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false;
AppProvider({
required this.connectionProvider,
@@ -92,6 +142,7 @@ class AppProvider with ChangeNotifier {
required this.imageProvider,
}) {
_setupCallbacks();
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking();
_loadMapEnabled();
_loadContactsEnabled();
@@ -100,7 +151,10 @@ class AppProvider with ChangeNotifier {
_loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled();
_loadMessageFontScale();
_loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings();
unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true;
@@ -385,6 +439,30 @@ class AppProvider with ChangeNotifier {
}
}
Future<void> _loadMessageFontScale() async {
try {
final prefs = await SharedPreferences.getInstance();
_messageFontScale = (prefs.getDouble('message_font_scale') ?? 1.0).clamp(
0.85,
1.4,
);
notifyListeners();
} catch (e) {
debugPrint('Error loading message font scale setting: $e');
}
}
Future<void> setMessageFontScale(double scale) async {
try {
_messageFontScale = scale.clamp(0.85, 1.4);
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('message_font_scale', _messageFontScale);
notifyListeners();
} catch (e) {
debugPrint('Error saving message font scale setting: $e');
}
}
/// Load auto-add discovered contacts setting from shared preferences.
Future<void> _loadAutoAddDiscoveredContacts() async {
try {
@@ -409,6 +487,50 @@ class AppProvider with ChangeNotifier {
}
}
Future<void> _loadMessagingRouteSettings() async {
try {
_autoRouteRotationEnabled =
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
_clearPathOnMaxRetry =
await MessagingRoutePreferences.getClearPathOnMaxRetry();
_nearestRelayFallbackEnabled =
await MessagingRoutePreferences.getNearestRelayFallbackEnabled();
notifyListeners();
} catch (e) {
debugPrint('Error loading messaging route settings: $e');
}
}
Future<void> toggleAutoRouteRotationEnabled(bool enabled) async {
try {
_autoRouteRotationEnabled = enabled;
await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto route rotation setting: $e');
}
}
Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
try {
_clearPathOnMaxRetry = enabled;
await MessagingRoutePreferences.setClearPathOnMaxRetry(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving clear path on max retry setting: $e');
}
}
Future<void> toggleNearestRelayFallbackEnabled(bool enabled) async {
try {
_nearestRelayFallbackEnabled = enabled;
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving nearest relay fallback setting: $e');
}
}
/// Initialize location tracking service
Future<void> _initializeLocationTracking() async {
try {
@@ -452,8 +574,20 @@ class AppProvider with ChangeNotifier {
connectionProvider.addListener(_handleConnectionStateChange);
messagesProvider.resolveContactNameCallback =
_resolveContactNameForNotification;
messagesProvider.resolveChannelNameCallback =
channelsProvider.getChannelDisplayName;
messagesProvider.resolveChannelNameCallback = (channelIdx) {
if (channelIdx == 0) {
return 'Public';
}
for (final channel in contactsProvider.channels) {
if (channel.publicKey.length > 1 &&
channel.publicKey[1] == channelIdx) {
return channel.displayName;
}
}
return channelsProvider.getChannelDisplayName(channelIdx);
};
voiceProvider.sendRawPacketCallback =
({
@@ -488,6 +622,7 @@ class AppProvider with ChangeNotifier {
contact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
unawaited(_pathHistoryService.recordLearnedPath(contact));
// Broadcast to SSE clients if server is running
connectionProvider.broadcastContactToSseClients(contact);
@@ -500,6 +635,9 @@ class AppProvider with ChangeNotifier {
contacts,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
for (final contact in contacts) {
unawaited(_pathHistoryService.recordLearnedPath(contact));
}
debugPrint('Received ${contacts.length} contacts');
// Broadcast all contacts to SSE clients if server is running
@@ -634,6 +772,19 @@ class AppProvider with ChangeNotifier {
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
enrichedMessage,
);
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null &&
enrichedMessage.isChannelMessage &&
(enrichedMessage.channelIdx ?? 0) == 0 &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
unawaited(
_learnPathFromPublicMessage(
contact: senderContact,
pathBytes: receivedPathBytes,
),
);
}
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -1117,6 +1268,14 @@ class AppProvider with ChangeNotifier {
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
};
connectionProvider.prepareDirectMessageSendCallback =
({required messageId, required contact, required retryAttempt}) async {
return _prepareDirectMessageSend(
messageId: messageId,
contact: contact,
);
};
// Wire up MessagesProvider's sendMessageCallback for retry logic
messagesProvider.sendMessageCallback =
({
@@ -1134,32 +1293,310 @@ class AppProvider with ChangeNotifier {
retryAttempt: retryAttempt,
);
};
messagesProvider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
debugPrint(
'🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)',
messagesProvider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async {
return _sendWithFinalNearestRouterFallback(
messageId: messageId,
contact: contact,
message: message,
);
contactsProvider.markPathUnhealthy(contact.publicKey);
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
try {
await connectionProvider.resetPath(contact.publicKey);
Future.delayed(const Duration(milliseconds: 150), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(contact.publicKey);
}
});
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e',
);
}
};
messagesProvider.onFinalDirectMessageFailureCallback =
({required messageId, required contact, required message}) async {
await _handleDirectMessageFinalFailure(
messageId: messageId,
contact: contact,
);
};
messagesProvider.onDirectMessageDeliveredCallback =
({
required messageId,
required contact,
required message,
required roundTripTimeMs,
}) {
_handleDirectMessageDelivered(
messageId: messageId,
contact: contact,
roundTripTimeMs: roundTripTimeMs,
);
};
}
Future<Contact> _prepareDirectMessageSend({
required String messageId,
required Contact contact,
}) async {
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId];
if (session == null) {
final selection = await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession(
currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false,
);
_directMessageRouteSessions[messageId] = session;
}
await _applyPathSelection(
latestContact,
session.currentSelection,
messageId: messageId,
routerFallbackAttempted: session.routerFallbackAttempted,
);
return contactsProvider.findContactByKey(contact.publicKey) ??
latestContact;
}
Future<void> _applyPathSelection(
Contact contact,
PathSelection selection, {
required String messageId,
required bool routerFallbackAttempted,
}) async {
final previousRoute = ContactRouteCodec.fromContact(contact);
try {
if (selection.usesFlood) {
contactsProvider.resetContactRouteLocal(contact.publicKey);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.resetPath(contact.publicKey);
}
} else {
final pathDescriptor =
((selection.hashSize - 1) << 6) | (selection.hopCount & 0x3F);
final signedDescriptor = ContactRouteCodec.toSignedDescriptor(
pathDescriptor,
);
final paddedPathBytes = Uint8List(ContactRouteCodec.maxPathBytes)
..setRange(0, selection.pathBytes.length, selection.pathBytes);
contactsProvider.setContactRouteLocal(
contact.publicKey,
signedEncodedPathLen: signedDescriptor,
paddedPathBytes: paddedPathBytes,
);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.setContactRoute(
contact,
signedEncodedPathLen: signedDescriptor,
paddedPathBytes: paddedPathBytes,
);
}
}
} catch (error) {
_restoreRouteLocal(contact.publicKey, previousRoute);
rethrow;
}
messagesProvider.updateMessageRouteSelection(
messageId,
selection,
routerFallbackAttempted: routerFallbackAttempted,
);
}
void _restoreRouteLocal(Uint8List publicKey, ParsedContactRoute? route) {
if (route == null) {
contactsProvider.resetContactRouteLocal(publicKey);
return;
}
contactsProvider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
}
Future<void> _restoreRouteOnDevice(
Contact contact,
ParsedContactRoute? route,
) async {
_restoreRouteLocal(contact.publicKey, route);
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
if (route == null) {
await connectionProvider.resetPath(contact.publicKey);
return;
}
await connectionProvider.setContactRoute(
contact,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
}
PathSelection _buildNearestRouterSelection(Contact repeater, int hashSize) {
return PathSelection(
mode: PathSelectionMode.nearestRouter,
pathBytes: Uint8List.fromList(repeater.publicKey.sublist(0, hashSize)),
hopCount: 1,
hashSize: hashSize,
relayName: repeater.advName,
relayKey6: _key6(repeater.publicKey),
);
}
Future<bool> _sendWithFinalNearestRouterFallback({
required String messageId,
required Contact contact,
required Message message,
}) async {
if (!_nearestRelayFallbackEnabled) {
return false;
}
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session =
_directMessageRouteSessions[messageId] ??
_DirectMessageRouteSession(
currentSelection: latestContact.routeHasPath
? PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: PathSelection.flood(),
originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false,
);
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
final repeater = _nearestRouterSelector.select(
senderPosition: locationTrackingService.currentPosition,
repeaters: contactsProvider.repeaters,
recipient: latestContact,
);
if (repeater == null) {
return false;
}
final routeHashSize = await RouteHashPreferences.getHashSize();
final fallbackSelection = _buildNearestRouterSelection(
repeater,
routeHashSize,
);
_directMessageRouteSessions[messageId] = session.copyWith(
currentSelection: fallbackSelection,
routerFallbackAttempted: true,
);
messagesProvider.updateMessageRouteSelection(
messageId,
fallbackSelection,
routerFallbackAttempted: true,
);
return connectionProvider.sendTextMessage(
contactPublicKey: latestContact.publicKey,
text: message.text,
messageId: messageId,
contact: latestContact,
retryAttempt: message.retryAttempt + 1,
);
}
void _handleDirectMessageDelivered({
required String messageId,
required Contact contact,
required int roundTripTimeMs,
}) {
final session = _directMessageRouteSessions.remove(messageId);
if (session == null) {
return;
}
unawaited(
_pathHistoryService.recordPathResult(
contact.publicKeyHex,
session.currentSelection,
success: true,
roundTripTimeMs: roundTripTimeMs,
),
);
}
Future<void> _handleDirectMessageFinalFailure({
required String messageId,
required Contact contact,
}) async {
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session = _directMessageRouteSessions.remove(messageId);
if (session != null) {
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
if (session.routerFallbackAttempted) {
await _restoreRouteOnDevice(latestContact, session.originalRoute);
}
}
if (_clearPathOnMaxRetry) {
contactsProvider.resetContactRouteLocal(latestContact.publicKey);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.resetPath(latestContact.publicKey);
Future.delayed(const Duration(milliseconds: 150), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(latestContact.publicKey);
}
});
}
}
}
String _key6(Uint8List publicKey) {
final bytes = publicKey.sublist(0, math.min(6, publicKey.length));
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
int _inferReceivedPathHashSize(
List<int> pathBytes, {
required int preferredHashSize,
}) {
final preferred = preferredHashSize;
final candidates = {preferred, 3, 2, 1}.toList();
for (final candidate in candidates) {
if (candidate >= 1 &&
candidate <= 3 &&
pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
/// Initialize the app (load contacts, sync time, etc.)
@@ -1238,12 +1675,42 @@ class AppProvider with ChangeNotifier {
);
messagesProvider.syncDrawingsWithProvider(drawingProvider);
_hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
notifyListeners();
} catch (e) {
debugPrint('Initialization error: $e');
}
}
Future<void> _syncAfterReconnect() async {
if (_isReconnectSyncInProgress ||
!connectionProvider.deviceInfo.isConnected) {
return;
}
_isReconnectSyncInProgress = true;
try {
debugPrint(
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
);
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await connectionProvider.getContacts();
final messageCount = await connectionProvider.syncAllMessages();
debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
);
} catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally {
_isReconnectSyncInProgress = false;
}
}
/// Automatically login to all rooms with saved passwords on cold connect
Future<void> _autoLoginToRooms() async {
if (!connectionProvider.deviceInfo.isConnected) return;
@@ -1553,14 +2020,13 @@ class AppProvider with ChangeNotifier {
if (requester == null ||
!requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops ||
!requester.routeSupportsLegacyRawTransport ||
requester.outPath.isEmpty) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: availability.encodeBinary(),
),
);
@@ -1630,7 +2096,7 @@ class AppProvider with ChangeNotifier {
for (final peer in peers) {
await connectionProvider.sendRawVoicePacket(
contactPath: peer.outPath,
contactPathLen: peer.routeSignedPathLen,
contactPathLen: peer.routeEncodedPathLen,
payload: request.encodeBinary(),
);
}
@@ -1653,7 +2119,6 @@ class AppProvider with ChangeNotifier {
if (responder == null ||
!responder.routeHasPath ||
responder.routeHopCount > _maxDirectPayloadHops ||
!responder.routeSupportsLegacyRawTransport ||
responder.outPath.isEmpty) {
continue;
}
@@ -1745,7 +2210,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen,
contactPathLen: target.routeEncodedPathLen,
payload: payload,
);
return true;
@@ -1986,9 +2451,6 @@ class AppProvider with ChangeNotifier {
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
return false;
}
if (!target.routeSupportsLegacyRawTransport) {
return false;
}
if (target.outPath.isEmpty) {
return false;
}
@@ -2021,7 +2483,7 @@ class AppProvider with ChangeNotifier {
);
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen,
contactPathLen: target.routeEncodedPathLen,
payload: RawRouteProbeRequest(
nonce: nonce,
requesterKey6: requesterKey6,
@@ -2049,7 +2511,7 @@ class AppProvider with ChangeNotifier {
if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}';
}
return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
}
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
@@ -2067,9 +2529,6 @@ class AppProvider with ChangeNotifier {
);
return;
}
if (!requester.routeSupportsLegacyRawTransport) {
return;
}
if (requester.outPath.isEmpty) {
return;
}
@@ -2079,7 +2538,7 @@ class AppProvider with ChangeNotifier {
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
),
);
@@ -2153,12 +2612,10 @@ class AppProvider with ChangeNotifier {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final raw = log.rawData;
final payloadType = (raw[3] >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -2174,11 +2631,9 @@ class AppProvider with ChangeNotifier {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final raw = log.rawData;
if (raw.length < 6) return null;
final pathLen = raw[4];
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) return null;
return decoded.pathBytes;
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
@@ -2228,6 +2683,8 @@ class AppProvider with ChangeNotifier {
/// Handle connection state changes to manage location tracking
void _handleConnectionStateChange() {
final isConnected = connectionProvider.deviceInfo.isConnected;
final wasConnected = _wasDeviceConnected;
_wasDeviceConnected = isConnected;
final wasTracking = locationTrackingService.isTracking;
// Only stop tracking on disconnect - DON'T start on connect
@@ -2239,6 +2696,10 @@ class AppProvider with ChangeNotifier {
);
_stopLocationTracking();
}
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
unawaited(_syncAfterReconnect());
}
}
/// Start location tracking

View File

@@ -175,6 +175,12 @@ class ConnectionProvider with ChangeNotifier {
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
onMessageSent;
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Future<Contact?> Function({
required String messageId,
required Contact contact,
required int retryAttempt,
})?
prepareDirectMessageSendCallback;
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
@@ -480,6 +486,14 @@ class ConnectionProvider with ChangeNotifier {
/// Start scanning for MeshCore devices
Future<void> startScan() async {
debugPrint('🔍 [Provider] startScan() called');
if (_deviceInfo.connectionState == ConnectionState.connecting ||
_deviceInfo.connectionState == ConnectionState.connected) {
debugPrint(
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
);
return;
}
_isScanning = true;
_scannedDevices.clear();
_error = null;
@@ -553,6 +567,11 @@ class ConnectionProvider with ChangeNotifier {
'🔵 [Provider] connect() called for device: ${device.platformName}',
);
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
_deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty
@@ -829,10 +848,26 @@ class ConnectionProvider with ChangeNotifier {
return true;
} catch (e) {
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
if (_isEmptyChannelQueryError(e)) {
debugPrint(
' Treating slot $channelIdx as empty because the device reported it was not found',
);
return true;
}
return false;
}
}
bool _isEmptyChannelQueryError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('not found');
}
bool _isChannelRefreshTimeoutError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('0x1f') && message.contains('timed out');
}
Future<int?> findNextEmptyChannelSlot() async {
if (!_activeService.isConnected) {
throw Exception('Not connected to device');
@@ -843,6 +878,35 @@ class ConnectionProvider with ChangeNotifier {
// maxChannels from device info, or default to 40
final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
// Match meshcore-open: trust the current synced channel list first and
// pick the first missing slot rather than probing every slot on-device.
if (getChannelInfo != null) {
final usedIndices = <int>{};
for (int i = 1; i < maxChannels; i++) {
final channel = getChannelInfo!(i);
if (channel == null) {
continue;
}
final channelName = (channel as dynamic).name as String?;
if (channelName != null && channelName.isNotEmpty) {
usedIndices.add(i);
}
}
for (int i = 1; i < maxChannels; i++) {
if (!usedIndices.contains(i)) {
debugPrint(' ✅ Found empty slot from synced channels: $i');
return i;
}
}
debugPrint(
' ⚠️ Synced channels report all custom slots occupied ($maxCustomChannels total)',
);
}
// Check each slot starting from 1 (skip 0 = public channel)
for (int i = 1; i < maxChannels; i++) {
@@ -939,10 +1003,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
} else {
// Find next empty slot for new channel
final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
final emptySlot = await findNextEmptyChannelSlot();
if (emptySlot == null) {
throw Exception(
'All channel slots are in use (maximum 39 custom channels)',
'All channel slots are in use (maximum $maxCustomChannels custom channels)',
);
}
slotIdx = emptySlot;
@@ -963,23 +1029,33 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Secret converted to 16-byte key using MD5');
}
// Send CMD_SET_CHANNEL to radio
await _activeService.setChannel(
channelIdx: slotIdx,
channelName: channelName,
secret: secretBytes,
);
// Send CMD_SET_CHANNEL to radio. Some devices accept the write but do not
// reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F).
try {
await _activeService.setChannel(
channelIdx: slotIdx,
channelName: channelName,
secret: secretBytes,
);
} catch (e) {
if (_isChannelRefreshTimeoutError(e)) {
debugPrint(
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
);
onChannelInfoReceived?.call(
slotIdx,
channelName,
Uint8List.fromList(secretBytes),
null,
);
} else {
rethrow;
}
}
debugPrint(
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
);
// Small delay to allow the response to propagate
await Future.delayed(const Duration(milliseconds: 100));
// Refresh channels to update UI
// The channel info will be received via onChannelInfoReceived callback
await _activeService.getChannel(slotIdx);
} catch (e) {
_error = 'Failed to create channel: $e';
debugPrint('❌ [Provider] Channel creation failed: $e');
@@ -1073,7 +1149,7 @@ class ConnectionProvider with ChangeNotifier {
///
/// [messageId] - optional message ID to track delivery status
/// [contact] - optional contact object for path status logging
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
/// [retryAttempt] - retry attempt number (0 = first send, >0 = retries)
Future<bool> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
@@ -1087,6 +1163,17 @@ class ConnectionProvider with ChangeNotifier {
return false;
}
var effectiveContact = contact;
if (messageId != null &&
effectiveContact != null &&
prepareDirectMessageSendCallback != null) {
effectiveContact = await prepareDirectMessageSendCallback!(
messageId: messageId,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
}
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
// Rate limit at 7 to stay under the limit
if (_messageDeliveryTracker.shouldRateLimit) {
@@ -1111,33 +1198,33 @@ class ConnectionProvider with ChangeNotifier {
try {
// Log path status and retry info
if (contact != null) {
if (effectiveContact != null) {
if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
);
} else {
debugPrint(
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
);
}
debugPrint(' Type: ${contact.type.displayName}');
debugPrint(' Path status: ${contact.routeSummary}');
if (contact.routeHasPath) {
debugPrint(' Type: ${effectiveContact.type.displayName}');
debugPrint(' Path status: ${effectiveContact.routeSummary}');
if (effectiveContact.routeHasPath) {
debugPrint(
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
);
} else {
debugPrint(' ⚠️ No path available - will use flood mode');
}
} else if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
);
}
// Track pending operation for auto-recovery (if contact not found in radio)
if (contact != null) {
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -1146,7 +1233,7 @@ class ConnectionProvider with ChangeNotifier {
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
debugPrint(
@@ -1191,7 +1278,7 @@ class ConnectionProvider with ChangeNotifier {
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) {
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))

View File

@@ -5,15 +5,13 @@ import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
/// This helper class centralizes retry logic for direct messages.
///
/// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware calculates timeout based on path length and airtime
/// - Direct mode: ~(path_len * airtime * 2) + margin
/// - Flood mode: ~10-30 seconds for multi-hop
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
/// - Firmware does NOT automatically retry - app must implement
class MessageRetryManager {
// Track retry state for each message ID
@@ -21,10 +19,10 @@ class MessageRetryManager {
final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
static const int maxRetryAttempts = 4;
// Retry backoff values in milliseconds.
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
@@ -32,13 +30,12 @@ class MessageRetryManager {
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
/// Get backoff delay for the next retry attempt.
int getDelayForAttempt(int attempt) {
if (attempt < 0 || attempt >= _retryDelays.length) {
return _retryDelays.last;
}
return _timeouts[attempt];
return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
@@ -65,43 +62,8 @@ class MessageRetryManager {
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.routeHasPath;
}
/// Check if should fall back to flood mode
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts with direct mode
/// - Contact HAS a learned path (so direct mode was used)
/// - Hasn't already used flood fallback
///
/// IMPORTANT: Only contacts WITH paths need flood fallback.
/// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
contact.routeHasPath &&
!message.usedFloodFallback;
return message.retryAttempt < maxRetryAttempts;
}
/// Track a retry attempt for a message

View File

@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
);
return false;
}
if (!requester.routeSupportsLegacyRawTransport) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
);
return false;
}
if (requester.outPath.isEmpty) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
try {
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: encodeBinary(fragment),
);
servedCount++;

View File

@@ -5,6 +5,8 @@ import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart';
import '../models/path_selection.dart';
import '../models/sar_marker.dart';
import '../models/map_drawing.dart';
import '../services/message_storage_service.dart';
@@ -27,6 +29,7 @@ class MessagesProvider with ChangeNotifier {
final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
// Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {};
@@ -59,6 +62,8 @@ class MessagesProvider with ChangeNotifier {
// Navigation state for message highlighting/scrolling
String? _targetMessageId;
String? _targetDestinationType;
String? _targetRecipientPublicKeyHex;
// Helper function to compare Uint8List for equality
bool _listEquals(Uint8List a, Uint8List b) {
@@ -81,6 +86,25 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback;
Future<bool> Function({
required String messageId,
required Contact contact,
required Message message,
})?
onFinalRouterFallbackCallback;
Future<void> Function({
required String messageId,
required Contact contact,
required Message message,
})?
onFinalDirectMessageFailureCallback;
void Function({
required String messageId,
required Contact contact,
required Message message,
required int roundTripTimeMs,
})?
onDirectMessageDeliveredCallback;
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
String Function(int channelIdx)? resolveChannelNameCallback;
@@ -126,6 +150,30 @@ class MessagesProvider with ChangeNotifier {
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
_messageTransferDetails[messageId];
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
_messageRouteMetadata[messageId];
void updateMessageRouteSelection(
String messageId,
PathSelection selection, {
required bool routerFallbackAttempted,
}) {
_messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection(
selection,
routerFallbackAttempted: routerFallbackAttempted,
);
final index = _messages.indexWhere((message) => message.id == messageId);
if (index != -1) {
_messages[index] = _messages[index].copyWith(
usedFloodFallback: selection.usesFlood,
);
}
_persistMessages();
notifyListeners();
}
/// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) {
_localizations = localizations;
@@ -137,16 +185,99 @@ class MessagesProvider with ChangeNotifier {
notifyListeners();
}
String? get targetDestinationType => _targetDestinationType;
String? get targetRecipientPublicKeyHex => _targetRecipientPublicKeyHex;
void navigateToDestination(String type, {String? recipientPublicKeyHex}) {
_targetDestinationType = type;
_targetRecipientPublicKeyHex = recipientPublicKeyHex;
notifyListeners();
}
/// Clear message navigation state
void clearMessageNavigation() {
_targetMessageId = null;
}
void clearDestinationNavigation() {
_targetDestinationType = null;
_targetRecipientPublicKeyHex = null;
}
/// Get count of unread messages (excluding sent messages and system messages)
int get unreadCount => _messages
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)
.length;
int getMessageCountForDestination(Contact contact) => _messages
.where((message) => _isMessageForDestination(message, contact))
.length;
DateTime? getLastActivityForDestination(Contact contact) {
DateTime? latest;
for (final message in _messages) {
if (!_isMessageForDestination(message, contact)) {
continue;
}
if (latest == null || message.sentAt.isAfter(latest)) {
latest = message.sentAt;
}
}
return latest;
}
int getUnreadCountForDestination(Contact contact) => _messages
.where(
(message) =>
_isMessageForDestination(message, contact) &&
!message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage,
)
.length;
bool _isMessageForDestination(Message message, Contact contact) {
if (message.isSystemMessage) return false;
if (contact.isChannel) {
final channelIdx = contact.publicKey.length > 1
? contact.publicKey[1]
: 0;
return message.isChannelMessage &&
(message.channelIdx ?? 0) == channelIdx;
}
if (!message.isContactMessage || contact.publicKey.length < 6) {
return false;
}
final selectedPrefix = contact.publicKey.sublist(0, 6);
if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 &&
_matchesPrefix(message.recipientPublicKey!, selectedPrefix)) {
return true;
}
if (message.senderPublicKeyPrefix != null &&
message.senderPublicKeyPrefix!.length >= 6 &&
_matchesPrefix(message.senderPublicKeyPrefix!, selectedPrefix)) {
return true;
}
return false;
}
bool _matchesPrefix(Uint8List key, Uint8List prefix) {
for (var i = 0; i < 6; i++) {
if (key[i] != prefix[i]) return false;
}
return true;
}
/// Initialize and load persisted messages
Future<void> initialize() async {
if (_isInitialized) return;
@@ -160,6 +291,8 @@ class MessagesProvider with ChangeNotifier {
.loadMessageReceptionDetails();
final storedTransferDetails = await _storageService
.loadMessageTransferDetails();
final storedRouteMetadata = await _storageService
.loadMessageRouteMetadata();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
@@ -169,6 +302,9 @@ class MessagesProvider with ChangeNotifier {
_messageTransferDetails
..clear()
..addAll(storedTransferDetails);
_messageRouteMetadata
..clear()
..addAll(storedRouteMetadata);
// Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) {
@@ -629,9 +765,17 @@ class MessagesProvider with ChangeNotifier {
final idx = channelIdx ?? 0;
final resolved = resolveChannelNameCallback?.call(idx).trim();
if (resolved != null && resolved.isNotEmpty) {
if (idx == 0 && resolved == 'Public') {
return _localizations?.publicChannel ?? resolved;
}
return resolved;
}
return idx == 0 ? 'Public' : 'Channel $idx';
if (idx == 0) {
return _localizations?.publicChannel ?? 'Public';
}
return _localizations != null
? '${_localizations!.channel} $idx'
: 'Channel $idx';
}
String _buildNotificationMessageText(
@@ -645,14 +789,14 @@ class MessagesProvider with ChangeNotifier {
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
final summary =
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
return isChannelMessage ? '$senderName\n$summary' : summary;
return summary;
}
final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) {
final summary =
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
return isChannelMessage ? '$senderName\n$summary' : summary;
return summary;
}
if (!isChannelMessage && message.recipientPublicKey != null) {
@@ -665,10 +809,6 @@ class MessagesProvider with ChangeNotifier {
}
}
if (isChannelMessage) {
return '$senderName\n${message.text}';
}
return message.text;
}
@@ -688,6 +828,7 @@ class MessagesProvider with ChangeNotifier {
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
);
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
@@ -768,6 +909,55 @@ class MessagesProvider with ChangeNotifier {
}
}
/// Mark unread messages for a specific destination as read.
void markDestinationAsRead({
required String destinationType,
Contact? contact,
}) {
if (destinationType == 'all') {
markAllAsRead();
return;
}
bool hasChanges = false;
for (int i = 0; i < _messages.length; i++) {
final message = _messages[i];
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
continue;
}
final matchesDestination = switch (destinationType) {
'channel' => _isChannelMessageForContact(message, contact),
'contact' ||
'room' => contact != null && _isMessageForDestination(message, contact),
_ => false,
};
if (!matchesDestination) {
continue;
}
_messages[i] = message.copyWith(isRead: true);
hasChanges = true;
}
if (hasChanges) {
_persistMessages();
notifyListeners();
}
}
bool _isChannelMessageForContact(Message message, Contact? contact) {
if (!message.isChannelMessage) {
return false;
}
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
? contact.publicKey[1]
: 0;
return (message.channelIdx ?? 0) == selectedChannelIdx;
}
/// Mark a specific message as read
void markAsRead(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
@@ -806,6 +996,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
_messageTransferDetails.remove(messageId);
_messageRouteMetadata.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -837,6 +1028,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_persistMessages();
notifyListeners();
}
@@ -854,6 +1046,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_persistMessages();
notifyListeners();
}
@@ -1549,6 +1742,12 @@ class MessagesProvider with ChangeNotifier {
final deliveredContact = _messageContactMap[message.id];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
onDirectMessageDeliveredCallback?.call(
messageId: message.id,
contact: deliveredContact,
message: updatedMessage,
roundTripTimeMs: roundTripTimeMs,
);
}
debugPrint(
@@ -1592,6 +1791,12 @@ class MessagesProvider with ChangeNotifier {
final deliveredContact = _messageContactMap[historicalMessageId];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
onDirectMessageDeliveredCallback?.call(
messageId: historicalMessageId,
contact: deliveredContact,
message: _messages[historicalIndex],
roundTripTimeMs: roundTripTimeMs,
);
}
_persistMessages();
notifyListeners();
@@ -1677,29 +1882,29 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
// Decision tree for retry/flood/fail
final routeMetadata = _messageRouteMetadata[messageId];
final routerFallbackAttempted =
routeMetadata?.routerFallbackAttempted ?? false;
// Decision tree for retry/final-router-fallback/fail
if (contact != null && _retryManager.canRetry(message, contact)) {
// RETRY: Contact has path and retry attempts < 3
_scheduleRetry(messageId, message, contact);
} else if (contact != null &&
_retryManager.shouldUseFloodFallback(message, contact)) {
// FLOOD FALLBACK: After 3 retries failed, try flood once
_sendWithFloodMode(messageId, message, contact);
} else if (contact != null && !routerFallbackAttempted) {
unawaited(_sendWithFinalRouterFallback(messageId, message, contact));
} else {
// PERMANENTLY FAILED: No retry possible
_markAsPermanentlyFailed(messageId, message);
}
}
/// Schedule a retry with progressive timeout
/// Schedule a retry with exponential backoff.
void _scheduleRetry(String messageId, Message message, Contact contact) {
final nextAttempt = message.retryAttempt + 1;
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
debugPrint(
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId',
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
);
debugPrint(' Timeout: ${timeout}ms');
debugPrint(' Delay: ${delayMs}ms');
// Update message with new retry attempt
final index = _messages.indexWhere((m) => m.id == messageId);
@@ -1720,10 +1925,10 @@ class MessagesProvider with ChangeNotifier {
// Track retry
_retryManager.trackRetry(messageId, nextAttempt);
notifyListeners(); // Update UI to show "Retrying (X/3)..."
notifyListeners();
// Schedule actual retry after delay
Timer(Duration(milliseconds: timeout), () async {
Timer(Duration(milliseconds: delayMs), () async {
debugPrint(
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
);
@@ -1759,49 +1964,46 @@ class MessagesProvider with ChangeNotifier {
}
}
/// Send message with flood mode as last resort
Future<void> _sendWithFloodMode(
Future<void> _sendWithFinalRouterFallback(
String messageId,
Message message,
Contact contact,
) async {
debugPrint(
'🌊 [MessagesProvider] Trying flood mode for message $messageId',
'🛟 [MessagesProvider] Trying final router fallback for $messageId',
);
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
_messages[index] = message.copyWith(
usedFloodFallback: true,
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
// Cancel old timeout timer
_timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id);
if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag);
}
_clearAckHistoryForMessage(messageId);
notifyListeners();
// Send with flood mode (no retry after this)
if (sendMessageCallback != null) {
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0, // Reset attempt for flood
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else {
if (onFinalRouterFallbackCallback == null) {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
'⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set',
);
_markAsPermanentlyFailed(messageId, _messages[index]);
return;
}
final queued = await onFinalRouterFallbackCallback!(
messageId: messageId,
contact: contact,
message: _messages[index],
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
_persistMessages();
@@ -1830,19 +2032,15 @@ class MessagesProvider with ChangeNotifier {
_retryManager.clearRetry(messageId);
final failedContact = _messageContactMap[messageId];
if (failedContact != null && failedContact.routeHasPath) {
final failureStreak = _retryManager.recordPathFailure(failedContact);
debugPrint(
' Path failure streak for ${failedContact.advName}: $failureStreak',
if (failedContact != null &&
onFinalDirectMessageFailureCallback != null) {
unawaited(
onFinalDirectMessageFailureCallback!(
messageId: messageId,
contact: failedContact,
message: _messages[index],
),
);
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
unawaited(
onDirectPathFailedCallback!(
contact: failedContact,
failureStreak: failureStreak,
),
);
}
}
_persistMessages();
@@ -1870,6 +2068,7 @@ class MessagesProvider with ChangeNotifier {
}
_clearAckHistoryForMessage(messageId);
_retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId);
_messages[index] = Message(
id: message.id,

View File

@@ -7,13 +7,23 @@ import '../models/contact.dart';
import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/messages_provider.dart';
import '../services/message_destination_preferences.dart';
import '../utils/contact_grouping.dart';
import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart';
class ContactsTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
const ContactsTab({super.key, this.onNavigateToMap});
const ContactsTab({
super.key,
this.onNavigateToMap,
this.onNavigateToMessages,
});
@override
State<ContactsTab> createState() => _ContactsTabState();
@@ -22,6 +32,11 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{};
final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen,
ContactSection.rooms: ContactSortMode.lastSeen,
};
@override
void initState() {
@@ -120,20 +135,31 @@ class _ContactsTabState extends State<ContactsTab> {
return l10n.daysAgo(diff.inDays);
}
List<Contact> _sortContactsByDistance(List<Contact> contacts) {
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) {
sorted.sort(
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
return sorted;
}
final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen;
sorted.sort((a, b) {
final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (sortMode == ContactSortMode.distance) {
final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) {
return -1;
} else if (distanceB != null) {
return 1;
if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) {
return -1;
} else if (distanceB != null) {
return 1;
}
}
return b.lastSeenTime.compareTo(a.lastSeenTime);
@@ -203,19 +229,31 @@ class _ContactsTabState extends State<ContactsTab> {
return Scaffold(
body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final chatContacts = _sortContactsByDistance(
final messagesProvider = context.watch<MessagesProvider>();
final chatContacts = _sortContacts(
contactsProvider.chatContacts,
ContactSection.teamMembers,
);
final repeaters = _sortContacts(
contactsProvider.repeaters,
ContactSection.repeaters,
);
final rooms = _sortContacts(
contactsProvider.rooms,
ContactSection.rooms,
);
final channels = _sortContacts(
contactsProvider.channels,
ContactSection.channels,
);
final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
final rooms = _sortContactsByDistance(contactsProvider.rooms);
final channels = _sortContactsByDistance(contactsProvider.channels);
final pendingAdverts = contactsProvider.pendingAdverts;
// Check if there are any displayable contacts (excluding channels)
// Check if there are any displayable contacts
final hasDisplayableContacts =
chatContacts.isNotEmpty ||
repeaters.isNotEmpty ||
rooms.isNotEmpty ||
channels.isNotEmpty ||
pendingAdverts.isNotEmpty;
if (!hasDisplayableContacts) {
@@ -276,16 +314,12 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.teamMembers,
count: chatContacts.length,
icon: Icons.people,
),
...chatContacts.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
trailing: _buildSortMenu(
context,
ContactSection.teamMembers,
),
),
..._buildContactSectionItems(chatContacts),
const Divider(height: 32),
],
@@ -295,16 +329,9 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.repeaters,
count: repeaters.length,
icon: Icons.router,
trailing: _buildSortMenu(context, ContactSection.repeaters),
),
...repeaters.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
),
),
..._buildContactSectionItems(repeaters),
const Divider(height: 32),
],
@@ -314,16 +341,9 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.rooms,
count: rooms.length,
icon: Icons.tag,
trailing: _buildSortMenu(context, ContactSection.rooms),
),
...rooms.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
),
),
..._buildContactSectionItems(rooms),
const Divider(height: 32),
],
@@ -335,12 +355,11 @@ class _ContactsTabState extends State<ContactsTab> {
),
if (channels.isNotEmpty) ...[
...channels.map(
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
(channel) => _ChannelActivityCard(
channel: channel,
messagesProvider: messagesProvider,
contactsProvider: contactsProvider,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
],
@@ -371,8 +390,97 @@ class _ContactsTabState extends State<ContactsTab> {
),
);
}
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
final items = ContactGrouping.buildItemsFromSorted(contacts);
return items.map((item) {
if (item.isGroup) {
return _InferredContactGroupCard(
label: item.group!.label,
contacts: item.group!.contacts,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}
return ContactTile(
contact: item.contact!,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}).toList();
}
Widget _buildSortMenu(BuildContext context, ContactSection section) {
final l10n = AppLocalizations.of(context)!;
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<ContactSortMode>(
tooltip: 'Sort',
initialValue: selectedMode,
onSelected: (sortMode) {
setState(() {
_sortModes[section] = sortMode;
});
},
itemBuilder: (context) => [
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.lastSeen,
child: Row(
children: [
Icon(
Icons.schedule,
size: 18,
color: selectedMode == ContactSortMode.lastSeen
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text(l10n.lastSeen),
],
),
),
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.distance,
child: Row(
children: [
Icon(
Icons.near_me,
size: 18,
color: selectedMode == ContactSortMode.distance
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text(l10n.distance),
],
),
),
],
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.more_horiz,
size: 18,
color: colorScheme.onSurfaceVariant,
),
),
);
}
}
enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, rooms, channels }
class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert;
final String subtitle;
@@ -417,11 +525,13 @@ class _SectionHeader extends StatelessWidget {
final String title;
final int count;
final IconData icon;
final Widget? trailing;
const _SectionHeader({
required this.title,
required this.count,
required this.icon,
this.trailing,
});
@override
@@ -450,6 +560,454 @@ class _SectionHeader extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall,
),
),
if (trailing != null) ...[const Spacer(), trailing!],
],
),
);
}
}
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance;
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
const _InferredContactGroupCard({
required this.label,
required this.contacts,
required this.currentPosition,
required this.calculateDistance,
required this.formatDistance,
required this.onNavigateToMap,
required this.onNavigateToMessages,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
initiallyExpanded: false,
leading: Icon(
Icons.folder_copy_outlined,
size: 18,
color: colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
contacts.length.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
children: [
...contacts.map(
(contact) => ContactTile(
contact: contact,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
onNavigateToMap: onNavigateToMap,
onNavigateToMessages: onNavigateToMessages,
),
),
],
),
),
);
}
}
class _ChannelActivityCard extends StatelessWidget {
final Contact channel;
final MessagesProvider messagesProvider;
final ContactsProvider contactsProvider;
final VoidCallback? onNavigateToMessages;
const _ChannelActivityCard({
required this.channel,
required this.messagesProvider,
required this.contactsProvider,
required this.onNavigateToMessages,
});
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
);
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination(
channel,
);
final activityLabel = lastActivityAt == null
? null
: _formatRelativeTime(context, lastActivityAt);
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.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
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: 24),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
channel.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
const SizedBox(width: 8),
if (activityLabel != null)
Text(
activityLabel,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
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,
),
),
],
),
);

View File

@@ -14,6 +14,7 @@ import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'sensors_tab.dart';
import 'map_tab.dart';
import 'repeaters_map_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
@@ -23,6 +24,8 @@ import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
enum _HomeTab { messages, contacts, sensors, map }
@@ -55,6 +58,7 @@ class _HomeScreenState extends State<HomeScreen>
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
@@ -90,6 +94,8 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) {
@@ -192,7 +198,6 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) {
case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break;
case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed();
@@ -217,6 +222,9 @@ class _HomeScreenState extends State<HomeScreen>
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
}
Future<void> _loadRxTxPreference() async {
@@ -228,6 +236,14 @@ class _HomeScreenState extends State<HomeScreen>
}
}
Future<void> _loadDeveloperModePreference() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
@@ -378,10 +394,14 @@ class _HomeScreenState extends State<HomeScreen>
required int count,
required bool isActive,
required Color activeColor,
bool compact = false,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 4 : 5,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
@@ -390,15 +410,15 @@ class _HomeScreenState extends State<HomeScreen>
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
width: compact ? 6 : 7,
height: compact ? 6 : 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 6),
SizedBox(width: compact ? 5 : 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: 11,
fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w600,
color: color,
),
@@ -598,56 +618,92 @@ class _HomeScreenState extends State<HomeScreen>
),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const SpectrumScanScreen(),
),
);
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
onTap: () {
// Capture context-dependent objects before async gap
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () async {
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (context) => SettingsScreen(
onThemeChanged: widget.onThemeChanged,
onLocaleChanged: widget.onLocaleChanged,
currentTheme: widget.currentTheme,
currentLocale: widget.currentLocale,
itemBuilder: (context) {
final items = <PopupMenuEntry<void>>[];
if (_isDeveloperModeEnabled) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const SpectrumScanScreen(),
),
);
});
},
),
);
}
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.router_outlined),
SizedBox(width: 8),
Text('Repeaters Map'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const RepeatersMapScreen(),
fullscreenDialog: true,
),
),
);
// Reload preference when returning from settings
_loadRxTxPreference();
});
},
),
],
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () async {
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (context) => SettingsScreen(
onThemeChanged: widget.onThemeChanged,
onLocaleChanged: widget.onLocaleChanged,
currentTheme: widget.currentTheme,
currentLocale: widget.currentLocale,
),
),
);
_loadRxTxPreference();
_loadDeveloperModePreference();
});
},
),
);
return items;
},
),
],
),
@@ -666,6 +722,7 @@ class _HomeScreenState extends State<HomeScreen>
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
);
case _HomeTab.sensors:
return const SensorsTab();
@@ -715,7 +772,7 @@ class _HomeScreenState extends State<HomeScreen>
Icons.message,
unreadCount,
),
text: AppLocalizations.of(context)!.messages,
text: 'Chat',
);
case _HomeTab.contacts:
return Tab(
@@ -1014,17 +1071,19 @@ class _HomeScreenState extends State<HomeScreen>
txActive: provider.txActivity,
)
: Container(
constraints: const BoxConstraints(minHeight: 48),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(22),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildActivityBadge(
@@ -1032,13 +1091,15 @@ class _HomeScreenState extends State<HomeScreen>
count: provider.rxPacketCount,
isActive: provider.rxActivity,
activeColor: Colors.green,
compact: true,
),
const SizedBox(height: 6),
const SizedBox(height: 4),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
compact: true,
),
],
),

View File

@@ -27,6 +27,7 @@ import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart';
import '../widgets/map/detailed_compass_dialog.dart';
import '../widgets/map/drawing_layer.dart';
import '../widgets/map/drawing_toolbar.dart';
import '../widgets/map/location_trail_layer.dart';
import '../widgets/map/trail_controls.dart';
import '../widgets/map/map_message_overlay.dart';
@@ -2131,6 +2132,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
right: 16,
child: Column(
children: [
const DrawingToolbar(),
const SizedBox(height: 8),
// Hide other buttons when in drawing mode
if (!drawingProvider.isDrawing) ...[
// Current Location - always center to GPS

View File

@@ -89,9 +89,7 @@ class _MessagesTabState extends State<MessagesTab> {
// Load saved message destination
_loadSavedDestination();
_loadVoiceBitrate();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest();
});
}
@@ -128,11 +126,20 @@ class _MessagesTabState extends State<MessagesTab> {
void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId;
final targetDestinationType = messagesProvider.targetDestinationType;
if (targetMessageId != null) {
_scrollToMessage(targetMessageId);
messagesProvider.clearMessageNavigation();
}
if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation();
}
}
void _scrollToMessage(String messageId) {
@@ -258,6 +265,7 @@ class _MessagesTabState extends State<MessagesTab> {
/// Show recipient selector bottom sheet
void _showRecipientSelector() {
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Filter contacts by type
final contacts = contactsProvider.contacts
@@ -278,6 +286,13 @@ class _MessagesTabState extends State<MessagesTab> {
contacts: contacts,
rooms: rooms,
channels: channels,
unreadCount: messagesProvider.unreadCount,
unreadCountsByPublicKey: {
for (final contact in [...contacts, ...rooms, ...channels])
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
contact,
),
},
currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected,
@@ -292,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient;
});
_markCurrentDestinationAsRead();
_enforceMessageByteLimit();
// Save to preferences
@@ -304,9 +321,107 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return;
}
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
}) async {
Contact? recipient;
if (recipientPublicKeyHex != null) {
final contactsProvider = context.read<ContactsProvider>();
recipient = contactsProvider.contacts.where((contact) {
return contact.publicKeyHex == recipientPublicKeyHex;
}).firstOrNull;
}
await _onRecipientSelected(type, recipient);
if (!mounted) return;
_focusNode.requestFocus();
}
void _insertReplyMention(String displayName) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] ';
final value = _textController.value;
final selection = value.selection;
final hasSelection =
selection.isValid &&
selection.start >= 0 &&
selection.end >= selection.start;
final start = hasSelection ? selection.start : value.text.length;
final end = hasSelection ? selection.end : value.text.length;
final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length;
_textController.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_enforceMessageByteLimit();
}
Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact;
String destinationType;
Contact? recipient;
if (message.isChannelMessage) {
final channelIdx = message.channelIdx ?? 0;
destinationType = MessageDestinationPreferences.destinationTypeChannel;
if (channelIdx != 0) {
recipient = contactsProvider.channels.where((contact) {
return contact.publicKey.length > 1 &&
contact.publicKey[1] == channelIdx;
}).firstOrNull;
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
}
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
}
await _onRecipientSelected(destinationType, recipient);
if (!mounted) return;
if (message.isChannelMessage && senderContact != null) {
_insertReplyMention(senderContact.displayName);
}
_focusNode.requestFocus();
}
/// Get icon for current destination type
IconData _getDestinationIcon() {
if (_destinationType ==
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return Icons.all_inbox;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
return Icons.public;
} else if (_destinationType ==
@@ -318,6 +433,9 @@ class _MessagesTabState extends State<MessagesTab> {
}
String _getDestinationLabel() {
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return AppLocalizations.of(context)!.showAll;
}
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
@@ -343,6 +461,12 @@ class _MessagesTabState extends State<MessagesTab> {
return;
}
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
if (!mounted) return;
ToastLogger.error(context, 'Select a channel, contact, or room first');
return;
}
try {
// Check destination type and send accordingly
if (_destinationType ==
@@ -375,6 +499,7 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.clear();
_focusNode.unfocus();
_markCurrentDestinationAsRead();
if (!mounted) return;
} catch (e) {
@@ -1107,6 +1232,15 @@ class _MessagesTabState extends State<MessagesTab> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search messages'),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showFilteredMessageSearch();
});
},
),
ListTile(
leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
@@ -1174,6 +1308,116 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
void _showFilteredMessageSearch() {
final messagesProvider = context.read<MessagesProvider>();
final scopedMessages = _getFilteredMessages(messagesProvider);
final searchController = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setModalState) {
final query = searchController.text.trim().toLowerCase();
final matches = query.isEmpty
? scopedMessages
: scopedMessages.where((message) {
return message.text.toLowerCase().contains(query) ||
(message.senderName?.toLowerCase().contains(query) ??
false);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: SizedBox(
height: MediaQuery.of(sheetContext).size.height * 0.72,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Search in current filter',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
setModalState(() {});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
),
onChanged: (_) => setModalState(() {}),
),
),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
query.isEmpty
? 'No messages in this filter'
: 'No matches in this filter',
),
)
: ListView.separated(
itemCount: matches.length,
separatorBuilder: (_, _) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final message = matches[index];
final title =
message.senderName?.trim().isNotEmpty ==
true
? message.senderName!
: message.isSentMessage
? 'You'
: _getDestinationLabel();
return ListTile(
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
message.text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Navigator.pop(sheetContext);
_scrollToMessage(message.id);
},
);
},
),
),
],
),
),
),
);
},
);
},
).whenComplete(searchController.dispose);
}
Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient;
if (recipient != null) {
@@ -1432,6 +1676,13 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
void _markCurrentDestinationAsRead() {
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100);
@@ -1439,17 +1690,18 @@ class _MessagesTabState extends State<MessagesTab> {
List<Message> filteredMessages;
// If channel destination is selected, filter by selected channel.
if (_destinationType ==
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
filteredMessages = allMessages;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (selectedChannelIdx == 0) {
// Public channel view keeps showing all messages (current app behavior).
filteredMessages = allMessages;
} else {
filteredMessages = allMessages
.where((message) => message.channelIdx == selectedChannelIdx)
.toList();
}
filteredMessages = allMessages
.where(
(message) =>
message.isChannelMessage &&
(message.channelIdx ?? 0) == selectedChannelIdx,
)
.toList();
}
// If a contact or room is selected, filter by recipient/sender prefixes.
else if ((_destinationType ==
@@ -1458,6 +1710,10 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeRoom) &&
_selectedRecipient != null) {
filteredMessages = allMessages.where((message) {
if (!message.isContactMessage) {
return false;
}
// Include messages sent TO this recipient
if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 &&
@@ -1538,6 +1794,7 @@ class _MessagesTabState extends State<MessagesTab> {
onRefresh: _handleRefresh,
onNavigateToMap: widget.onNavigateToMap,
onMessageTap: _handleMessageTap,
onReplyToMessage: _replyToMessage,
),
),
Positioned(

View File

@@ -8,6 +8,8 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget {
@@ -460,26 +462,6 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
final resolvedPath = decodedRoute?.pathHashes
.map(
(hash) => LogRxRouteDecoder.resolveHash(
hash,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
? resolvedPath.first
: null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -604,13 +586,9 @@ class _PacketLogCard extends StatelessWidget {
),
),
],
if (isRx && decodedRoute != null) ...[
if (isRx) ...[
const SizedBox(height: 12),
_RouteSection(
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
_DecodedRouteSection(log: log),
],
const SizedBox(height: 12),
Container(
@@ -755,6 +733,97 @@ class _PacketLogCard extends StatelessWidget {
}
}
class _DecodedRouteSection extends StatelessWidget {
final BlePacketLog log;
const _DecodedRouteSection({required this.log});
@override
Widget build(BuildContext context) {
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
return FutureBuilder<List<dynamic>>(
future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
]),
builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: snapshot.data?.first as int?,
);
if (decodedRoute == null) {
return const SizedBox.shrink();
}
final cachedNodes = snapshot.hasData
? snapshot.data![1] as List<MeshMapNode>
: const <MeshMapNode>[];
final resolvedPath = decodedRoute.hopHashes
.map(
(hashHex) => _resolveHashWithFallback(
hashHex,
contacts: contacts,
cachedNodes: cachedNodes,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
return _RouteSection(
route: decodedRoute,
path: resolvedPath,
originalSender: originalSender,
);
},
);
}
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 {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
@@ -802,7 +871,13 @@ class _RouteSection extends StatelessWidget {
_FactCard(
icon: Icons.hub,
label: 'Hops',
value: '${route.pathHashes.length}',
value: '${route.hopCount}',
),
_FactCard(
icon: Icons.tag,
label: 'Hash size',
value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
),
if (originalSender != null)
_FactCard(

View File

@@ -0,0 +1,775 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
class RepeatersMapScreen extends StatefulWidget {
const RepeatersMapScreen({super.key});
@override
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
}
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
static const double _fallbackZoom = 7;
static const double _myLocationZoom = 14;
final flutter_map.MapController _mapController = flutter_map.MapController();
List<MeshMapNode> _onlineRepeaters = const [];
bool _isLoading = true;
bool _didFitCamera = false;
String? _error;
double _currentZoom = _fallbackZoom;
LatLng? _myLocation;
final Set<String> _addingRepeaters = <String>{};
@override
void initState() {
super.initState();
_loadRepeaters();
}
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
setState(() {
_isLoading = true;
_error = null;
if (forceRefresh) {
_didFitCamera = false;
}
});
try {
final nodes = await MeshMapNodesService.fetchNodes(
forceRefresh: forceRefresh,
);
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
if (!mounted) return;
setState(() {
_onlineRepeaters = repeaters;
_isLoading = false;
});
_refreshMyLocation();
} catch (error) {
final cached = await MeshMapNodesService.loadCachedNodes();
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
if (!mounted) return;
setState(() {
_onlineRepeaters = repeaters;
_isLoading = false;
_error = repeaters.isEmpty
? 'Unable to load repeaters right now.'
: 'Showing cached repeaters. Pull to retry.';
});
_refreshMyLocation();
}
}
List<_MapRepeater> _mergeRepeaters({
required List<MeshMapNode> onlineNodes,
required List<Contact> contactRepeaters,
}) {
final merged = <String, _MapRepeater>{};
for (final node in onlineNodes) {
merged[node.publicKey] = _MapRepeater(
publicKey: node.publicKey,
name: node.name,
latitude: node.latitude,
longitude: node.longitude,
isFromContacts: false,
isFromOnline: true,
);
}
for (final contact in contactRepeaters) {
final location = contact.displayLocation;
if (location == null) {
continue;
}
final key = contact.publicKeyHex.toLowerCase();
final existing = merged[key];
merged[key] = _MapRepeater(
publicKey: key,
name: contact.displayName.isNotEmpty
? contact.displayName
: existing?.name ?? 'Unknown repeater',
latitude: existing?.latitude ?? location.latitude,
longitude: existing?.longitude ?? location.longitude,
isFromContacts: true,
isFromOnline: existing?.isFromOnline ?? false,
);
}
final repeaters = merged.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return repeaters;
}
Future<void> _refreshMyLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (!mounted) return;
setState(() {
_myLocation = LatLng(position.latitude, position.longitude);
});
} catch (_) {
// Best effort only.
}
}
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
if (_didFitCamera || repeaters.isEmpty || !mounted) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _didFitCamera || repeaters.isEmpty) {
return;
}
if (repeaters.length == 1) {
final repeater = repeaters.first;
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
_currentZoom = 13;
} else {
final bounds = flutter_map.LatLngBounds.fromPoints(
repeaters
.map((node) => LatLng(node.latitude, node.longitude))
.toList(),
);
_mapController.fitCamera(
flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(48),
),
);
}
_didFitCamera = true;
});
}
void _showRepeaterDetails(_MapRepeater repeater) {
final isAdding = _addingRepeaters.contains(repeater.publicKey);
final canAdd = !repeater.isFromContacts;
final isConnected = context
.read<ConnectionProvider>()
.deviceInfo
.isConnected;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
repeater.name,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
_InfoLine(
label: 'Public key',
value: repeater.publicKey,
monospace: true,
),
_InfoLine(
label: 'Coordinates',
value:
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
),
_InfoLine(label: 'Source', value: repeater.sourceLabel),
const SizedBox(height: 12),
if (canAdd)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: !isConnected || isAdding
? null
: () async {
Navigator.of(context).pop();
await _addRepeaterToContacts(repeater);
},
icon: isAdding
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(
!isConnected
? 'Connect device to add'
: 'Add to contacts',
),
),
)
else
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.check_circle),
label: const Text('Already in contacts'),
),
),
],
),
),
),
);
}
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
if (_addingRepeaters.contains(repeater.publicKey)) {
return;
}
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect to a device before adding contacts'),
),
);
return;
}
setState(() {
_addingRepeaters.add(repeater.publicKey);
});
try {
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${repeater.name} added to contacts')),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
);
} finally {
if (mounted) {
setState(() {
_addingRepeaters.remove(repeater.publicKey);
});
}
}
}
Uint8List _hexToBytes(String hex) {
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
return Uint8List.fromList(
List<int>.generate(
normalized.length ~/ 2,
(index) => int.parse(
normalized.substring(index * 2, index * 2 + 2),
radix: 16,
),
),
);
}
@override
Widget build(BuildContext context) {
final contactsProvider = context.watch<ContactsProvider>();
final repeaters = _mergeRepeaters(
onlineNodes: _onlineRepeaters,
contactRepeaters: contactsProvider.repeaters,
);
_fitCameraIfNeeded(repeaters);
final clusters = _buildClusters(repeaters);
final markers = clusters.map((cluster) {
final markerColor = switch (cluster.status) {
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
_ClusterStatus.both => const Color(0xFF1B8F4F),
_ClusterStatus.mixed => const Color(0xFF2563EB),
};
if (cluster.members.length == 1) {
final repeater = cluster.members.first;
return flutter_map.Marker(
point: cluster.center,
width: 80,
height: 100,
child: GestureDetector(
onTap: () => _showRepeaterDetails(repeater),
child: _RepeaterMarker(color: markerColor, label: repeater.name),
),
);
}
return flutter_map.Marker(
point: cluster.center,
width: 48,
height: 48,
child: GestureDetector(
onTap: () => _zoomToCluster(cluster),
child: _ClusterMarker(
color: markerColor,
count: cluster.members.length,
),
),
);
}).toList();
final allMarkers = <flutter_map.Marker>[
...markers,
if (_myLocation != null)
flutter_map.Marker(
point: _myLocation!,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('Repeaters Map'),
actions: [
IconButton(
onPressed: _isLoading
? null
: () => _loadRepeaters(forceRefresh: true),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: Stack(
children: [
flutter_map.FlutterMap(
mapController: _mapController,
options: flutter_map.MapOptions(
initialCenter: _fallbackCenter,
initialZoom: _fallbackZoom,
onPositionChanged: (camera, hasGesture) {
final nextZoom = camera.zoom;
if (nextZoom != _currentZoom && mounted) {
setState(() {
_currentZoom = nextZoom;
});
}
},
),
children: [
flutter_map.TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'app.meshcore.sar',
),
flutter_map.MarkerLayer(markers: allMarkers),
],
),
if (_error != null)
Positioned(
top: 16,
left: 16,
right: 16,
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(14),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Text(_error!),
),
),
),
if (_isLoading) const Center(child: CircularProgressIndicator()),
if (!_isLoading && repeaters.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error ?? 'No repeaters available.',
textAlign: TextAlign.center,
),
),
),
if (repeaters.isNotEmpty)
Positioned(
left: 16,
top: _error == null ? 16 : 72,
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(14),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: const [
_LegendRow(
color: Color(0xFF7C3AED),
label: 'From contacts',
),
SizedBox(height: 8),
_LegendRow(
color: Color(0xFFE8681D),
label: 'Online only',
),
SizedBox(height: 8),
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
],
),
),
),
),
if (repeaters.isNotEmpty)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Material(
elevation: 3,
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Text(
'${repeaters.length} repeaters',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _jumpToMyLocation,
tooltip: 'My location',
child: const Icon(Icons.my_location),
),
);
}
Future<void> _jumpToMyLocation() async {
await _refreshMyLocation();
final myLocation = _myLocation;
if (!mounted || myLocation == null) {
return;
}
_mapController.move(myLocation, _myLocationZoom);
setState(() {
_currentZoom = _myLocationZoom;
});
}
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
if (repeaters.isEmpty) {
return const [];
}
if (_currentZoom >= 11.5) {
return repeaters
.map(
(repeater) => _RepeaterCluster(
members: [repeater],
center: LatLng(repeater.latitude, repeater.longitude),
status: repeater.clusterStatus,
),
)
.toList();
}
final cellSize = _gridSizeForZoom(_currentZoom);
final buckets = <String, List<_MapRepeater>>{};
for (final repeater in repeaters) {
final latBucket = (repeater.latitude / cellSize).floor();
final lonBucket = (repeater.longitude / cellSize).floor();
final key = '$latBucket:$lonBucket';
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
}
return buckets.values.map((members) {
final centerLat =
members.map((node) => node.latitude).reduce((a, b) => a + b) /
members.length;
final centerLon =
members.map((node) => node.longitude).reduce((a, b) => a + b) /
members.length;
final statuses = members.map((node) => node.clusterStatus).toSet();
return _RepeaterCluster(
members: members,
center: LatLng(centerLat, centerLon),
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
);
}).toList();
}
double _gridSizeForZoom(double zoom) {
if (zoom < 5) return 3.0;
if (zoom < 7) return 1.5;
if (zoom < 9) return 0.7;
if (zoom < 10.5) return 0.28;
return 0.12;
}
void _zoomToCluster(_RepeaterCluster cluster) {
if (cluster.members.isEmpty) {
return;
}
final bounds = flutter_map.LatLngBounds.fromPoints(
cluster.members
.map((node) => LatLng(node.latitude, node.longitude))
.toList(),
);
_mapController.fitCamera(
flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: (_currentZoom + 2).clamp(8, 14),
),
);
}
}
class _InfoLine extends StatelessWidget {
final String label;
final String value;
final bool monospace;
const _InfoLine({
required this.label,
required this.value,
this.monospace = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 92,
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(
value,
style: monospace
? const TextStyle(fontFamily: 'monospace')
: null,
),
),
],
),
);
}
}
class _LegendRow extends StatelessWidget {
final Color color;
final String label;
const _LegendRow({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(label),
],
);
}
}
class _RepeaterMarker extends StatelessWidget {
final Color color;
final String label;
const _RepeaterMarker({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: const Icon(Icons.router, color: Colors.white, size: 18),
),
const SizedBox(height: 2),
Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
);
}
}
class _ClusterMarker extends StatelessWidget {
final Color color;
final int count;
const _ClusterMarker({required this.color, required this.count});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.22),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
alignment: Alignment.center,
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
);
}
}
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
class _RepeaterCluster {
final List<_MapRepeater> members;
final LatLng center;
final _ClusterStatus status;
const _RepeaterCluster({
required this.members,
required this.center,
required this.status,
});
}
class _MapRepeater {
final String publicKey;
final String name;
final double latitude;
final double longitude;
final bool isFromContacts;
final bool isFromOnline;
const _MapRepeater({
required this.publicKey,
required this.name,
required this.latitude,
required this.longitude,
required this.isFromContacts,
required this.isFromOnline,
});
String get sourceLabel {
if (isFromContacts && isFromOnline) return 'Contacts and online';
if (isFromContacts) return 'Contacts';
return 'Online';
}
_ClusterStatus get clusterStatus {
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
if (isFromContacts) return _ClusterStatus.contactsOnly;
return _ClusterStatus.onlineOnly;
}
}

View File

@@ -371,6 +371,7 @@ class _SensorCard extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
final telemetry = contact?.telemetry;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final metrics = contact == null || telemetry == null
? const <_MetricCardData>[]
: _buildMetricCards(l10n, telemetry, contact!);
@@ -378,16 +379,23 @@ class _SensorCard extends StatelessWidget {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.14),
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 18,
offset: const Offset(0, 8),
color: Colors.black.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart';
@@ -15,12 +16,14 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart';
import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart';
@@ -70,6 +73,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10;
bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService();
@@ -85,6 +92,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadRouteHashSizePreference();
_loadImagePreferences();
_loadFastLocationSettings();
_loadDeveloperMode();
_loadOnlineTraceCacheStatus();
}
@override
@@ -114,6 +123,56 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _loadDeveloperMode() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = false;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
return;
}
final nextTapCount = _versionTapCount + 1;
if (nextTapCount >= 3) {
await DeveloperModeService.setEnabled(true);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = true;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
return;
}
if (!mounted) return;
setState(() {
_versionTapCount = nextTapCount;
});
}
Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value);
@@ -424,7 +483,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// Check for app updates and show notification or dialog
Future<void> _checkForUpdates() async {
// Only on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -783,6 +842,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Future<void> _clearOnlineTraceCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear online trace database'),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_isClearingOnlineTraceCache = true;
});
await MeshMapNodesService.clearCache();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = null;
_isClearingOnlineTraceCache = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Online trace database cleared'),
backgroundColor: Colors.orange,
),
);
}
String _onlineTraceCacheSubtitle() {
final cachedAt = _onlineTraceCacheUpdatedAt;
if (cachedAt == null) {
return 'No cached online database. Refresh runs in background when internet is available.';
}
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int part) => part.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>(
context: context,
@@ -929,6 +1048,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: _showRouteHashSizeDialog,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.swap_horiz),
title: const Text('Auto route rotation'),
subtitle: const Text(
'Rotate between best known direct paths and flood mode for room/contact sends',
),
value: appProvider.autoRouteRotationEnabled,
onChanged: (value) async {
await appProvider.toggleAutoRouteRotationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Nearest repeater fallback'),
subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater',
),
value: appProvider.nearestRelayFallbackEnabled,
onChanged: (value) async {
await appProvider.toggleNearestRelayFallbackEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Clear path on max retry'),
subtitle: const Text(
'Clear the route only after all retries and final router fallback fail',
),
value: appProvider.clearPathOnMaxRetry,
onChanged: (value) async {
await appProvider.toggleClearPathOnMaxRetry(value);
},
),
),
ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(
@@ -938,6 +1096,63 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => ListTile(
leading: const Icon(Icons.format_size),
title: const Text('Message font size'),
subtitle: Text(
'${(appProvider.messageFontScale * 100).round()}% of default',
),
trailing: SizedBox(
width: 150,
child: Slider(
value: appProvider.messageFontScale,
min: 0.85,
max: 1.4,
divisions: 11,
label: '${(appProvider.messageFontScale * 100).round()}%',
onChanged: (value) {
appProvider.setMessageFontScale(value);
},
),
),
),
),
]),
_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'),
@@ -1106,8 +1321,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
if (!kIsWeb) ...[
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
@@ -1197,6 +1414,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...',
),
onTap: _handleVersionTap,
),
ListTile(
leading: const Icon(Icons.badge),
@@ -1212,7 +1430,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showAboutDialog(),
),
]),
if (Platform.isAndroid)
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon(

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../services/wizard_preferences.dart';
import '../widgets/connection_dialog.dart';
/// Welcome wizard screen to introduce new users to the app
class WelcomeWizardScreen extends StatefulWidget {
@@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget {
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
final PageController _pageController = PageController();
final TextEditingController _deviceNameController = TextEditingController();
int _currentPage = 0;
static const int _totalPages = 5;
bool _isApplyingDeviceSetup = false;
static const int _totalPages = 6;
static const List<_RadioPreset> _radioPresets = [
// Official MeshCore config presets from https://api.meshcore.nz/api/v1/config
_RadioPreset(
id: 'australia',
label: 'Australia',
summary: '915.800 MHz, BW 250 kHz, SF10, CR5',
frequencyKhz: 915800,
bandwidth: 8,
spreadingFactor: 10,
codingRate: 5,
),
_RadioPreset(
id: 'australia_narrow',
label: 'Australia (Narrow)',
summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8',
frequencyKhz: 916575,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 8,
),
_RadioPreset(
id: 'australia_sa_wa',
label: 'Australia: SA, WA',
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 923125,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'australia_qld',
label: 'Australia: QLD',
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5',
frequencyKhz: 923125,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 5,
),
_RadioPreset(
id: 'eu_uk_narrow',
label: 'EU/UK (Narrow)',
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'eu_uk_deprecated',
label: 'EU/UK (Deprecated)',
summary: '869.525 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 869525,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'czech_republic_narrow',
label: 'Czech Republic (Narrow)',
summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 869432,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'eu_433_long_range',
label: 'EU 433MHz (Long Range)',
summary: '433.650 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 433650,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'new_zealand',
label: 'New Zealand',
summary: '917.375 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 917375,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'new_zealand_narrow',
label: 'New Zealand (Narrow)',
summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 917375,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'portugal_433',
label: 'Portugal 433',
summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6',
frequencyKhz: 433375,
bandwidth: 6,
spreadingFactor: 9,
codingRate: 6,
),
_RadioPreset(
id: 'portugal_868',
label: 'Portugal 868',
summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 6,
),
_RadioPreset(
id: 'switzerland',
label: 'Switzerland',
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'usa_canada_recommended',
label: 'USA/Canada (Recommended)',
summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 910525,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'vietnam_narrow',
label: 'Vietnam (Narrow)',
summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5',
frequencyKhz: 920250,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 5,
),
_RadioPreset(
id: 'vietnam_deprecated',
label: 'Vietnam (Deprecated)',
summary: '920.250 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 920250,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
];
_RadioPreset _selectedPreset = _radioPresets[4];
@override
void initState() {
super.initState();
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_deviceNameController.text =
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
}
@override
void dispose() {
_pageController.dispose();
_deviceNameController.dispose();
super.dispose();
}
@@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
}
void _nextPage() {
if (_currentPage < _totalPages - 1) {
if (_currentPage == 1) {
_handleDeviceSetupAction();
} else if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
@@ -56,6 +220,98 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
}
}
Future<void> _openConnectionDialog() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const ConnectionDialog(),
);
if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.refreshDeviceInfo();
}
if (!mounted) return;
final deviceInfo = connectionProvider.deviceInfo;
final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
if (fetchedName.isNotEmpty) {
setState(() {
_deviceNameController.text = fetchedName;
});
} else {
setState(() {});
}
}
Future<void> _handleDeviceSetupAction() async {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
if (!deviceInfo.isConnected) {
await _openConnectionDialog();
return;
}
final trimmedName = _deviceNameController.text.trim();
if (trimmedName.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.wizardEnterDeviceName)));
return;
}
setState(() {
_isApplyingDeviceSetup = true;
});
try {
await connectionProvider.setAdvertName(trimmedName);
await connectionProvider.setRadioParams(
frequency: _selectedPreset.frequencyKhz,
bandwidth: _selectedPreset.bandwidth,
spreadingFactor: _selectedPreset.spreadingFactor,
codingRate: _selectedPreset.codingRate,
);
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.wizardDeviceSetupSaved(trimmedName, _selectedPreset.label),
),
backgroundColor: Colors.green,
),
);
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.failedToSave(e.toString())),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isApplyingDeviceSetup = false;
});
}
}
}
void _skipDeviceSetupStep() {
if (_isApplyingDeviceSetup) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -68,7 +324,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
children: [
// Top bar with skip button
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -96,8 +352,10 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
child: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildWelcomePage(context, l10n, colorScheme),
_buildConnectDevicePage(context, l10n, colorScheme),
_buildConnectingPage(context, l10n, colorScheme),
_buildChannelPage(context, l10n, colorScheme),
_buildContactsPage(context, l10n, colorScheme),
@@ -108,7 +366,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
// Page indicators
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
@@ -130,11 +388,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
// Next/Get Started button
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _nextPage,
onPressed: _isApplyingDeviceSetup ? null : _nextPage,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16.0),
shape: RoundedRectangleBorder(
@@ -142,7 +400,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
),
),
child: Text(
_currentPage < _totalPages - 1
_currentPage == 1
? _deviceSetupButtonLabel(context)
: _currentPage < _totalPages - 1
? l10n.wizardNext
: l10n.wizardGetStarted,
style: const TextStyle(fontSize: 16),
@@ -165,7 +425,21 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
icon: Icons.waving_hand,
iconColor: Colors.orange,
title: l10n.wizardWelcomeTitle,
description: l10n.wizardWelcomeDescription,
description: l10n.wizardOverviewDescription,
features: [
_FeatureItem(
icon: Icons.chat_bubble_outline,
text: l10n.wizardOverviewFeature1,
),
_FeatureItem(
icon: Icons.emergency_share,
text: l10n.wizardOverviewFeature2,
),
_FeatureItem(
icon: Icons.settings_input_antenna,
text: l10n.wizardOverviewFeature3,
),
],
colorScheme: colorScheme,
);
}
@@ -176,33 +450,225 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.bluetooth_searching,
icon: Icons.forum_rounded,
iconColor: Colors.blue,
title: l10n.wizardConnectingTitle,
description: l10n.wizardConnectingDescription,
title: l10n.wizardMessagingTitle,
description: l10n.wizardMessagingDescription,
features: [
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
],
colorScheme: colorScheme,
);
}
Widget _buildConnectDevicePage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
final connectionProvider = context.watch<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final isConnected = deviceInfo.isConnected;
final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName;
return LayoutBuilder(
builder: (context, constraints) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 600,
maxHeight: constraints.maxHeight,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPageHeader(
context: context,
colorScheme: colorScheme,
icon: Icons.bluetooth_connected,
iconColor: Colors.green,
title: l10n.wizardConnectDeviceTitle,
description: l10n.wizardConnectDeviceDescription,
badge: l10n.wizardSetupBadge,
),
const SizedBox(height: 16),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
isConnected
? Icons.check_circle
: Icons.bluetooth_searching,
color: isConnected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isConnected
? l10n.wizardConnectedToDevice(
connectedName ?? l10n.device,
)
: l10n.wizardNoDeviceConnected,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
TextButton(
onPressed: _openConnectionDialog,
child: Text(
isConnected ? l10n.change : l10n.connect,
),
),
],
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _skipDeviceSetupStep,
child: Text(l10n.wizardSkipForNow),
),
),
],
),
),
const SizedBox(height: 12),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _deviceNameController,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: l10n.wizardDeviceNameLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardDeviceNameHelp,
),
),
const SizedBox(height: 12),
DropdownButtonFormField<_RadioPreset>(
initialValue: _selectedPreset,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.wizardConfigRegionLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardConfigRegionHelp,
),
items: _radioPresets
.map(
(preset) => DropdownMenuItem<_RadioPreset>(
value: preset,
child: Text(
preset.label,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: (preset) {
if (preset == null) return;
setState(() {
_selectedPreset = preset;
});
},
),
],
),
),
const SizedBox(height: 16),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedPreset.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
_selectedPreset.summary,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
...[
_FeatureItem(
icon: Icons.rule,
text: l10n.wizardPresetNote1,
),
_FeatureItem(
icon: Icons.settings_input_antenna,
text: l10n.wizardPresetNote2,
),
_FeatureItem(
icon: Icons.public,
text: l10n.wizardPresetNote3,
),
].map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: _buildFeatureRow(
context,
colorScheme,
feature,
iconSize: 20,
gap: 12,
),
),
),
],
),
),
],
),
),
),
),
);
}
String _deviceSetupButtonLabel(BuildContext context) {
final isConnected = context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected;
if (_isApplyingDeviceSetup) {
return AppLocalizations.of(context)!.wizardSaving;
}
final l10n = AppLocalizations.of(context)!;
return isConnected
? l10n.wizardSaveAndContinue
: l10n.wizardConnectDeviceTitle;
}
Widget _buildChannelPage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.campaign,
icon: Icons.groups_rounded,
iconColor: Colors.purple,
title: l10n.wizardChannelTitle,
description: l10n.wizardChannelDescription,
title: l10n.wizardNetworkTitle,
description: l10n.wizardNetworkDescription,
features: [
_FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
_FeatureItem(
icon: Icons.person_add_alt_1,
text: l10n.wizardNetworkFeature1,
),
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
],
colorScheme: colorScheme,
);
@@ -214,17 +680,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.people,
icon: Icons.map_rounded,
iconColor: Colors.teal,
title: l10n.wizardContactsTitle,
description: l10n.wizardContactsDescription,
title: l10n.wizardMapOpsTitle,
description: l10n.wizardMapOpsDescription,
features: [
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
_FeatureItem(
icon: Icons.battery_std,
text: l10n.wizardContactsFeature3,
),
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
],
colorScheme: colorScheme,
);
@@ -236,18 +699,17 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.map,
icon: Icons.tune_rounded,
iconColor: Colors.red,
title: l10n.wizardMapTitle,
description: l10n.wizardMapDescription,
title: l10n.wizardToolsTitle,
description: l10n.wizardToolsDescription,
features: [
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
_FeatureItem(
icon: Icons.person_pin_circle,
text: l10n.wizardMapFeature2,
icon: Icons.analytics_outlined,
text: l10n.wizardToolsFeature3,
),
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
],
colorScheme: colorScheme,
);
@@ -261,69 +723,149 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
List<_FeatureItem>? features,
required ColorScheme colorScheme,
}) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 20),
// Icon
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 80, color: iconColor),
final l10n = AppLocalizations.of(context)!;
return LayoutBuilder(
builder: (context, constraints) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: constraints.maxHeight,
),
const SizedBox(height: 32),
// Title
Text(
title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 32),
// Features list
...features.map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(feature.icon, color: colorScheme.primary, size: 24),
const SizedBox(width: 16),
Expanded(
child: Text(
feature.text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildPageHeader(
context: context,
colorScheme: colorScheme,
icon: icon,
iconColor: iconColor,
title: title,
description: description,
badge: l10n.wizardOverviewBadge,
),
),
if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 18),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
children: features
.map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: _buildFeatureRow(
context,
colorScheme,
feature,
),
),
)
.toList(),
),
),
],
],
),
],
const SizedBox(height: 20),
],
),
),
),
);
}
Widget _buildPageHeader({
required BuildContext context,
required ColorScheme colorScheme,
required IconData icon,
required Color iconColor,
required String title,
required String description,
required String badge,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badge,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 56, color: iconColor),
),
const SizedBox(height: 18),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
height: 1.4,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildContentCard({
required ColorScheme colorScheme,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: child,
);
}
Widget _buildFeatureRow(
BuildContext context,
ColorScheme colorScheme,
_FeatureItem feature, {
double iconSize = 22,
double gap = 14,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(feature.icon, color: colorScheme.primary, size: iconSize),
SizedBox(width: gap),
Expanded(
child: Text(
feature.text,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
),
),
],
);
}
}
class _FeatureItem {
@@ -332,3 +874,23 @@ class _FeatureItem {
_FeatureItem({required this.icon, required this.text});
}
class _RadioPreset {
final String id;
final String label;
final String summary;
final int frequencyKhz;
final int bandwidth;
final int spreadingFactor;
final int codingRate;
const _RadioPreset({
required this.id,
required this.label,
required this.summary,
required this.frequencyKhz,
required this.bandwidth,
required this.spreadingFactor,
required this.codingRate,
});
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -58,6 +59,16 @@ class BackgroundLocationService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
debugPrint(
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
);
return false;
}
}
// Save settings
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true);
@@ -68,8 +79,7 @@ class BackgroundLocationService {
try {
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceThreshold.toInt(),
),
).listen((Position position) async {
@@ -171,4 +181,22 @@ class BackgroundLocationService {
await startTracking(distanceThreshold: distance);
}
}
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
);
}
}

View File

@@ -1,6 +1,5 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
/// Service for accessing build information from native platform code
/// Currently supports Android only - returns "unknown" for other platforms
@@ -25,7 +24,7 @@ class BuildInfoService {
}
// Only Android has the platform channel implementation
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
_cachedCommitHash = 'unknown';
return _cachedCommitHash!;

View File

@@ -0,0 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
class DeveloperModeService {
static const String _developerModeKey = 'developer_mode_enabled';
static Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_developerModeKey) ?? false;
}
static Future<void> setEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_developerModeKey, enabled);
}
}

View File

@@ -15,6 +15,10 @@ class LocalePreferences {
Locale('fr'), // French
Locale('it'), // Italian
Locale('el'), // Greek
Locale('pt'), // Portuguese
Locale('tr'), // Turkish
Locale('pl'), // Polish
Locale('uk'), // Ukrainian
Locale('ru'), // Russian
Locale('zh'), // Chinese
];
@@ -66,6 +70,14 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Greek';
case 'pt':
return 'Portuguese';
case 'tr':
return 'Turkish';
case 'pl':
return 'Polish';
case 'uk':
return 'Ukrainian';
case 'ru':
return 'Русский';
case 'zh':
@@ -94,6 +106,14 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Ελληνικά';
case 'pt':
return 'Português';
case 'tr':
return 'Türkçe';
case 'pl':
return 'Polski';
case 'uk':
return 'Українська';
case 'ru':
return 'Русский';
case 'zh':

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -148,6 +149,10 @@ class LocationTrackingService {
/// Check if location permissions are granted
Future<bool> checkPermissions() async {
final permission = await Geolocator.checkPermission();
if (Platform.isIOS) {
return permission == LocationPermission.always;
}
return permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
}
@@ -181,6 +186,16 @@ class LocationTrackingService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
onError?.call(
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
);
return false;
}
}
debugPrint('✅ [LocationTracking] Location permissions granted');
return true;
}
@@ -209,9 +224,9 @@ class LocationTrackingService {
}
final position = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
timeLimit: timeLimit,
distanceFilter: 0,
),
);
@@ -255,13 +270,35 @@ class LocationTrackingService {
/// [distanceFilter] - Minimum distance in meters between position updates
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceFilter.toInt(),
),
);
}
LocationSettings _buildLocationSettings({
int distanceFilter = 10,
Duration? timeLimit,
}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
timeLimit: timeLimit,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
timeLimit: timeLimit,
);
}
// ============================================================================
// Tracking Control
// ============================================================================

View File

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

View File

@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
/// Destination types
static const String destinationTypeAll = 'all';
static const String destinationTypeChannel = 'channel';
static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room';
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
final publicKey = prefs.getString(_recipientPublicKeyKey);
return {
'type': type,
'publicKey': ?publicKey,
};
return {'type': type, 'publicKey': ?publicKey};
}
/// Save the selected destination
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
static Future<void> setDestination(
String type, {
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
/// Get display name for destination type
static String getDestinationTypeName(String type) {
switch (type) {
case destinationTypeAll:
return 'All';
case destinationTypeChannel:
return 'Channel';
case destinationTypeContact:

View File

@@ -5,6 +5,7 @@ import '../models/message.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
@@ -16,6 +17,8 @@ class MessageStorageService {
'stored_message_reception_details';
static const String _messageTransferDetailsKey =
'stored_message_transfer_details';
static const String _messageRouteMetadataKey =
'stored_message_route_metadata';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
@@ -24,6 +27,7 @@ class MessageStorageService {
Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {},
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
}) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -44,6 +48,7 @@ class MessageStorageService {
final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{};
final transferJson = <String, dynamic>{};
final routeMetadataJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson();
@@ -59,6 +64,11 @@ class MessageStorageService {
transferJson[entry.key] = entry.value.toJson();
}
}
for (final entry in messageRouteMetadata.entries) {
if (retainedMessageIds.contains(entry.key)) {
routeMetadataJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_messageContactLocationsKey,
jsonEncode(locationJson),
@@ -71,6 +81,10 @@ class MessageStorageService {
_messageTransferDetailsKey,
jsonEncode(transferJson),
);
await prefs.setString(
_messageRouteMetadataKey,
jsonEncode(routeMetadataJson),
);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -170,6 +184,32 @@ class MessageStorageService {
}
}
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageRouteMetadataKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageRouteMetadata>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
result[entry.key] = MessageRouteMetadata.fromJson(value);
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
return const {};
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
@@ -206,6 +246,7 @@ class MessageStorageService {
await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');

View File

@@ -0,0 +1,46 @@
import 'package:shared_preferences/shared_preferences.dart';
class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey =
'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_autoRouteRotationKey) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_autoRouteRotationKey, enabled);
}
static Future<bool> getClearPathOnMaxRetry() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
}
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
}
static Future<bool> getNearestRelayFallbackEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_nearestRelayFallbackKey) ??
defaultNearestRelayFallbackEnabled;
}
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_nearestRelayFallbackKey, enabled);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:geolocator/geolocator.dart';
import '../models/contact.dart';
class NearestRouterSelector {
const NearestRouterSelector();
Contact? select({
required Position? senderPosition,
required List<Contact> repeaters,
required Contact recipient,
}) {
if (senderPosition == null) {
return null;
}
final eligible = repeaters.where((contact) {
if (contact.publicKeyHex == recipient.publicKeyHex) {
return false;
}
if (!contact.isRecentlySeen) {
return false;
}
return contact.displayLocation != null;
}).toList();
if (eligible.isEmpty) {
return null;
}
eligible.sort((a, b) {
final locationA = a.displayLocation!;
final locationB = b.displayLocation!;
final distanceA = Geolocator.distanceBetween(
senderPosition.latitude,
senderPosition.longitude,
locationA.latitude,
locationA.longitude,
);
final distanceB = Geolocator.distanceBetween(
senderPosition.latitude,
senderPosition.longitude,
locationB.latitude,
locationB.longitude,
);
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) {
return distanceCompare;
}
final advertCompare = b.lastAdvert.compareTo(a.lastAdvert);
if (advertCompare != 0) {
return advertCompare;
}
final hopCompare = a.routeHopCount.compareTo(b.routeHopCount);
if (hopCompare != 0) {
return hopCompare;
}
final nameCompare = a.advName.compareTo(b.advName);
if (nameCompare != 0) {
return nameCompare;
}
return a.publicKeyHex.compareTo(b.publicKeyHex);
});
return eligible.first;
}
}

View File

@@ -402,10 +402,13 @@ class NotificationService {
(DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final resolvedChannelName = channelName?.trim().isNotEmpty == true
? channelName!.trim()
: (localizations?.publicChannel ?? 'Public');
final title = isChannelMessage
? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}'
: 'Channel: ${channelName ?? "Public"}')
? '${localizations.channel}: $resolvedChannelName'
: 'Channel: $resolvedChannelName')
: (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName');
@@ -550,7 +553,7 @@ class NotificationService {
final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion'
'${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details

View File

@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
File? _file;
Future<File> _resolveFile() async {
if (kIsWeb) {
throw UnsupportedError('Packet capture file storage is not supported on web');
}
if (_file != null) return _file!;
final dir = await getApplicationSupportDirectory();
final file = File('${dir.path}/$_fileName');
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
Future<void> appendLogs(List<BlePacketLog> logs) async {
if (logs.isEmpty) return;
if (kIsWeb) return;
try {
final file = await _resolveFile();
final sink = file.openWrite(mode: FileMode.append);
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
}
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
if (kIsWeb) return const [];
try {
final file = await _resolveFile();
if (!await file.exists()) return const [];
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
}
Future<int> count() async {
if (kIsWeb) return 0;
try {
final file = await _resolveFile();
if (!await file.exists()) return 0;
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
}
Future<void> clear() async {
if (kIsWeb) return;
try {
final file = await _resolveFile();
if (await file.exists()) {

View File

@@ -0,0 +1,273 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart';
class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1';
static const int _maxDirectPaths = 20;
static const int _topRotationCount = 3;
final Map<String, ContactPathHistory> _cache = {};
bool _isLoaded = false;
Future<void> initialize() async {
if (_isLoaded) return;
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) {
_isLoaded = true;
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
}
}
}
} catch (error) {
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
}
_isLoaded = true;
}
Future<void> recordLearnedPath(Contact contact) async {
await initialize();
if (!contact.routeHasPath || contact.routeHopCount <= 0) {
return;
}
final history = _historyFor(contact.publicKeyHex);
final signature = _signature(contact.routePathBytes);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contact.publicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<void> recordReceivedBytePath(
String contactPublicKeyHex,
List<int> pathBytes,
int hashSize,
) async {
await initialize();
if (pathBytes.isEmpty) {
return;
}
if (hashSize < 1 || hashSize > 3) {
return;
}
if (pathBytes.length % hashSize != 0) {
return;
}
final history = _historyFor(contactPublicKeyHex);
final signature = pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: List<int>.from(pathBytes),
hopCount: pathBytes.length ~/ hashSize,
hashSize: hashSize,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<PathSelection> getSelectionForContact(
Contact contact, {
required bool autoRouteRotationEnabled,
}) async {
await initialize();
await recordLearnedPath(contact);
if (contact.routeHasPath && contact.routeHopCount > 0) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (!autoRouteRotationEnabled) {
return PathSelection.flood();
}
final history = _historyFor(contact.publicKeyHex);
final ranked = List<PathRecord>.from(history.directPaths)
..sort(_comparePathRecords);
final topPaths = ranked.take(_topRotationCount).toList();
if (topPaths.isEmpty) {
final nextFloodHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, nextFloodHistory);
return PathSelection.flood();
}
final selections =
topPaths
.map(
(record) => PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
),
)
.toList()
..add(PathSelection.flood());
final index = history.rotationIndex % selections.length;
final updatedHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, updatedHistory);
return selections[index];
}
Future<void> recordPathResult(
String contactPublicKeyHex,
PathSelection selection, {
required bool success,
int? roundTripTimeMs,
}) async {
await initialize();
final history = _historyFor(contactPublicKeyHex);
if (selection.usesFlood) {
final current = history.floodStats;
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
floodStats: current.copyWith(
successCount: current.successCount + (success ? 1 : 0),
failureCount: current.failureCount + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? current.lastRoundTripTimeMs)
: current.lastRoundTripTimeMs,
lastUsedAt: DateTime.now(),
),
),
);
return;
}
final signature = _signature(selection.pathBytes);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount,
hashSize: selection.hashSize,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0),
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
ContactPathHistory historyFor(String contactPublicKeyHex) {
return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex);
}
ContactPathHistory _historyFor(String contactPublicKeyHex) {
return _cache.putIfAbsent(
contactPublicKeyHex,
() => ContactPathHistory.empty(contactPublicKeyHex),
);
}
Future<void> _saveHistory(
String contactPublicKeyHex,
ContactPathHistory history,
) async {
_cache[contactPublicKeyHex] = history;
final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{};
for (final entry in _cache.entries) {
payload[entry.key] = entry.value.toJson();
}
await prefs.setString(_storageKey, jsonEncode(payload));
}
List<PathRecord> _upsertDirectPath(
List<PathRecord> existing,
PathRecord updatedRecord,
) {
final updated = List<PathRecord>.from(existing)
..removeWhere((record) => record.signature == updatedRecord.signature)
..insert(0, updatedRecord);
if (updated.length > _maxDirectPaths) {
return updated.take(_maxDirectPaths).toList();
}
return updated;
}
int _comparePathRecords(PathRecord a, PathRecord b) {
final successRateCompare = b.successRate.compareTo(a.successRate);
if (successRateCompare != 0) return successRateCompare;
final successCountCompare = b.successCount.compareTo(a.successCount);
if (successCountCompare != 0) return successCountCompare;
final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs;
final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs;
final rttCompare = aRtt.compareTo(bRtt);
if (rttCompare != 0) return rttCompare;
return b.lastUsedAt.compareTo(a.lastUsedAt);
}
PathRecord? _findDirectPath(List<PathRecord> records, String signature) {
for (final record in records) {
if (record.signature == signature) {
return record;
}
}
return null;
}
String _signature(Uint8List bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}

View File

@@ -1,4 +1,6 @@
class AvatarLabelHelper {
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
static String buildLabel(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
return '#${_take(hashBody, 2)}'.toUpperCase();
}
final parts = trimmed
.split(RegExp(r'[\s_-]+'))
final parts = _alnumChunks
.allMatches(trimmed)
.map((match) => match.group(0)!)
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return '?';
}
if (parts.length >= 2) {
final first = _take(parts[0], 1);
final second = _take(parts[1], 1);
return '$first$second'.toUpperCase();
}
return _take(trimmed, 2).toUpperCase();
return _take(parts.first, 2).toUpperCase();
}
static String _take(String value, int count) {

View File

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

View File

@@ -4,38 +4,51 @@ import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> pathHashes;
final int pathDescriptor;
final List<int> pathBytes;
final int hashSize;
const DecodedLogRxRoute({
required this.payloadType,
required this.pathHashes,
required this.pathDescriptor,
required this.pathBytes,
required this.hashSize,
});
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
List<String> get hopHashes =>
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
int get hopCount => hopHashes.length;
String? get originalSenderHashHex =>
hopHashes.isEmpty ? null : hopHashes.first;
}
class ResolvedNodeHash {
final int hash;
final String hashHex;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
const ResolvedNodeHash({
required this.hash,
required this.hashHex,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
});
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
String get hexLabel => '0x${hashHex.toUpperCase()}';
}
class LogRxRouteDecoder {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) {
static DecodedLogRxRoute? decode(
Uint8List rawData, {
int? preferredHashSize,
}) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
@@ -52,30 +65,109 @@ class LogRxRouteDecoder {
}
if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null;
final pathDescriptor = rawPacketData[index++];
final pathMode = (pathDescriptor & 0xFF) >> 6;
final pathByteLen = pathMode == 0
? pathDescriptor
: descriptorByteLength(pathDescriptor);
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
return null;
}
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
final hashSize = pathMode == 0
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
: (descriptorHashSize(pathDescriptor) ??
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
return DecodedLogRxRoute(
payloadType: payloadType,
pathHashes: rawPacketData.sublist(index, index + pathLen),
pathDescriptor: pathDescriptor,
pathBytes: pathBytes,
hashSize: hashSize,
);
}
static int? descriptorHashSize(int pathDescriptor) {
final normalized = pathDescriptor & 0xFF;
final mode = normalized >> 6;
if (mode == 3) return null;
return mode + 1;
}
static int? descriptorHopCount(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
if (hashSize == null) return null;
return (pathDescriptor & 0xFF) & 0x3F;
}
static int? descriptorByteLength(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
final hopCount = descriptorHopCount(pathDescriptor);
if (hashSize == null || hopCount == null) return null;
final byteLen = hopCount * hashSize;
return byteLen <= 64 ? byteLen : null;
}
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
if (pathBytes.isEmpty) return 1;
final normalizedPreferred =
preferredHashSize != null &&
preferredHashSize >= 1 &&
preferredHashSize <= 3
? preferredHashSize
: null;
if (normalizedPreferred != null &&
pathBytes.length % normalizedPreferred == 0) {
return normalizedPreferred;
}
for (final candidate in const [3, 2, 1]) {
if (pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
static List<String> splitHopHashes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.toList();
}
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(),
);
}
return hops;
}
static ResolvedNodeHash resolveHash(
int hash, {
String hashHex, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
}) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
? ownPublicKey.first
: null;
if (ownHash == hash) {
final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey);
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
@@ -84,12 +176,12 @@ class LogRxRouteDecoder {
}
final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
@@ -99,7 +191,7 @@ class LogRxRouteDecoder {
if (matches.length == 1) {
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
@@ -119,11 +211,19 @@ class LogRxRouteDecoder {
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: label,
isOwnNode: false,
isUniqueMatch: false,
matchCount: matches.length,
);
}
static String? _bytesToHex(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
}

View File

@@ -1,12 +1,17 @@
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../models/message.dart';
import '../l10n/app_localizations.dart';
import '../providers/messages_provider.dart';
/// Extension for Message to provide localized delivery status
extension MessageLocalization on Message {
/// Get localized delivery status text
String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(id);
// For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
@@ -26,29 +31,51 @@ extension MessageLocalization on Message {
case MessageDeliveryStatus.sending:
if (isContactMessage) {
if (retryAttempt > 0) {
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
return l10n.pending;
return routeMetadata == null
? l10n.pending
: '${l10n.pending}${routeMetadata.modeLabel}';
}
return l10n.sending;
case MessageDeliveryStatus.sent:
return l10n.sent;
return routeMetadata == null
? l10n.sent
: '${l10n.sent}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.delivered:
if (retryAttempt > 0 && roundTripTimeMs != null) {
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
if (retryAttempt > 0) {
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
if (roundTripTimeMs != null) {
return l10n.deliveredWithTime(roundTripTimeMs!);
return routeMetadata == null
? l10n.deliveredWithTime(roundTripTimeMs!)
: '${l10n.deliveredWithTime(roundTripTimeMs!)}${routeMetadata.modeLabel}';
}
return l10n.delivered;
return routeMetadata == null
? l10n.delivered
: '${l10n.delivered}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.failed:
if (retryAttempt > 0) {
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
return l10n.failed;
return routeMetadata == null
? l10n.failed
: '${l10n.failed}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.received:
return '';
}

View File

@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji;
if (emoji != null && emoji.isNotEmpty) {
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room;
bool get _showsLeadingEmoji {
final emoji = contact.roleEmoji;
if (emoji == null || emoji.isEmpty) return false;
final effectiveName = (displayName ?? contact.displayName).trimLeft();
return effectiveName.startsWith(emoji);
}
Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
return TrailColorService.getTrailColor(contact);
}

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
}
Future<void> _loadLocalIPs() async {
if (kIsWeb) {
return;
}
final Set<String> ipsSet = {};
try {

View File

@@ -35,16 +35,17 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
/// Validate channel name
String? _validateName(String? value) {
final l10n = AppLocalizations.of(context)!;
final trimmedValue = value?.trim() ?? '';
if (value == null || value.trim().isEmpty) {
if (trimmedValue.isEmpty) {
return l10n.channelNameRequired;
}
if (value.length > 31) {
if (trimmedValue.length > 31) {
return l10n.channelNameTooLong;
}
if (!_isAscii(value)) {
if (!_isAscii(trimmedValue)) {
return l10n.invalidAsciiCharacters;
}
@@ -105,7 +106,8 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isHashChannel = _nameController.text.startsWith('#');
final normalizedName = _nameController.text.trimLeft();
final isHashChannel = normalizedName.startsWith('#');
return AlertDialog(
title: Text(l10n.addChannel),
@@ -163,8 +165,15 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
enabled: !_isCreating,
maxLength: 31,
validator: _validateName,
textInputAction: TextInputAction.next,
textInputAction: isHashChannel
? TextInputAction.done
: TextInputAction.next,
onChanged: (_) => setState(() {}), // Rebuild to update icon
onFieldSubmitted: (_) {
if (isHashChannel) {
_handleCreate();
}
},
),
// Channel Secret Field (only show for private channels)
if (!isHashChannel) ...[

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../../services/route_hash_preferences.dart';
class ContactRouteDialogResult {
@@ -57,6 +59,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute;
String? _errorText;
bool _showRoutingInfo = false;
@override
void initState() {
@@ -133,6 +136,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
@override
Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>();
final routeCandidates =
widget.availableContacts
.where((contact) => contact.isRepeater || contact.isRoom)
@@ -184,6 +188,19 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
),
],
const SizedBox(height: 16),
_AutomationRoutingInfo(
isExpanded: _showRoutingInfo,
onToggle: () {
setState(() {
_showRoutingInfo = !_showRoutingInfo;
});
},
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled:
appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
),
const SizedBox(height: 16),
Text(
'Pick hops from contacts',
style: Theme.of(context).textTheme.labelLarge,
@@ -203,10 +220,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(candidate.displayName),
subtitle: Text(
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
style: const TextStyle(fontFamily: 'monospace'),
),
trailing: TextButton(
onPressed: () => _appendHop(candidate),
child: Text(
@@ -248,3 +261,134 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
);
}
}
class _AutomationRoutingInfo extends StatelessWidget {
final bool isExpanded;
final VoidCallback onToggle;
final bool autoRouteRotationEnabled;
final bool nearestRelayFallbackEnabled;
final bool clearPathOnMaxRetry;
const _AutomationRoutingInfo({
required this.isExpanded,
required this.onToggle,
required this.autoRouteRotationEnabled,
required this.nearestRelayFallbackEnabled,
required this.clearPathOnMaxRetry,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Automatic direct-send routing',
style: Theme.of(context).textTheme.titleSmall,
),
),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: colorScheme.primary,
),
],
),
),
),
if (isExpanded) ...[
const SizedBox(height: 8),
Text(
'Room/contact sends keep one selected path for the whole send chain, retry up to 5 total attempts with 1s, 2s, 4s, and 8s backoff, then try one final nearest repeater if everything else fails.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Text(
'Public and channel broadcasts are not affected by this automation.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InfoChip(
label: autoRouteRotationEnabled
? 'Auto route rotation on'
: 'Auto route rotation off',
icon: Icons.swap_horiz,
),
_InfoChip(
label: nearestRelayFallbackEnabled
? 'Nearest repeater fallback on'
: 'Nearest repeater fallback off',
icon: Icons.router,
),
_InfoChip(
label: clearPathOnMaxRetry
? 'Clear path on max retry on'
: 'Clear path on max retry off',
icon: Icons.route,
),
],
),
] else ...[
const SizedBox(height: 6),
Text(
'Shows retry, rotation, and final repeater fallback behavior.',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
);
}
}
class _InfoChip extends StatelessWidget {
final String label;
final IconData icon;
const _InfoChip({required this.label, required this.icon});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Theme.of(context).colorScheme.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/location_trail.dart';
import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart';
@@ -66,6 +69,31 @@ class TrailControls extends StatelessWidget {
const Divider(),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
OutlinedButton.icon(
onPressed: () => _importTrail(context, mapProvider),
icon: const Icon(Icons.file_upload_outlined),
label: Text(l10n.importFromClipboard),
),
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.length >= 2)
OutlinedButton.icon(
onPressed: () => _exportTrail(
context,
mapProvider.currentTrail!,
customName: 'My Trail',
),
icon: const Icon(Icons.file_download_outlined),
label: Text(l10n.exportToClipboard),
),
],
),
const SizedBox(height: 16),
// Trail stats
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
@@ -104,8 +132,6 @@ class TrailControls extends StatelessWidget {
),
),
const SizedBox(height: 16),
// Clear trail button
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
@@ -238,7 +264,17 @@ class TrailControls extends StatelessWidget {
),
],
),
title: Text(contact.displayName),
title: Row(
children: [
Expanded(child: Text(contact.displayName)),
IconButton(
tooltip: l10n.exportToClipboard,
icon: const Icon(Icons.file_download_outlined),
onPressed: () =>
_exportContactTrail(context, contact),
),
],
),
subtitle: Text(
'${contact.advertHistory.length} points',
),
@@ -266,6 +302,92 @@ class TrailControls extends StatelessWidget {
);
}
Future<void> _importTrail(
BuildContext context,
MapProvider mapProvider,
) async {
final l10n = AppLocalizations.of(context)!;
try {
final importedTrail = await GpxService.importTrailFromFile();
if (importedTrail == null || !context.mounted) {
return;
}
mapProvider.replaceCurrentTrailWithImport(importedTrail);
mapProvider.clearImportedTrail();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.locationTrail}: ${importedTrail.points.length} ${l10n.points.toLowerCase()}',
),
),
);
} catch (error) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.importFailed(error.toString()))),
);
}
}
Future<void> _exportTrail(
BuildContext context,
LocationTrail trail, {
String? customName,
}) async {
final l10n = AppLocalizations.of(context)!;
try {
final success = await GpxService.exportTrailToFile(
trail,
customName: customName,
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.exportFailed('unable to share GPX'))),
);
}
} catch (error) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.exportFailed(error.toString()))),
);
}
}
Future<void> _exportContactTrail(BuildContext context, Contact contact) async {
await _exportTrail(
context,
_buildContactTrail(contact),
customName: '${contact.displayName} Trail',
);
}
LocationTrail _buildContactTrail(Contact contact) {
final sortedHistory = [...contact.advertHistory]
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
return LocationTrail(
id: 'contact_${contact.publicKeyHex}',
isActive: false,
startTime: sortedHistory.first.timestamp,
endTime: sortedHistory.last.timestamp,
points: sortedHistory
.map(
(advert) => TrailPoint(
position: advert.location,
timestamp: advert.timestamp,
),
)
.toList(),
);
}
void _showClearConfirmation(
BuildContext context,
MapProvider mapProvider,

View File

@@ -403,15 +403,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}
}
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) {
_showToast(
'Image fetch over ${sender.routeHopCount} hops may take a while.',
@@ -462,7 +453,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
);
await conn.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.routeSignedPathLen,
contactPathLen: sender.routeEncodedPathLen,
payload: payload,
);
} catch (_) {

View File

@@ -13,6 +13,7 @@ import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
import '../../providers/voice_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../../providers/app_provider.dart';
import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart';
import '../../services/sar_template_service.dart';
@@ -26,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
@@ -44,6 +46,7 @@ import 'system_message_bubble.dart';
class MessageBubble extends StatefulWidget {
final Message message;
final VoidCallback? onTap;
final VoidCallback? onReply;
final bool isHighlighted;
final VoidCallback? onNavigateToMap;
@@ -54,6 +57,7 @@ class MessageBubble extends StatefulWidget {
super.key,
required this.message,
this.onTap,
this.onReply,
this.isHighlighted = false,
this.onNavigateToMap,
this.isCompact = false,
@@ -64,6 +68,7 @@ class MessageBubble extends StatefulWidget {
}
class _MessageBubbleState extends State<MessageBubble> {
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
bool _isExpanded = false;
bool _showReceivedStats = false;
@@ -91,6 +96,72 @@ class _MessageBubbleState extends State<MessageBubble> {
});
}
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
final matches = _mentionPattern.allMatches(text).toList();
if (matches.isEmpty) {
return Text(text, style: baseBodyStyle);
}
final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final backgroundColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12);
final borderColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.25);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in matches) {
if (match.start > cursor) {
spans.add(
TextSpan(
text: text.substring(cursor, match.start),
style: baseBodyStyle,
),
);
}
final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) {
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Text(
'@$mentionName',
style: baseBodyStyle?.copyWith(
color: textColor,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
),
);
} else {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle));
}
cursor = match.end;
}
if (cursor < text.length) {
spans.add(TextSpan(text: text.substring(cursor), style: baseBodyStyle));
}
return Text.rich(TextSpan(children: spans, style: baseBodyStyle));
}
void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
if (!widget.message.isRead &&
!widget.message.isSentMessage &&
@@ -209,6 +280,15 @@ class _MessageBubbleState extends State<MessageBubble> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isOwnMessage && widget.onReply != null)
ListTile(
leading: const Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply),
onTap: () {
Navigator.pop(context);
widget.onReply?.call();
},
),
// Copy text option
ListTile(
leading: const Icon(Icons.copy),
@@ -469,6 +549,9 @@ class _MessageBubbleState extends State<MessageBubble> {
final retryCause = _retryCauseLabel(widget.message);
final retryResult = _retryResultLabel(widget.message);
final retryMode = _retryModeLabel(widget.message);
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(widget.message.id);
final rawLines = <String>[
'Message ID: ${widget.message.id}',
@@ -790,7 +873,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow(
context,
label: l10n.retryAttempt,
value: '${widget.message.retryAttempt}/3',
value: '${widget.message.retryAttempt}/4',
),
if (widget.message.lastRetryAt != null)
_detailRow(
@@ -809,6 +892,20 @@ class _MessageBubbleState extends State<MessageBubble> {
label: l10n.floodFallback,
value: l10n.yes,
),
if (routeMetadata?.relayName case final relayName?)
_detailRow(
context,
label: 'Relay',
value: relayName,
),
if (routeMetadata?.canonicalPath
case final routePath?)
_detailRow(
context,
label: 'Selected path',
value: routePath,
onCopy: () => copyField(routePath),
),
if (retryResult != null)
_detailRow(
context,
@@ -885,7 +982,9 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow(
context,
label: l10n.envelope,
value: envelope != null ? 'VE3 compact' : l10n.unknown,
value: envelope != null
? 'VE3 compact'
: l10n.unknown,
),
if (voiceSession != null)
_detailRow(
@@ -1238,12 +1337,10 @@ class _MessageBubbleState extends State<MessageBubble> {
// Logged frame format:
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
final raw = log.rawData;
final payloadType = (raw[3] >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -1259,11 +1356,9 @@ class _MessageBubbleState extends State<MessageBubble> {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final raw = log.rawData;
if (raw.length < 6) return null;
final pathLen = raw[4];
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) return null;
return decoded.pathBytes;
}
void _showDeleteConfirmation(BuildContext context) {
@@ -1545,8 +1640,15 @@ class _MessageBubbleState extends State<MessageBubble> {
return null;
}
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
if (routeMetadata != null) {
return routeMetadata.modeLabel;
}
if (message.usedFloodFallback) {
return 'Flood fallback';
return 'Flood route';
}
if (message.retryAttempt > 0 || message.expectedAckTag != null) {
@@ -1561,9 +1663,17 @@ class _MessageBubbleState extends State<MessageBubble> {
return null;
}
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
final routeLabel = routeMetadata?.modeLabel.toLowerCase();
if (message.deliveryStatus == MessageDeliveryStatus.delivered) {
if (routeLabel != null) {
return 'Delivered via $routeLabel';
}
if (message.usedFloodFallback) {
return 'Delivered after flood fallback';
return 'Delivered after flood route';
}
if (message.retryAttempt > 0) {
return 'Delivered after retry';
@@ -1574,8 +1684,11 @@ class _MessageBubbleState extends State<MessageBubble> {
}
if (message.deliveryStatus == MessageDeliveryStatus.sending) {
if (routeLabel != null) {
return '$routeLabel in progress';
}
if (message.usedFloodFallback) {
return 'Flood fallback in progress';
return 'Flood route in progress';
}
if (message.retryAttempt > 0) {
return 'Retry in progress';
@@ -1586,8 +1699,11 @@ class _MessageBubbleState extends State<MessageBubble> {
}
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
if (routeLabel != null) {
return 'Failed via $routeLabel';
}
if (message.usedFloodFallback) {
return 'Failed after flood fallback';
return 'Failed after flood route';
}
if (message.retryAttempt > 0) {
return 'Failed after retry attempts';
@@ -1598,6 +1714,36 @@ class _MessageBubbleState extends State<MessageBubble> {
return null;
}
Widget _buildDirectMessageStatusIndicator(
BuildContext context,
Message message,
) {
switch (message.deliveryStatus) {
case MessageDeliveryStatus.delivered:
return Icon(
Icons.done_all,
size: 16,
color: Theme.of(context).colorScheme.primary,
);
case MessageDeliveryStatus.sent:
return Icon(
Icons.done,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
case MessageDeliveryStatus.sending:
return Icon(Icons.schedule, size: 15, color: Colors.orange);
case MessageDeliveryStatus.failed:
return const Icon(Icons.error_outline, size: 15, color: Colors.red);
case MessageDeliveryStatus.received:
return Icon(
Icons.done_all,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
}
@override
Widget build(BuildContext context) {
// Display system messages with minimal styling
@@ -1615,6 +1761,7 @@ class _MessageBubbleState extends State<MessageBubble> {
}
final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final messageFontScale = context.watch<AppProvider>().messageFontScale;
// Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>();
@@ -1728,6 +1875,9 @@ class _MessageBubbleState extends State<MessageBubble> {
: null;
final shouldFloatBubble = widget.isCompact;
final baseBodyStyle = Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontSize: 14 * messageFontScale);
final bubble = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: shouldFloatBubble
@@ -2146,9 +2296,9 @@ class _MessageBubbleState extends State<MessageBubble> {
);
if (drawing == null) {
return Text(
return _buildMessageTextContent(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
baseBodyStyle,
);
}
@@ -2235,10 +2385,7 @@ class _MessageBubbleState extends State<MessageBubble> {
)
// Regular message content
else if (!message.isDrawing || widget.isCompact)
Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
),
_buildMessageTextContent(message.text, baseBodyStyle),
if (!widget.isCompact &&
!isSarMarker &&
@@ -2458,33 +2605,42 @@ class _MessageBubbleState extends State<MessageBubble> {
Row(
mainAxisSize: MainAxisSize.max,
children: [
Icon(
getDeliveryStatusIcon(message.deliveryStatus),
size: 12,
color: getDeliveryStatusColor(
message.deliveryStatus,
),
),
const SizedBox(width: 3),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
message.getLocalizedDeliveryStatus(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: getDeliveryStatusColor(
message.deliveryStatus,
),
fontStyle: FontStyle.italic,
),
if (message.isContactMessage)
_buildDirectMessageStatusIndicator(
context,
message,
)
else ...[
Icon(
getDeliveryStatusIcon(message.deliveryStatus),
size: 12,
color: getDeliveryStatusColor(
message.deliveryStatus,
),
),
),
const SizedBox(width: 3),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
message.getLocalizedDeliveryStatus(
context,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: getDeliveryStatusColor(
message.deliveryStatus,
),
fontStyle: FontStyle.italic,
),
),
),
),
],
// Show retry button for failed messages
if (message.deliveryStatus ==
MessageDeliveryStatus.failed) ...[

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/message.dart';
import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart';
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (status) {
@@ -185,6 +188,9 @@ Widget buildSentDirectSignalStatus(
required int roundTripTimeMs,
required Duration txEstimate,
}) {
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: txEstimate > Duration.zero
? txEstimate.inMilliseconds
@@ -230,7 +236,7 @@ Widget buildSentDirectSignalStatus(
_techChip(
context,
icon: Icons.refresh,
label: 'retry ${message.retryAttempt}/3',
label: 'retry ${message.retryAttempt}/4',
color: Colors.redAccent,
),
if (message.suggestedTimeoutMs != null)
@@ -244,7 +250,7 @@ Widget buildSentDirectSignalStatus(
_techChip(
context,
icon: Icons.waves,
label: 'flood fallback',
label: 'flood route',
color: Colors.teal,
)
else if (message.expectedAckTag != null)
@@ -254,6 +260,17 @@ Widget buildSentDirectSignalStatus(
label: 'direct ACK',
color: Colors.indigo,
),
if (routeMetadata != null)
_techChip(
context,
icon: routeMetadata.mode == PathSelectionMode.nearestRouter
? Icons.router
: Icons.alt_route,
label: routeMetadata.modeLabel,
color: routeMetadata.mode == PathSelectionMode.nearestRouter
? Colors.deepPurple
: Colors.indigo,
),
],
);
}

View File

@@ -1,6 +1,7 @@
// ignore_for_file: use_null_aware_elements
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
@@ -10,6 +11,8 @@ import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart';
class MessageTraceSheet extends StatefulWidget {
final Message message;
@@ -32,6 +35,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize();
final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
@@ -46,19 +50,30 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
var trace = _buildTraceResult(
nodes: localNodes,
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
if (_isCompleteTrace(
trace,
expectedRelayCount: math.max(0, widget.message.pathLen),
)) {
return trace;
}
final remoteNodes = await MeshMapNodesService.fetchNodes(
unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
@@ -336,9 +351,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key]
.toRadixString(16)
.padLeft(2, '0');
final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
node: entry.value,
label: entry.value?.name ?? 'Unknown',
@@ -428,6 +441,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required List<int>? packetPath,
required int preferredHashSize,
required String? senderPrefix,
required String? recipientPrefix,
}) {
@@ -435,9 +449,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize(
packetPath,
preferredHashSize: preferredHashSize,
);
final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath,
hashSize: hashSize,
);
final matched = _matchNodesFromPathHashes(
nodes: nodes,
pathHashes: packetPath,
pathHashes: hopHashes,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
@@ -445,7 +467,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
pathHashes: hopHashes,
matchedPathNodes: matched,
);
}
@@ -481,7 +503,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
trace.matchedPathNodes.every((node) => node != null);
}
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
final concreteCount = trace.matchedPathNodes
.whereType<MeshMapNode>()
.length;
return concreteCount >= expectedRelayCount + 2;
}
@@ -499,13 +523,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
for (final log in logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue;
final raw = log.rawData;
final header = raw[3];
final payloadType = (header >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -516,20 +537,22 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
if (bestLog == null || bestDeltaMs > 30000) return null;
final raw = bestLog.rawData;
final pathLen = raw[4];
return raw.sublist(5, 5 + pathLen);
final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) {
return null;
}
return decoded.pathBytes;
}
List<MeshMapNode?> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes,
required List<int> pathHashes,
required List<String> pathHashes,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
final hashHex = pathHashes[i].toLowerCase();
final candidates = nodes
.where((n) => n.publicKey.startsWith(hashHex))
.toList();
@@ -618,7 +641,7 @@ class _TraceResult {
final TraceMode mode;
final MeshMapNode? sender;
final MeshMapNode? recipient;
final List<int> pathHashes;
final List<String> pathHashes;
final List<MeshMapNode?> matchedPathNodes;
const _TraceResult({
@@ -645,7 +668,10 @@ class _RouteDisplayEntry {
return _RouteDisplayEntry(
node: node,
label: node.name,
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/app_provider.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
@@ -253,6 +255,10 @@ class _MessageInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
final messageFontScale = context.watch<AppProvider>().messageFontScale;
const baseFontSize = 15.0;
final resolvedFontSize = baseFontSize * messageFontScale;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
@@ -286,12 +292,12 @@ class _MessageInput extends StatelessWidget {
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [messageByteLimiter],
style: const TextStyle(fontSize: 15),
style: TextStyle(fontSize: resolvedFontSize),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
fontSize: resolvedFontSize,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),

View File

@@ -14,6 +14,7 @@ class MessagesContent extends StatelessWidget {
final Future<void> Function() onRefresh;
final VoidCallback? onNavigateToMap;
final ValueChanged<Message>? onMessageTap;
final ValueChanged<Message>? onReplyToMessage;
const MessagesContent({
super.key,
@@ -24,6 +25,7 @@ class MessagesContent extends StatelessWidget {
required this.onRefresh,
this.onNavigateToMap,
this.onMessageTap,
this.onReplyToMessage,
});
@override
@@ -86,6 +88,9 @@ class MessagesContent extends StatelessWidget {
onTap: onMessageTap == null
? null
: () => onMessageTap!(message),
onReply: onReplyToMessage == null
? null
: () => onReplyToMessage!(message),
);
},
),

View File

@@ -8,6 +8,8 @@ class RecipientSelectorSheet extends StatefulWidget {
final List<Contact> contacts;
final List<Contact> rooms;
final List<Contact> channels;
final int unreadCount;
final Map<String, int> unreadCountsByPublicKey;
final String? currentDestinationType;
final String? currentRecipientPublicKey;
final Function(String type, Contact? recipient) onSelect;
@@ -17,6 +19,8 @@ class RecipientSelectorSheet extends StatefulWidget {
required this.contacts,
required this.rooms,
required this.channels,
required this.unreadCount,
required this.unreadCountsByPublicKey,
this.currentDestinationType,
this.currentRecipientPublicKey,
required this.onSelect,
@@ -47,7 +51,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
bool _isSelected(String type, Contact? contact) {
if (widget.currentDestinationType != type) return false;
if (contact == null) return false;
if (contact == null) return widget.currentRecipientPublicKey == null;
return contact.publicKeyHex == widget.currentRecipientPublicKey;
}
@@ -138,6 +142,19 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
child: ListView(
shrinkWrap: true,
children: [
_buildOptionTile(
context: context,
icon: Icons.all_inbox,
title: l10n.showAll,
subtitle: 'All messages',
unreadCount: widget.unreadCount,
isSelected: _isSelected('all', null),
onTap: () {
widget.onSelect('all', null);
Navigator.pop(context);
},
),
const Divider(),
// Channels section
if (widget.channels.isNotEmpty) ...[
Padding(
@@ -174,6 +191,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
subtitle: channel.isPublicChannel
? l10n.broadcastToAllNearby
: '${l10n.channel} ${channel.publicKey[1]}', // Show slot number
unreadCount:
widget.unreadCountsByPublicKey[channel
.publicKeyHex] ??
0,
isSelected: _isSelected('channel', channel),
onTap: () {
widget.onSelect('channel', channel);
@@ -219,6 +240,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
contact: contact,
title: contact.displayName,
subtitle: contact.publicKeyShort,
unreadCount:
widget.unreadCountsByPublicKey[contact
.publicKeyHex] ??
0,
isSelected: _isSelected('contact', contact),
onTap: () {
widget.onSelect('contact', contact);
@@ -264,6 +289,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
contact: room,
title: room.displayName,
subtitle: room.publicKeyShort,
unreadCount:
widget.unreadCountsByPublicKey[room.publicKeyHex] ??
0,
isSelected: _isSelected('room', room),
onTap: () {
widget.onSelect('room', room);
@@ -314,6 +342,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
required Contact contact,
required String title,
required String subtitle,
required int unreadCount,
required bool isSelected,
required VoidCallback onTap,
}) {
@@ -339,13 +368,95 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
trailing: _buildTrailing(
context,
unreadCount: unreadCount,
isSelected: isSelected,
),
onTap: onTap,
);
}
Widget _buildOptionTile({
required BuildContext context,
required IconData icon,
required String title,
required String subtitle,
required int unreadCount,
required bool isSelected,
required VoidCallback onTap,
}) {
return ListTile(
leading: CircleAvatar(
radius: 20,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Icon(icon, color: Theme.of(context).colorScheme.primary),
),
title: Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
],
),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 12, fontFamily: 'monospace').copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
trailing: _buildTrailing(
context,
unreadCount: unreadCount,
isSelected: isSelected,
),
onTap: onTap,
);
}
Widget? _buildTrailing(
BuildContext context, {
required int unreadCount,
required bool isSelected,
}) {
if (unreadCount <= 0 && !isSelected) {
return null;
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0)
Container(
key: Key('unread-badge-$unreadCount'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(999),
),
child: Text(
unreadCount > 99 ? '99+' : '$unreadCount',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
if (unreadCount > 0 && isSelected) const SizedBox(width: 8),
if (isSelected)
Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
),
],
);
}
}

View File

@@ -366,15 +366,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}
}
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) {
_showToast(
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
@@ -415,10 +406,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
missingIndices: missing,
requesterKey6: requesterKey6,
)
: VoiceFetchRequest(
sessionId: sessionId,
requesterKey6: requesterKey6,
);
: VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6);
try {
debugPrint(
@@ -426,7 +414,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
);
await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.routeSignedPathLen,
contactPathLen: sender.routeEncodedPathLen,
payload: request.encodeBinary(),
);
} catch (_) {

View File

@@ -15,6 +15,7 @@ import flutter_local_notifications
import geolocator_apple
import nsd_macos
import package_info_plus
import path_provider_foundation
import record_macos
import share_plus
import shared_preferences_foundation
@@ -32,6 +33,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -129,14 +129,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
codec2_flutter:
dependency: "direct main"
description:
@@ -623,14 +615,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gsettings:
dependency: transitive
description:
@@ -639,14 +623,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.8"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http:
dependency: "direct main"
description:
@@ -799,14 +775,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -828,7 +796,7 @@ packages:
description:
path: "."
ref: main
resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"
@@ -856,14 +824,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
url: "https://pub.dev"
source: hosted
version: "0.17.5"
nested:
dependency: transitive
description:
@@ -920,14 +880,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_info_plus:
dependency: "direct main"
description:
@@ -969,13 +921,13 @@ packages:
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
dependency: "direct overridden"
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
@@ -1112,14 +1064,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record:
dependency: "direct main"
description:
@@ -1321,10 +1265,10 @@ packages:
dependency: transitive
description:
name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+2"
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
@@ -1582,5 +1526,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1"

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0308.1+21
version: 2026.0311.1+26
environment:
sdk: ^3.9.2
@@ -138,6 +138,12 @@ dev_dependencies:
flutter_launcher_icons: "^0.14.4"
fake_async: ^1.3.3
dependency_overrides:
# path_provider_foundation 2.6.0 pulls in package:objective_c as a native
# asset. That framework has been ending up archived with a macOS platform
# slice and fails App Store validation for iOS uploads.
path_provider_foundation: 2.5.1
flutter_launcher_icons:
android: "launcher_icon"
ios: true

View File

@@ -104,7 +104,7 @@ void main() {
expect(contact.routeHashSize, 3);
expect(contact.routeHopCount, 2);
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
expect(contact.routeSupportsLegacyRawTransport, isFalse);
expect(contact.routeSupportsLegacyRawTransport, isTrue);
});
test('treats -1 as unknown route', () {

View File

@@ -47,6 +47,63 @@ void main() {
expect(ok, isFalse);
});
test('returns false when requester has no learned path', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: -1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
);
expect(ok, isFalse);
});
test('returns false when requester path payload is empty', () async {
final requester = Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List(0),
advName: 'Requester',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: requester,
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
);
expect(ok, isFalse);
});
test('sends only requested indices', () async {
final sent = <Uint8List>[];
final ok = await serveCachedSessionFragments<_Fragment>(

View File

@@ -4,7 +4,10 @@ import 'package:fake_async/fake_async.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/path_selection.dart';
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
Contact _buildContact() {
return Contact(
@@ -39,6 +42,10 @@ Message _buildDirectMessage(String id) {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('MessagesProvider retransmission', () {
test('direct messages become sent before delivery ACK arrives', () {
final provider = MessagesProvider();
@@ -171,7 +178,11 @@ void main() {
);
expect(retryCalls, 0);
async.elapse(const Duration(seconds: 4));
async.elapse(const Duration(milliseconds: 998));
async.flushMicrotasks();
expect(retryCalls, 0);
async.elapse(const Duration(milliseconds: 1));
async.flushMicrotasks();
expect(retryCalls, 1);
@@ -237,68 +248,89 @@ void main() {
expect(provider.messages.single.retryAttempt, 0);
});
test('repeated max-retry failures request path reset', () async {
test(
'final router fallback runs after all normal retries are exhausted',
() async {
final provider = MessagesProvider();
final fallbackCalls = <String>[];
provider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async {
fallbackCalls.add(messageId);
return true;
};
provider.addSentMessage(
_buildDirectMessage(
'm5',
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
contact: _buildContact(),
);
provider.markMessageFailed('m5');
await Future<void>.delayed(Duration.zero);
expect(fallbackCalls, ['m5']);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
},
);
test('final router fallback is not retried twice', () async {
final provider = MessagesProvider();
final contact = _buildContact();
final resetRequests = <(String, int)>[];
provider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
resetRequests.add((contact.advName, failureStreak));
};
provider.addSentMessage(
_buildDirectMessage(
'm5',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m5');
provider.addSentMessage(
_buildDirectMessage(
'm6',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
contact: _buildContact(),
);
provider.updateMessageRouteSelection(
'm6',
PathSelection.flood(),
routerFallbackAttempted: true,
);
provider.markMessageFailed('m6');
await Future<void>.delayed(Duration.zero);
expect(resetRequests, [('Teammate', 2)]);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.failed,
);
});
test('successful delivery clears path failure streak', () async {
final provider = MessagesProvider();
final contact = _buildContact();
final resetRequests = <int>[];
provider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
resetRequests.add(failureStreak);
};
test(
'final permanent failure callback runs after router fallback failure',
() async {
final provider = MessagesProvider();
final failedMessageIds = <String>[];
provider.onFinalDirectMessageFailureCallback =
({required messageId, required contact, required message}) async {
failedMessageIds.add(messageId);
};
provider.addSentMessage(
_buildDirectMessage(
provider.addSentMessage(
_buildDirectMessage(
'm7',
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
contact: _buildContact(),
);
provider.updateMessageRouteSelection(
'm7',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m7');
PathSelection.flood(),
routerFallbackAttempted: true,
);
provider.addSentMessage(_buildDirectMessage('m8'), contact: contact);
provider.markMessageSent('m8', 123, 10);
provider.markMessageDelivered(123, 150);
provider.markMessageFailed('m7');
await Future<void>.delayed(Duration.zero);
provider.addSentMessage(
_buildDirectMessage(
'm9',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m9');
await Future<void>.delayed(Duration.zero);
expect(resetRequests, isEmpty);
});
expect(failedMessageIds, ['m7']);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.failed,
);
},
);
});
}

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -11,6 +12,30 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Contact buildContact({
required List<int> prefix,
required ContactType type,
String name = 'Test Contact',
}) {
final key = Uint8List.fromList([
...prefix,
...List<int>.generate(26, (index) => index),
]);
return Contact(
publicKey: key,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
group('MessagesProvider voice detection', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -190,5 +215,60 @@ void main() {
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
});
test('marks only the selected contact destination as read', () {
final provider = MessagesProvider();
final alice = buildContact(
prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
type: ContactType.chat,
name: 'Alice',
);
final bob = buildContact(
prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25],
type: ContactType.chat,
name: 'Bob',
);
provider.addMessage(
Message(
id: 'alice-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Alice unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: alice.publicKey.sublist(0, 6),
),
);
provider.addMessage(
Message(
id: 'bob-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000101,
text: 'Bob unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: bob.publicKey.sublist(0, 6),
),
);
provider.markDestinationAsRead(
destinationType: 'contact',
contact: alice,
);
final aliceMessage = provider.messages.firstWhere(
(message) => message.id == 'alice-incoming',
);
final bobMessage = provider.messages.firstWhere(
(message) => message.id == 'bob-incoming',
);
expect(aliceMessage.isRead, isTrue);
expect(bobMessage.isRead, isFalse);
expect(provider.unreadCount, equals(1));
});
});
}

View File

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

View File

@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/services/messaging_route_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('route preference defaults are disabled', () async {
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isFalse,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isTrue,
);
});
test('route preferences persist changes', () async {
await MessagingRoutePreferences.setAutoRouteRotationEnabled(true);
await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false);
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isTrue,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isFalse,
);
});
}

View File

@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/nearest_router_selector.dart';
Contact _buildRepeater({
required int seed,
required String name,
required double latitude,
required double longitude,
required int lastAdvert,
int outPathLen = -1,
}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.repeater,
flags: 0,
outPathLen: outPathLen,
outPath: Uint8List(0),
advName: name,
lastAdvert: lastAdvert,
advLat: (latitude * 1e6).round(),
advLon: (longitude * 1e6).round(),
lastMod: lastAdvert,
);
}
Position _position(double latitude, double longitude) {
return Position(
latitude: latitude,
longitude: longitude,
timestamp: DateTime.now(),
accuracy: 1,
altitude: 0,
altitudeAccuracy: 1,
heading: 0,
headingAccuracy: 1,
speed: 0,
speedAccuracy: 0,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('selector chooses nearest eligible repeater', () {
final selector = NearestRouterSelector();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final recipient = _buildRepeater(
seed: 90,
name: 'Recipient',
latitude: 46.05,
longitude: 14.50,
lastAdvert: now,
).copyWith(type: ContactType.chat);
final selected = selector.select(
senderPosition: _position(46.0569, 14.5058),
repeaters: [
_buildRepeater(
seed: 1,
name: 'Far',
latitude: 46.10,
longitude: 14.60,
lastAdvert: now,
outPathLen: 1,
),
_buildRepeater(
seed: 2,
name: 'Near',
latitude: 46.0570,
longitude: 14.5060,
lastAdvert: now - 5,
outPathLen: 1,
),
],
recipient: recipient,
);
expect(selected?.advName, 'Near');
});
test('selector skips stale repeaters', () {
final selector = NearestRouterSelector();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final staleAdvert = now - (11 * 60);
final recipient = _buildRepeater(
seed: 91,
name: 'Recipient',
latitude: 46.05,
longitude: 14.50,
lastAdvert: now,
).copyWith(type: ContactType.chat);
final selected = selector.select(
senderPosition: _position(46.0569, 14.5058),
repeaters: [
_buildRepeater(
seed: 3,
name: 'Stale',
latitude: 46.0570,
longitude: 14.5060,
lastAdvert: staleAdvert,
outPathLen: 1,
),
],
recipient: recipient,
);
expect(selected, isNull);
});
}

View File

@@ -0,0 +1,175 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart';
Contact _buildContact({
required int seed,
required List<int> pathBytes,
required int hopCount,
required int hashSize,
}) {
final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F);
final outPath = Uint8List(ContactRouteCodec.maxPathBytes)
..setRange(0, pathBytes.length, pathBytes);
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat,
flags: 0,
outPathLen: ContactRouteCodec.toSignedDescriptor(encoded),
outPath: outPath,
advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact _buildContactWithoutRoute({required int seed}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('auto rotation ranks best paths before flood', () async {
final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 0);
final best = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
);
final second = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xCC, 0xDD]),
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordLearnedPath(contact);
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 120,
);
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 110,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: true,
roundTripTimeMs: 200,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: false,
);
final first = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final third = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final secondPick = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(first.mode, PathSelectionMode.directHistorical);
expect(first.canonicalPath, 'AA,BB');
expect(third.mode, PathSelectionMode.directHistorical);
expect(third.canonicalPath, 'CC,DD');
expect(secondPick.mode, PathSelectionMode.flood);
});
test(
'current learned route is reused first even with rotation enabled',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 9,
pathBytes: [0xAA, 0xBB, 0xCC],
hopCount: 1,
hashSize: 3,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11, 0x22, 0x33]),
hopCount: 1,
hashSize: 3,
),
success: true,
roundTripTimeMs: 90,
);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AABBCC');
},
);
test('no history falls back to flood', () async {
final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 0);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.flood);
});
test('received public byte path is added to history', () async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3);
final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]);
expect(history.directPaths.single.hashSize, 3);
expect(history.directPaths.single.hopCount, 1);
});
}

View File

@@ -22,5 +22,13 @@ void main() {
test('keeps non-hash labels at two characters', () {
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
});
test('strips emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD');
});
test('strips leading emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH');
});
});
}

View File

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

View File

@@ -24,15 +24,64 @@ void main() {
expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01);
expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.originalSenderHash, 0xc2);
expect(decoded.pathDescriptor, 0x04);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']);
expect(decoded.originalSenderHashHex, 'c2ba');
});
test('parses encoded descriptor with 2-byte hashes', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x42,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.pathDescriptor, 0x42);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2);
expect(decoded.hopCount, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']);
});
test('uses preferred hash size when packet length is ambiguous', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x09,
0x06,
0xaa,
0xbb,
0xcc,
0xdd,
0xee,
0xff,
]);
final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 1);
expect(decoded, isNotNull);
expect(decoded!.hashSize, 1);
expect(decoded.hopHashes, ['aa', 'bb', 'cc', 'dd', 'ee', 'ff']);
});
});
group('LogRxRouteDecoder.resolveHash', () {
test('prefers own node when hash matches device key', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
'c201',
contacts: const [],
ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]),
ownName: 'Base',
@@ -44,8 +93,10 @@ void main() {
test('resolves unique contact by first public key byte', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)],
'c211',
contacts: [
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
],
);
expect(resolved.isUniqueMatch, isTrue);
@@ -54,10 +105,10 @@ void main() {
test('marks ambiguous matches without pretending certainty', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
'c2',
contacts: [
_contact(name: 'Alpha', keyPrefix: 0xc2),
_contact(name: 'Bravo', keyPrefix: 0xc2),
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
_contact(name: 'Bravo', keyPrefix: [0xc2, 0x22]),
],
);
@@ -67,10 +118,10 @@ void main() {
});
}
Contact _contact({required String name, required int keyPrefix}) {
Contact _contact({required String name, required List<int> keyPrefix}) {
return Contact(
publicKey: Uint8List.fromList([
keyPrefix,
...keyPrefix,
0x11,
0x22,
0x33,

Some files were not shown because too many files have changed in this diff Show More