Compare commits

..

17 Commits

Author SHA1 Message Date
Janez T
3e8d4c2d11 fix: Use GitHub client #1928
ref:
2026-04-02 08:52:25 +02:00
Janez T
3a28acac2d feat: Improve live traffic packet help #0 2026-04-01 20:58:13 +02:00
Janez T
5ee03d4b2f chore: Pin Meshcore client git ref #0 2026-04-01 15:11:52 +02:00
Janez T
3dfdbd7700 fix: Preserve contacts and sensor updates 2026-04-01 13:48:40 +02:00
Janez T
043faea06a fix: Dedupe recent messages and wasm 2026-04-01 13:35:02 +02:00
Janez T
05ddb6841a fix: Use zero-hop relay ping history 2026-03-31 18:13:53 +02:00
Janez T
3392e5f9c1 feat: Add relay ping and lock message destination 2026-03-31 17:52:44 +02:00
Janez T
7df750c8d5 feat: Add send mode localization #123 2026-03-31 09:41:45 +02:00
Janez T
e09d418084 fix: Correct neighbor times ref: 2026-03-29 11:10:25 +02:00
Janez T
96aeec1301 chore: Bump iOS build number ref: 2026-03-24 19:29:20 +01:00
Janez T
6978f24440 fix: Merge self replay and refresh self telemetry 2026-03-24 19:07:44 +01:00
Janez T
ba0f1fc141 feat: Add fast GPS send logs 2026-03-23 21:00:41 +01:00
Janez T
28a9235168 fix: Tame rapid GPS updates #41 2026-03-23 20:51:48 +01:00
Janez T
3e3c9a34d5 fix: Keep received replays separate 2026-03-23 20:07:06 +01:00
Janez T
e85dccea35 feat: Add location-based DM retries 2026-03-23 15:16:59 +01:00
Janez T
019224d955 fix: Hide public channel and show signal pills #123 2026-03-23 15:04:45 +01:00
Janez T
4c048a5a50 fix: Show recipient activity previews 2026-03-23 10:44:20 +01:00
66 changed files with 4218 additions and 860 deletions

View File

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

View File

@@ -135,6 +135,9 @@ clean: ## Clean build artifacts
flutter clean flutter clean
rm -rf $(BUILD_DIR) rm -rf $(BUILD_DIR)
build-web: ## Build web with WebAssembly
flutter build web --wasm --release
# Build for all platforms # Build for all platforms
build-all: bump ## Build for Android and iOS build-all: bump ## Build for Android and iOS
flutter build apk --release flutter build apk --release

View File

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

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>124</string> <string>129</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000201">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.144858"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.700922">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="98.142592"> <testcase classname="fastlane.lanes" name="2: build_app" time="102.603385">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="157.12775"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="2538.71069">
</testcase> </testcase>

View File

@@ -660,6 +660,30 @@
"@flood": { "@flood": {
"description": "Flood routing indicator" "description": "Flood routing indicator"
}, },
"autoSend": "Auto Send",
"@autoSend": {
"description": "Auto send mode - uses current path"
},
"autoSendDescription": "Send via current path.",
"@autoSendDescription": {
"description": "Description for auto send mode"
},
"sendDirect": "Send Direct",
"@sendDirect": {
"description": "Send direct mode - zero-hop to contact"
},
"sendDirectDescription": "Send directly to this contact.",
"@sendDirectDescription": {
"description": "Description for send direct mode"
},
"sendFlood": "Send Flood",
"@sendFlood": {
"description": "Send flood mode - via all repeaters"
},
"sendFloodDescription": "Send via all repeaters.",
"@sendFloodDescription": {
"description": "Description for send flood mode"
},
"loggedIn": "Logged In", "loggedIn": "Logged In",
"@loggedIn": { "@loggedIn": {
"description": "Logged in status badge" "description": "Logged in status badge"

View File

@@ -961,6 +961,42 @@ abstract class AppLocalizations {
/// **'Flood'** /// **'Flood'**
String get flood; String get flood;
/// Auto send mode - uses current path
///
/// In en, this message translates to:
/// **'Auto Send'**
String get autoSend;
/// Description for auto send mode
///
/// In en, this message translates to:
/// **'Send via current path.'**
String get autoSendDescription;
/// Send direct mode - zero-hop to contact
///
/// In en, this message translates to:
/// **'Send Direct'**
String get sendDirect;
/// Description for send direct mode
///
/// In en, this message translates to:
/// **'Send directly to this contact.'**
String get sendDirectDescription;
/// Send flood mode - via all repeaters
///
/// In en, this message translates to:
/// **'Send Flood'**
String get sendFlood;
/// Description for send flood mode
///
/// In en, this message translates to:
/// **'Send via all repeaters.'**
String get sendFloodDescription;
/// Logged in status badge /// Logged in status badge
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -481,6 +481,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get flood => 'Flut'; String get flood => 'Flut';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Angemeldet'; String get loggedIn => 'Angemeldet';

View File

@@ -483,6 +483,24 @@ class AppLocalizationsEl extends AppLocalizations {
@override @override
String get flood => 'Πλημμυρικό'; String get flood => 'Πλημμυρικό';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Συνδεδεμένος'; String get loggedIn => 'Συνδεδεμένος';

View File

@@ -478,6 +478,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get flood => 'Flood'; String get flood => 'Flood';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Logged In'; String get loggedIn => 'Logged In';

View File

@@ -481,6 +481,24 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get flood => 'Inundación'; String get flood => 'Inundación';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Sesión iniciada'; String get loggedIn => 'Sesión iniciada';

View File

@@ -483,6 +483,24 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get flood => 'Inondation'; String get flood => 'Inondation';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Connecté'; String get loggedIn => 'Connecté';

View File

@@ -476,6 +476,24 @@ class AppLocalizationsHr extends AppLocalizations {
@override @override
String get flood => 'Preplavljanje'; String get flood => 'Preplavljanje';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Prijavljen'; String get loggedIn => 'Prijavljen';

View File

@@ -480,6 +480,24 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get flood => 'Flood'; String get flood => 'Flood';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Connesso'; String get loggedIn => 'Connesso';

View File

@@ -481,6 +481,24 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get flood => 'Rozgłoszeniowo'; String get flood => 'Rozgłoszeniowo';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Zalogowano'; String get loggedIn => 'Zalogowano';

View File

@@ -482,6 +482,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get flood => 'Inundação'; String get flood => 'Inundação';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Conectado'; String get loggedIn => 'Conectado';

View File

@@ -480,6 +480,24 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get flood => 'Широковещательно'; String get flood => 'Широковещательно';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Вход выполнен'; String get loggedIn => 'Вход выполнен';

View File

@@ -477,6 +477,24 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get flood => 'Razpršitev'; String get flood => 'Razpršitev';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Prijavljen'; String get loggedIn => 'Prijavljen';

View File

@@ -480,6 +480,24 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get flood => 'Yayılım'; String get flood => 'Yayılım';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Giriş yapıldı'; String get loggedIn => 'Giriş yapıldı';

View File

@@ -480,6 +480,24 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get flood => 'Широкомовно'; String get flood => 'Широкомовно';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => 'Увійшли'; String get loggedIn => 'Увійшли';

View File

@@ -464,6 +464,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get flood => '泛洪'; String get flood => '泛洪';
@override
String get autoSend => 'Auto Send';
@override
String get autoSendDescription => 'Send via current path.';
@override
String get sendDirect => 'Send Direct';
@override
String get sendDirectDescription => 'Send directly to this contact.';
@override
String get sendFlood => 'Send Flood';
@override
String get sendFloodDescription => 'Send via all repeaters.';
@override @override
String get loggedIn => '已登录'; String get loggedIn => '已登录';

View File

@@ -1,5 +1,11 @@
{ {
"de": [ "de": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -16,6 +22,12 @@
], ],
"el": [ "el": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -32,6 +44,12 @@
], ],
"es": [ "es": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -48,6 +66,12 @@
], ],
"fr": [ "fr": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -64,6 +88,12 @@
], ],
"hr": [ "hr": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -80,6 +110,12 @@
], ],
"it": [ "it": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -96,6 +132,12 @@
], ],
"pl": [ "pl": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -112,6 +154,12 @@
], ],
"pt": [ "pt": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -128,6 +176,12 @@
], ],
"ru": [ "ru": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -144,6 +198,12 @@
], ],
"sl": [ "sl": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -160,6 +220,12 @@
], ],
"tr": [ "tr": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -176,6 +242,12 @@
], ],
"uk": [ "uk": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",
@@ -192,6 +264,12 @@
], ],
"zh": [ "zh": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle", "postConnectDiscoveryTitle",
"postConnectDiscoveryDescription", "postConnectDiscoveryDescription",
"setRegionScope", "setRegionScope",

View File

@@ -122,6 +122,15 @@ class Channel {
/// Base64-encoded PSK for sharing with firmware CLI and related tooling. /// Base64-encoded PSK for sharing with firmware CLI and related tooling.
String get pskBase64 => base64.encode(secret); String get pskBase64 => base64.encode(secret);
/// MeshCore group packets carry the first byte of SHA256(channel secret).
int get hashByte {
final digest = sha256.convert(secret);
return digest.bytes.first;
}
String get hashHex =>
hashByte.toRadixString(16).padLeft(2, '0').toUpperCase();
/// Display name for the channel /// Display name for the channel
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N" /// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName { String get displayName {

View File

@@ -9,6 +9,11 @@ class PathRecord {
final int failureCount; final int failureCount;
final int lastRoundTripTimeMs; final int lastRoundTripTimeMs;
final DateTime lastUsedAt; final DateTime lastUsedAt;
final DateTime? lastSucceededAt;
final double? senderLatitude;
final double? senderLongitude;
final double? recipientLatitude;
final double? recipientLongitude;
const PathRecord({ const PathRecord({
required this.pathBytes, required this.pathBytes,
@@ -19,6 +24,11 @@ class PathRecord {
required this.failureCount, required this.failureCount,
required this.lastRoundTripTimeMs, required this.lastRoundTripTimeMs,
required this.lastUsedAt, required this.lastUsedAt,
required this.lastSucceededAt,
required this.senderLatitude,
required this.senderLongitude,
required this.recipientLatitude,
required this.recipientLongitude,
}); });
String get signature => String get signature =>
@@ -36,6 +46,11 @@ class PathRecord {
int? failureCount, int? failureCount,
int? lastRoundTripTimeMs, int? lastRoundTripTimeMs,
DateTime? lastUsedAt, DateTime? lastUsedAt,
DateTime? lastSucceededAt,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) { }) {
return PathRecord( return PathRecord(
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
@@ -46,6 +61,11 @@ class PathRecord {
failureCount: failureCount ?? this.failureCount, failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt, lastUsedAt: lastUsedAt ?? this.lastUsedAt,
lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt,
senderLatitude: senderLatitude ?? this.senderLatitude,
senderLongitude: senderLongitude ?? this.senderLongitude,
recipientLatitude: recipientLatitude ?? this.recipientLatitude,
recipientLongitude: recipientLongitude ?? this.recipientLongitude,
); );
} }
@@ -59,6 +79,11 @@ class PathRecord {
'failure_count': failureCount, 'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs, 'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(), 'last_used_at': lastUsedAt.toIso8601String(),
'last_succeeded_at': lastSucceededAt?.toIso8601String(),
'sender_latitude': senderLatitude,
'sender_longitude': senderLongitude,
'recipient_latitude': recipientLatitude,
'recipient_longitude': recipientLongitude,
}; };
} }
@@ -79,6 +104,13 @@ class PathRecord {
lastUsedAt: lastUsedAt:
DateTime.tryParse(json['last_used_at'] as String? ?? '') ?? DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0), DateTime.fromMillisecondsSinceEpoch(0),
lastSucceededAt: DateTime.tryParse(
json['last_succeeded_at'] as String? ?? '',
),
senderLatitude: (json['sender_latitude'] as num?)?.toDouble(),
senderLongitude: (json['sender_longitude'] as num?)?.toDouble(),
recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(),
recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(),
); );
} }
} }

View File

@@ -1761,6 +1761,7 @@ class AppProvider with ChangeNotifier {
return _prepareDirectMessageSend( return _prepareDirectMessageSend(
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: retryAttempt,
); );
}; };
@@ -1830,23 +1831,47 @@ class AppProvider with ChangeNotifier {
Future<Contact> _prepareDirectMessageSend({ Future<Contact> _prepareDirectMessageSend({
required String messageId, required String messageId,
required Contact contact, required Contact contact,
required int retryAttempt,
}) async { }) async {
final latestContact = final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId]; var session = _directMessageRouteSessions[messageId];
if (session == null) { if (session == null) {
final selection = await _pathHistoryService.getSelectionForContact( final selection = latestContact.routeHasPath && latestContact.routeHopCount > 0
latestContact, ? PathSelection(
autoRouteRotationEnabled: _autoRouteRotationEnabled, mode: PathSelectionMode.directCurrent,
); pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession( session = _DirectMessageRouteSession(
currentSelection: selection, currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false, routerFallbackAttempted: false,
); );
_directMessageRouteSessions[messageId] = session;
} }
if (!session.routerFallbackAttempted) {
final currentSignature =
latestContact.routeHasPath && latestContact.routeHopCount > 0
? latestContact.routePathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
: null;
final selection = await _resolveDirectMessageSelectionForRetry(
latestContact,
retryAttempt: retryAttempt,
currentSignature: currentSignature,
fallbackSelection: session.currentSelection,
);
session = session.copyWith(currentSelection: selection);
}
_directMessageRouteSessions[messageId] = session;
await _applyPathSelection( await _applyPathSelection(
latestContact, latestContact,
session.currentSelection, session.currentSelection,
@@ -1857,6 +1882,43 @@ class AppProvider with ChangeNotifier {
latestContact; latestContact;
} }
Future<PathSelection> _resolveDirectMessageSelectionForRetry(
Contact contact, {
required int retryAttempt,
required String? currentSignature,
required PathSelection fallbackSelection,
}) async {
if (contact.routeHasPath && contact.routeHopCount > 0 && retryAttempt <= 1) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (retryAttempt == 2) {
return PathSelection.flood();
}
if (retryAttempt >= 3) {
final historicalSelection = await _pathHistoryService
.getLastSuccessfulDirectSelection(
contact,
excludeSignature: currentSignature,
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
);
if (historicalSelection != null) {
return historicalSelection;
}
}
return fallbackSelection;
}
Future<void> _applyPathSelection( Future<void> _applyPathSelection(
Contact contact, Contact contact,
PathSelection selection, { PathSelection selection, {
@@ -2031,6 +2093,10 @@ class AppProvider with ChangeNotifier {
session.currentSelection, session.currentSelection,
success: true, success: true,
roundTripTimeMs: roundTripTimeMs, roundTripTimeMs: roundTripTimeMs,
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
), ),
); );
} }
@@ -2813,8 +2879,13 @@ class AppProvider with ChangeNotifier {
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000, timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
); );
debugPrint( debugPrint(
'📍 [AppProvider] Sending fast GPS update ($reason): ' '📤 [AppProvider] Fast GPS send '
'${position.latitude}, ${position.longitude} via channel $channelIdx', 'reason=$reason '
'sender=$senderKey6 '
'channel=$channelIdx '
'lat=${position.latitude} '
'lon=${position.longitude} '
'ts=${packet.timestampSeconds}',
); );
try { try {
await connectionProvider.sendChannelData( await connectionProvider.sendChannelData(
@@ -2822,11 +2893,32 @@ class AppProvider with ChangeNotifier {
dataType: MeshCoreConstants.dataTypeDev, dataType: MeshCoreConstants.dataTypeDev,
payload: packet.encodeBinary(), payload: packet.encodeBinary(),
); );
debugPrint(
'✅ [AppProvider] Fast GPS sent '
'sender=$senderKey6 channel=$channelIdx ts=${packet.timestampSeconds}',
);
} catch (e) { } catch (e) {
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e'); debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
} }
} }
Future<bool> sendTestFastLocationUpdate() async {
if (!connectionProvider.deviceInfo.isConnected) {
return false;
}
final position = await locationTrackingService.getCurrentPosition(
timeLimit: const Duration(seconds: 10),
retryCount: 1,
);
if (position == null) {
return false;
}
await _sendFastLocationUpdate(position, reason: 'test');
return true;
}
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6); final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) { if (liveContact != null) {

View File

@@ -29,6 +29,16 @@ class ChannelsProvider with ChangeNotifier {
return index == 0 ? 'Public' : 'Channel $index'; return index == 0 ? 'Public' : 'Channel $index';
} }
List<Channel> getChannelsByHashByte(int hashByte) {
return _channels.values.where((channel) => channel.hashByte == hashByte).toList()
..sort((a, b) => a.index.compareTo(b.index));
}
String? getUniqueChannelDisplayNameByHashByte(int hashByte) {
final matches = getChannelsByHashByte(hashByte);
return matches.length == 1 ? matches.single.displayName : null;
}
/// Add or update a channel /// Add or update a channel
void addOrUpdateChannel({ void addOrUpdateChannel({
required int index, required int index,

View File

@@ -47,6 +47,23 @@ class PingResult {
}); });
} }
/// Result of a relay ping (trace path) operation
class RelayPingResult {
final bool success;
final int durationMs;
final double snrThere;
final double snrBack;
final int hopCount;
const RelayPingResult({
required this.success,
required this.durationMs,
required this.snrThere,
required this.snrBack,
required this.hopCount,
});
}
/// Scanned device with RSSI information /// Scanned device with RSSI information
class ScannedDevice { class ScannedDevice {
final BluetoothDevice device; final BluetoothDevice device;
@@ -172,6 +189,8 @@ class ConnectionProvider with ChangeNotifier {
MessageDeliveryTracker(); MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker(); final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {}; final Map<String, Future<PingResult>> _pendingSmartPings = {};
final Map<int, Completer<RelayPingResult>> _pendingRelayPings = {};
final Map<int, int> _relayPingStartTimes = {};
// Expose room login states // Expose room login states
Map<String, RoomLoginState> get roomLoginStates => Map<String, RoomLoginState> get roomLoginStates =>
@@ -631,6 +650,10 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
}; };
service.onTraceDataReceived = (nonce, hopCount, snrThere, snrBack) {
_handleTraceDataReceived(nonce, hopCount, snrThere, snrBack);
};
service.onTxActivity = () { service.onTxActivity = () {
_txActivity = true; _txActivity = true;
notifyListeners(); notifyListeners();
@@ -2036,6 +2059,70 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
/// Ping a relay/repeater using trace path (command 36).
/// Returns RTT, SNR there/back, and hop count.
Future<RelayPingResult> pingRelay(Contact contact) async {
if (!_activeService.isConnected) {
return const RelayPingResult(
success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0,
);
}
final nonce = Random().nextInt(0xFFFFFFFF);
final completer = Completer<RelayPingResult>();
_pendingRelayPings[nonce] = completer;
_relayPingStartTimes[nonce] = DateTime.now().millisecondsSinceEpoch;
// Timeout after 10 seconds
final timer = Timer(const Duration(seconds: 10), () {
_pendingRelayPings.remove(nonce);
_relayPingStartTimes.remove(nonce);
if (!completer.isCompleted) {
completer.complete(const RelayPingResult(
success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0,
));
}
});
try {
// Zero-hop ping: prefixSize=1 sends 1 byte of public key, hopType=0
await _activeService.sendTracePath(
nonce: nonce,
prefixSize: 1,
contactPublicKey: contact.publicKey,
);
final result = await completer.future;
timer.cancel();
return result;
} catch (e) {
timer.cancel();
_pendingRelayPings.remove(nonce);
_relayPingStartTimes.remove(nonce);
return const RelayPingResult(
success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0,
);
}
}
void _handleTraceDataReceived(
int nonce, int hopCount, double snrThere, double snrBack,
) {
final completer = _pendingRelayPings.remove(nonce);
final startTime = _relayPingStartTimes.remove(nonce);
if (completer != null && !completer.isCompleted) {
final durationMs = startTime != null
? DateTime.now().millisecondsSinceEpoch - startTime
: 0;
completer.complete(RelayPingResult(
success: true,
durationMs: durationMs,
snrThere: snrThere,
snrBack: snrBack,
hopCount: hopCount,
));
}
}
String _publicKeyToHex(Uint8List publicKey) { String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
} }
@@ -2880,6 +2967,32 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
/// Set a contact to zero-hop direct mode (no mesh forwarding).
///
/// This forces the firmware to send directly to this contact without
/// using any repeaters, matching the "Send Direct" option in the
/// official MeshCore client.
Future<void> setContactDirect(Contact contact) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
final updatedContact = contact.copyWith(
outPathLen: 0, // descriptor: hashSize=1, hopCount=0 → zero-hop direct
outPath: Uint8List(64),
);
await _activeService.addOrUpdateContact(updatedContact);
} catch (e) {
_error = 'Failed to set direct path: $e';
notifyListeners();
rethrow;
}
}
/// Remove a contact from the companion radio /// Remove a contact from the companion radio
/// ///
/// Deletes the contact from the device's internal contact table. /// Deletes the contact from the device's internal contact table.

View File

@@ -275,10 +275,10 @@ class ContactsProvider with ChangeNotifier {
/// Clear runtime contact state before a live device contact sync begins. /// Clear runtime contact state before a live device contact sync begins.
/// ///
/// This intentionally does not touch persisted storage. It keeps any saved /// This intentionally does not touch persisted storage. It snapshots the
/// contact groups for the active profile, but removes stale in-memory device /// current in-memory contacts so sync updates can still merge against the
/// contacts and discovery state so a newly connected device starts from an /// latest local state, but keeps the visible list intact so reconnects do
/// empty list while sync is in progress. /// not blank the UI while the device is resyncing.
Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async { Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey); _setSelfDevicePublicKey(devicePublicKey);
if (!_isInitialized) { if (!_isInitialized) {
@@ -292,7 +292,7 @@ class ContactsProvider with ChangeNotifier {
} }
debugPrint( debugPrint(
'🧹 [ContactsProvider] Clearing runtime contacts before device sync', '🧹 [ContactsProvider] Preparing retained contact state for device sync',
); );
_retainedContactsForSync _retainedContactsForSync
..clear() ..clear()
@@ -301,12 +301,6 @@ class ContactsProvider with ChangeNotifier {
(contact) => MapEntry(contact.publicKeyHex, contact), (contact) => MapEntry(contact.publicKeyHex, contact),
), ),
); );
_contacts.clear();
_pendingAdverts.clear();
_estimatedLocations.clear();
_rssiObservations.clear();
_ensurePublicChannelExists();
notifyListeners();
} }
/// Remove self-contact from loaded contacts (called after BLE connection established) /// Remove self-contact from loaded contacts (called after BLE connection established)
@@ -1143,13 +1137,34 @@ class ContactsProvider with ChangeNotifier {
), ),
); );
debugPrint(
'📥 [ContactsProvider] Fast GPS received '
'sender=${packet.senderKey6} '
'contact=${contact.displayName} '
'contactKey=${contact.publicKeyHex} '
'lat=${packet.latitude} '
'lon=${packet.longitude} '
'ts=${packet.timestampSeconds}',
);
final updatedContact = contact.copyWith( final updatedContact = contact.copyWith(
telemetry: updatedTelemetry, telemetry: updatedTelemetry,
lastAdvert: packet.timestampSeconds, lastAdvert: packet.timestampSeconds,
lastMod: packet.timestampSeconds,
advLat: _coordinateToAdvertMicrodegrees(packet.latitude), advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
advLon: _coordinateToAdvertMicrodegrees(packet.longitude), advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
); );
_contacts[contact.publicKeyHex] = updatedContact; _contacts[contact.publicKeyHex] = updatedContact;
_estimatedLocations[contact.publicKeyHex] = LatLng(
packet.latitude,
packet.longitude,
);
debugPrint(
'✅ [ContactsProvider] Fast GPS applied '
'contact=${updatedContact.displayName} '
'contactKey=${updatedContact.publicKeyHex} '
'lastAdvert=${updatedContact.lastAdvert}',
);
_persistContacts(); _persistContacts();
notifyListeners(); notifyListeners();
} }

View File

@@ -21,8 +21,8 @@ class MessageRetryManager {
final Map<String, int> _pathFailureStreaks = {}; final Map<String, int> _pathFailureStreaks = {};
/// Max retry attempts when the contact has a known path. /// Max retry attempts when the contact has a known path.
/// Official MeshCore app uses 5 (with auto-retry) or 3 (without). /// Sequence: 2 direct attempts, flood, then last successful route.
static const int maxRetryAttemptsWithPath = 5; static const int maxRetryAttemptsWithPath = 3;
/// No retries for flood-only contacts (no known path). /// No retries for flood-only contacts (no known path).
/// Value 0 means: don't retry at all, go straight to fallback/fail. /// Value 0 means: don't retry at all, go straight to fallback/fail.

View File

@@ -23,6 +23,7 @@ typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
/// Messages Provider - manages message history and SAR markers /// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12); static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
static const Duration _receivedDuplicateWindow = Duration(seconds: 5);
final List<Message> _messages = []; final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {}; final Map<String, SarMarker> _sarMarkers = {};
@@ -664,6 +665,27 @@ class MessagesProvider with ChangeNotifier {
return; // Skip duplicate return; // Skip duplicate
} }
final matchingSentReplayIndex = _findMatchingSentReplayIndex(finalMessage);
if (matchingSentReplayIndex != -1) {
final existingId = _messages[matchingSentReplayIndex].id;
_clearChannelSendWarning(existingId);
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
final existingMessage = _messages[matchingSentReplayIndex];
_messages[matchingSentReplayIndex] = existingMessage.copyWith(
pathLen: finalMessage.pathLen > 0 ? finalMessage.pathLen : existingMessage.pathLen,
pathBytes: finalMessage.pathBytes ?? existingMessage.pathBytes,
);
_persistMessages();
return;
}
_messages.add(finalMessage); _messages.add(finalMessage);
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot; _messageContactLocations[finalMessage.id] = contactLocationSnapshot;
@@ -710,9 +732,25 @@ class MessagesProvider with ChangeNotifier {
return -1; return -1;
} }
final exactDuplicateIndex = _findExactDuplicateMessageIndex(message);
if (exactDuplicateIndex != -1) {
return exactDuplicateIndex;
}
final lastConversationDuplicateIndex =
_findLastConversationDuplicateMessageIndex(message);
if (lastConversationDuplicateIndex != -1) {
return lastConversationDuplicateIndex;
}
return -1;
}
int _findExactDuplicateMessageIndex(Message message) {
for (int index = 0; index < _messages.length; index++) { for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index]; final existing = _messages[index];
if (!_matchesDuplicateScope(existing, message) || if (existing.isSentMessage ||
!_matchesExactDuplicateScope(existing, message) ||
existing.text != message.text) { existing.text != message.text) {
continue; continue;
} }
@@ -729,23 +767,38 @@ class MessagesProvider with ChangeNotifier {
return -1; return -1;
} }
bool _matchesDuplicateScope(Message existing, Message message) { int _findLastConversationDuplicateMessageIndex(Message message) {
for (int index = _messages.length - 1; index >= 0; index--) {
final existing = _messages[index];
if (!_isSameConversation(existing, message)) {
continue;
}
if (existing.isSentMessage ||
existing.isSystemMessage ||
existing.text != message.text) {
return -1;
}
final receivedDelta = existing.receivedAt.difference(message.receivedAt).abs();
if (receivedDelta > _receivedDuplicateWindow) {
return -1;
}
return _matchesDuplicateSenderIdentity(existing, message) ? index : -1;
}
return -1;
}
bool _matchesExactDuplicateScope(Message existing, Message message) {
if (existing.messageType != message.messageType) { if (existing.messageType != message.messageType) {
return false; return false;
} }
if (message.isContactMessage) { if (message.isContactMessage) {
// Match by sender key + sender timestamp (matches official app's DB if (!_isSameConversation(existing, message)) {
// uniqueness: contactPublicKey + senderTimestamp + text + txtType). return false;
if (existing.senderKeyShort == message.senderKeyShort &&
existing.senderTimestamp == message.senderTimestamp) {
return true;
} }
return existing.senderTimestamp == message.senderTimestamp &&
// Fallback dedup when retransmits surface as separate inbound rows _matchesDuplicateSenderIdentity(existing, message);
// without a stable timestamp/message id, but still carry the same
// visible sender identity and payload.
return _matchesDuplicateSenderIdentity(existing, message);
} }
if (message.isChannelMessage) { if (message.isChannelMessage) {
@@ -753,27 +806,40 @@ class MessagesProvider with ChangeNotifier {
return false; return false;
} }
// Primary dedup: same senderTimestamp = same message from mesh repeats.
// Matches official app's DB uniqueness: (channelSecret, senderTimestamp, text).
if (existing.senderTimestamp == message.senderTimestamp) { if (existing.senderTimestamp == message.senderTimestamp) {
return true; return true;
} }
return false;
// Secondary: catch near-duplicate repeats within a 30s window
// (clock drift between nodes).
final withinChannelRepeatWindow =
(existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
if (!withinChannelRepeatWindow) {
return false;
}
return _matchesDuplicateSenderIdentity(existing, message);
} }
// System messages and other types: never deduplicate by scope alone. // System messages and other types: never deduplicate by scope alone.
return false; return false;
} }
bool _isSameConversation(Message existing, Message message) {
if (existing.messageType != message.messageType) {
return false;
}
if (message.isChannelMessage) {
return existing.channelIdx == message.channelIdx;
}
if (!message.isContactMessage) {
return false;
}
if (existing.recipientPublicKey != null && message.recipientPublicKey != null) {
return _listEquals(existing.recipientPublicKey!, message.recipientPublicKey!);
}
if (existing.recipientPublicKey == null && message.recipientPublicKey == null) {
return true;
}
return false;
}
bool _matchesDuplicateSenderIdentity(Message existing, Message message) { bool _matchesDuplicateSenderIdentity(Message existing, Message message) {
final existingSenderKey = existing.senderKeyShort; final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort; final incomingSenderKey = message.senderKeyShort;
@@ -790,6 +856,45 @@ class MessagesProvider with ChangeNotifier {
existingSenderName == incomingSenderName; existingSenderName == incomingSenderName;
} }
int _findMatchingSentReplayIndex(Message message) {
if (!message.isChannelMessage || message.isSentMessage) {
return -1;
}
for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index];
if (!existing.isSentMessage ||
!existing.isChannelMessage ||
existing.text != message.text) {
continue;
}
if (_matchesSentReplayScope(existing, message)) {
return index;
}
}
return -1;
}
bool _matchesSentReplayScope(Message existing, Message message) {
if (!_isSameConversation(existing, message)) {
return false;
}
if (existing.senderTimestamp == message.senderTimestamp) {
return true;
}
final withinChannelRepeatWindow =
(existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
if (!withinChannelRepeatWindow) {
return false;
}
return _matchesDuplicateSenderIdentity(existing, message);
}
/// Add multiple messages /// Add multiple messages
void addMessages(List<Message> messages) { void addMessages(List<Message> messages) {
int addedCount = 0; int addedCount = 0;
@@ -1187,9 +1292,7 @@ class MessagesProvider with ChangeNotifier {
for (final message in messages) { for (final message in messages) {
final occurrenceCount = _messageOccurrenceCount(message); final occurrenceCount = _messageOccurrenceCount(message);
final existingIndex = entries.indexWhere( final existingIndex = entries.indexWhere(
(entry) => (entry) => _shouldCollapseDisplayMessage(entry.message, message),
entry.message.text == message.text &&
_matchesDuplicateScope(entry.message, message),
); );
if (existingIndex == -1) { if (existingIndex == -1) {
@@ -1207,6 +1310,17 @@ class MessagesProvider with ChangeNotifier {
return entries; return entries;
} }
bool _shouldCollapseDisplayMessage(Message existing, Message message) {
if (existing.isSentMessage || message.isSentMessage) {
return false;
}
return existing.text == message.text &&
(_matchesExactDuplicateScope(existing, message) ||
(_isSameConversation(existing, message) &&
_matchesDuplicateSenderIdentity(existing, message)));
}
int _messageOccurrenceCount(Message message) => int _messageOccurrenceCount(Message message) =>
_messageReceptionDetails[message.id]?.receivedCopies ?? 1; _messageReceptionDetails[message.id]?.receivedCopies ?? 1;
@@ -2354,17 +2468,6 @@ class MessagesProvider with ChangeNotifier {
return; return;
} }
// On the last attempt, reset the path to force flood mode
// (matches official MeshCore app behaviour)
if (_retryManager.isLastAttempt(currentMessage, contact)) {
debugPrint(
'🔄 [MessagesProvider] Last attempt — resetting path to flood for $messageId',
);
if (resetPathBeforeLastRetryCallback != null) {
await resetPathBeforeLastRetryCallback!(contact);
}
}
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
final queued = await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,

View File

@@ -14,7 +14,7 @@ enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorsProvider with ChangeNotifier { class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1); static const Duration _successStateRetention = Duration(minutes: 1);
static const int selfAutoRefreshMinutes = 1; static const Duration selfAutoRefreshInterval = Duration(seconds: 30);
static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans'; static const String _fieldSpanKey = 'sensor_field_spans';
@@ -374,18 +374,18 @@ class SensorsProvider with ChangeNotifier {
return List<String>.unmodifiable(dueKeys); return List<String>.unmodifiable(dueKeys);
} }
bool _isRefreshDue( bool _isRefreshDueForInterval(
String publicKeyHex, { String publicKeyHex, {
required int minutes, required Duration interval,
required DateTime refreshTime, required DateTime refreshTime,
}) { }) {
if (minutes <= 0) { if (interval <= Duration.zero) {
return false; return false;
} }
final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex]; final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex];
return lastRefreshAt == null || return lastRefreshAt == null ||
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes); refreshTime.difference(lastRefreshAt) >= interval;
} }
Future<void> toggleMetric( Future<void> toggleMetric(
@@ -769,9 +769,9 @@ class SensorsProvider with ChangeNotifier {
final self = selfContact(contactsProvider, connectionProvider); final self = selfContact(contactsProvider, connectionProvider);
if (self != null && if (self != null &&
!dueKeys.contains(self.publicKeyHex) && !dueKeys.contains(self.publicKeyHex) &&
_isRefreshDue( _isRefreshDueForInterval(
self.publicKeyHex, self.publicKeyHex,
minutes: selfAutoRefreshMinutes, interval: selfAutoRefreshInterval,
refreshTime: refreshTime, refreshTime: refreshTime,
)) { )) {
dueKeys.insert(0, self.publicKeyHex); dueKeys.insert(0, self.publicKeyHex);

View File

@@ -19,6 +19,8 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart'; import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart'; import '../widgets/contacts/add_channel_dialog.dart';
import '../services/region_scope_preferences.dart';
import '../utils/toast_logger.dart';
import 'add_contact_screen.dart'; import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget { class ContactsTab extends StatefulWidget {
@@ -546,6 +548,15 @@ class _ContactsTabState extends State<ContactsTab> {
await _exportHashChannelPskBase64(context, channel); await _exportHashChannelPskBase64(context, channel);
}, },
), ),
_ChannelSheetAction(
icon: Icons.language_rounded,
label: l10n.setRegionScope,
onTap: () async {
Navigator.pop(context);
if (!context.mounted) return;
_showRegionScopeForChannel(context, channel);
},
),
if (!channel.isPublicChannel) if (!channel.isPublicChannel)
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.delete_outline_rounded, icon: Icons.delete_outline_rounded,
@@ -575,6 +586,38 @@ class _ContactsTabState extends State<ContactsTab> {
); );
} }
void _showRegionScopeForChannel(BuildContext context, Contact channel) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final l10n = AppLocalizations.of(context)!;
final currentScope = await RegionScopePreferences.getScope(channelIdx);
if (!context.mounted) return;
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _ContactsRegionScopeSheet(
currentScopeName: currentScope?.name,
l10n: l10n,
onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop();
if (name == null) {
await RegionScopePreferences.clearScope(channelIdx);
if (!context.mounted) return;
ToastLogger.success(context, l10n.regionScopeCleared);
} else {
final key = RegionScopePreferences.deriveRegionKey(name);
await RegionScopePreferences.setScope(channelIdx, name, key);
if (!context.mounted) return;
ToastLogger.success(context, l10n.regionScopeSet(name));
}
},
);
},
);
}
Color _sectionAccentColor(BuildContext context, ContactSection section) { Color _sectionAccentColor(BuildContext context, ContactSection section) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
switch (section) { switch (section) {
@@ -2394,3 +2437,165 @@ class _MetricChip extends StatelessWidget {
); );
} }
} }
class _ContactsRegionScopeSheet extends StatefulWidget {
final String? currentScopeName;
final AppLocalizations l10n;
final ValueChanged<String?> onScopeSelected;
const _ContactsRegionScopeSheet({
required this.currentScopeName,
required this.l10n,
required this.onScopeSelected,
});
@override
State<_ContactsRegionScopeSheet> createState() =>
_ContactsRegionScopeSheetState();
}
class _ContactsRegionScopeSheetState
extends State<_ContactsRegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
void _submitManualName() {
var name = _nameController.text.trim();
if (name.isEmpty) return;
if (!name.startsWith('#')) name = '#$name';
widget.onScopeSelected(name);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = widget.l10n;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.72,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.regionScope,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l10n.regionScopeWarning,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_ScopeOption(
label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null,
onTap: () => widget.onScopeSelected(null),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: InputDecoration(
hintText: l10n.enterRegionName,
isDense: true,
prefixText: '#',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onSubmitted: (_) => _submitManualName(),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: _submitManualName,
child: const Icon(Icons.check_rounded, size: 20),
),
],
),
],
),
),
),
);
}
}
class _ScopeOption extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ScopeOption({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
isSelected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../models/ble_packet_log.dart'; import '../models/ble_packet_log.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../providers/channels_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/live_traffic_summary.dart'; import '../services/live_traffic_summary.dart';
@@ -155,6 +156,11 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
tooltip: 'Open packet logs', tooltip: 'Open packet logs',
icon: const Icon(Icons.list_alt_rounded), icon: const Icon(Icons.list_alt_rounded),
), ),
IconButton(
onPressed: () => _showPacketTypeHelpSheet(context),
tooltip: 'Packet type help',
icon: const Icon(Icons.help_outline),
),
IconButton( IconButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
@@ -278,6 +284,68 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
_selectedWindow = selected; _selectedWindow = selected;
}); });
} }
Future<void> _showPacketTypeHelpSheet(BuildContext context) {
final packetTypes = LiveTrafficEntry.knownPayloadTypes;
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: ListView(
shrinkWrap: true,
children: [
Text(
'Packet Types',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'Descriptions below follow the current MeshCore payload definitions.',
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
for (final packetType in packetTypes) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packetType.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
packetType.description,
style: TextStyle(
fontSize: 13,
color: scheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: 10),
],
],
),
),
);
},
);
}
} }
class _SummaryPanel extends StatelessWidget { class _SummaryPanel extends StatelessWidget {
@@ -676,7 +744,11 @@ class _LiveTrafficCard extends StatelessWidget {
final accent = isRx ? Colors.green : Colors.blue; final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo; final rxInfo = log.logRxDataInfo;
final originDistance = _originDistanceLabel(context, entry); final originDistance = _originDistanceLabel(context, entry);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry); final channelsProvider = _maybeProvider<ChannelsProvider>(context);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(
entry,
channelsProvider: channelsProvider,
);
final signalMetric = SignalMetric.fromRxInfo(rxInfo); final signalMetric = SignalMetric.fromRxInfo(rxInfo);
return Material( return Material(
@@ -730,7 +802,7 @@ class _LiveTrafficCard extends StatelessWidget {
if (entry.payloadMeaning != null) if (entry.payloadMeaning != null)
Text( Text(
entry.payloadMeaning!, entry.payloadMeaning!,
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -989,27 +1061,22 @@ class _LiveTrafficPacketDetails {
required this.endpointLine, required this.endpointLine,
}); });
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) { factory _LiveTrafficPacketDetails.fromEntry(
LiveTrafficEntry entry, {
ChannelsProvider? channelsProvider,
}) {
final route = entry.route; final route = entry.route;
final payloadType = route?.payloadType; final payloadType = route?.payloadType;
final parsedPayload = _ParsedTrafficPayload.tryParse( final parsedPayload = _ParsedTrafficPayload.tryParse(
entry.log.rawData, entry.log.rawData,
route, route,
channelsProvider: channelsProvider,
); );
final title = switch (payloadType) { final title = switch (payloadType) {
0x00 => 'FLOOD REQUEST', 0x05 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x05),
0x01 => 'FLOOD RESPONSE', 0x06 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x06),
0x02 => 'FLOOD TEXT', null => entry.payloadLabel.toUpperCase(),
0x03 => 'FLOOD ACK', _ => LiveTrafficEntry.payloadTypeTitle(payloadType),
0x04 => 'FLOOD ADVERTISEMENT',
0x05 => 'FLOOD GROUP_TEXT',
0x06 => 'FLOOD GROUP_DATA',
0x07 => 'FLOOD ANON_REQUEST',
0x08 => 'FLOOD RETURNED_PATH',
0x09 => 'FLOOD TRACE_PATH',
0x0A => 'FLOOD MULTIPART',
0x0B => 'FLOOD CONTROL',
_ => entry.payloadLabel.toUpperCase(),
}; };
final hopHashes = route?.hopHashes ?? const <String>[]; final hopHashes = route?.hopHashes ?? const <String>[];
@@ -1041,13 +1108,15 @@ class _LiveTrafficPacketDetails {
class _ParsedTrafficPayload { class _ParsedTrafficPayload {
final String? endpointLine; final String? endpointLine;
final String? channelDisplayName;
const _ParsedTrafficPayload({this.endpointLine}); const _ParsedTrafficPayload({this.endpointLine, this.channelDisplayName});
static _ParsedTrafficPayload? tryParse( static _ParsedTrafficPayload? tryParse(
List<int> rawData, List<int> rawData,
DecodedLogRxRoute? route, DecodedLogRxRoute? route, {
) { ChannelsProvider? channelsProvider,
}) {
if (rawData.length < 5 || if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) { rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return null; return null;
@@ -1084,9 +1153,18 @@ class _ParsedTrafficPayload {
case 0x05: case 0x05:
case 0x06: case 0x06:
if (payload.isEmpty) return const _ParsedTrafficPayload(); if (payload.isEmpty) return const _ParsedTrafficPayload();
final channelHash = payload.first;
final channelHashHex = channelHash
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final channelDisplayName = channelsProvider
?.getUniqueChannelDisplayNameByHashByte(channelHash);
return _ParsedTrafficPayload( return _ParsedTrafficPayload(
endpointLine: channelDisplayName: channelDisplayName,
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}', endpointLine: channelDisplayName == null
? 'Channel Hash: $channelHashHex'
: 'Channel: $channelDisplayName ($channelHashHex)',
); );
case 0x00: case 0x00:
case 0x01: case 0x01:

View File

@@ -37,7 +37,6 @@ import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/region_scope_preferences.dart'; import '../services/region_scope_preferences.dart';
import '../services/region_discovery_service.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -149,11 +148,13 @@ class _MessagesTabState extends State<MessagesTab> {
TextRange? _activeMentionRange; TextRange? _activeMentionRange;
String _mentionQuery = ''; String _mentionQuery = '';
List<Contact> _mentionSuggestions = const []; List<Contact> _mentionSuggestions = const [];
ContactsProvider? _contactsProvider;
// Message destination state // Message destination state
String _destinationType = String _destinationType =
MessageDestinationPreferences.destinationTypeChannel; MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient; Contact? _selectedRecipient;
bool _isDestinationLocked = false;
// Region scope state // Region scope state
String? _channelRegionScopeName; String? _channelRegionScopeName;
@@ -184,13 +185,9 @@ class _MessagesTabState extends State<MessagesTab> {
super.initState(); super.initState();
_textController.addListener(_handleComposerChanged); _textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged); _focusNode.addListener(_handleFocusChanged);
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings(); _loadVoiceSettings();
_loadAllChannelRegionScopes(); _loadAllChannelRegionScopes();
WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleDestinationSync();
_checkForNavigationRequest();
});
} }
Future<void> _loadVoiceSettings() async { Future<void> _loadVoiceSettings() async {
@@ -204,11 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// Reload saved destination and check for navigation request whenever dependencies change final contactsProvider = context.read<ContactsProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) { if (!identical(_contactsProvider, contactsProvider)) {
_loadSavedDestination(); _contactsProvider?.removeListener(_handleContactsChanged);
_checkForNavigationRequest(); _contactsProvider = contactsProvider;
}); _contactsProvider?.addListener(_handleContactsChanged);
}
_scheduleDestinationSync();
} }
@override @override
@@ -217,6 +216,7 @@ class _MessagesTabState extends State<MessagesTab> {
_channelReadTimer?.cancel(); _channelReadTimer?.cancel();
_voiceStreamSub?.cancel(); _voiceStreamSub?.cancel();
_voiceRecorder.dispose(); _voiceRecorder.dispose();
_contactsProvider?.removeListener(_handleContactsChanged);
_focusNode.removeListener(_handleFocusChanged); _focusNode.removeListener(_handleFocusChanged);
_textController.dispose(); _textController.dispose();
_focusNode.dispose(); _focusNode.dispose();
@@ -229,13 +229,37 @@ class _MessagesTabState extends State<MessagesTab> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) { if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>()); _syncChannelAutoReadTimer(context.read<MessagesProvider>());
if (widget.isActive) {
_scheduleDestinationSync();
}
} }
} }
void _checkForNavigationRequest() { void _scheduleDestinationSync() {
WidgetsBinding.instance.addPostFrameCallback((_) {
_synchronizeDestinationState();
});
}
void _handleContactsChanged() {
if (!mounted) return;
_scheduleDestinationSync();
}
Future<void> _synchronizeDestinationState() async {
if (!mounted) return;
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId; final targetMessageId = messagesProvider.targetMessageId;
final targetDestinationType = messagesProvider.targetDestinationType; final targetDestinationType = messagesProvider.targetDestinationType;
final targetRecipientPublicKeyHex =
messagesProvider.targetRecipientPublicKeyHex;
await _restoreDestinationState(
overrideType: targetDestinationType,
overrideRecipientPublicKeyHex: targetRecipientPublicKeyHex,
);
if (!mounted) return;
if (targetMessageId != null) { if (targetMessageId != null) {
_scrollToMessage(targetMessageId); _scrollToMessage(targetMessageId);
@@ -243,11 +267,8 @@ class _MessagesTabState extends State<MessagesTab> {
} }
if (targetDestinationType != null) { if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation(); messagesProvider.clearDestinationNavigation();
_focusNode.requestFocus();
} }
} }
@@ -448,56 +469,91 @@ class _MessagesTabState extends State<MessagesTab> {
_updateCharacterCount(); _updateCharacterCount();
} }
/// Load saved message destination from preferences Future<void> _restoreDestinationState({
Future<void> _loadSavedDestination() async { String? overrideType,
String? overrideRecipientPublicKeyHex,
}) async {
final lockedDestination =
await MessageDestinationPreferences.getLockedDestination();
final savedDestination = final savedDestination =
await MessageDestinationPreferences.getDestination(); await MessageDestinationPreferences.getDestination();
final effectiveType =
overrideType ??
lockedDestination?['type'] ??
savedDestination?['type'] ??
MessageDestinationPreferences.destinationTypeChannel;
final effectivePublicKeyHex =
overrideRecipientPublicKeyHex ??
lockedDestination?['publicKey'] ??
savedDestination?['publicKey'];
if (!mounted) return;
final contactsProvider = context.read<ContactsProvider>();
final recipient = _resolveDestinationRecipient(
contactsProvider,
effectiveType,
effectivePublicKeyHex,
);
final allowsEmptyRecipient =
effectiveType == MessageDestinationPreferences.destinationTypeAll ||
(effectiveType == MessageDestinationPreferences.destinationTypeChannel &&
effectivePublicKeyHex == null);
final shouldFallbackToPublicChannel =
recipient == null && !allowsEmptyRecipient;
final destinationType = shouldFallbackToPublicChannel
? MessageDestinationPreferences.destinationTypeChannel
: effectiveType;
final selectedRecipient = shouldFallbackToPublicChannel ? null : recipient;
final shouldClearSavedDestination =
lockedDestination == null &&
overrideType == null &&
shouldFallbackToPublicChannel &&
savedDestination != null;
if (savedDestination == null || !mounted) { if (!mounted) return;
// Default to public channel
return;
}
final type = savedDestination['type']!;
final publicKey = savedDestination['publicKey'];
setState(() { setState(() {
_destinationType = type; _isDestinationLocked = lockedDestination != null;
_destinationType = destinationType;
_selectedRecipient = selectedRecipient;
}); });
// If it's a contact or room, try to find it in the contacts list if (shouldClearSavedDestination) {
if (publicKey != null && mounted) { await MessageDestinationPreferences.clearDestination();
final contactsProvider = context.read<ContactsProvider>();
final contact = contactsProvider.contacts.where((c) {
return c.publicKeyHex == publicKey;
}).firstOrNull;
if (contact != null) {
setState(() {
_selectedRecipient = contact;
});
} else {
// Contact/room not found, fallback to public channel
debugPrint(
'⚠️ [MessagesTab] Saved recipient not found, falling back to public channel',
);
setState(() {
_destinationType =
MessageDestinationPreferences.destinationTypeChannel;
_selectedRecipient = null;
});
await MessageDestinationPreferences.clearDestination();
}
} }
_enforceMessageByteLimit(); _enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope(); await _loadRegionScope();
} }
Contact? _resolveDestinationRecipient(
ContactsProvider contactsProvider,
String type,
String? publicKeyHex,
) {
if (publicKeyHex == null) {
return null;
}
final candidates = switch (type) {
MessageDestinationPreferences.destinationTypeChannel =>
contactsProvider.channels,
MessageDestinationPreferences.destinationTypeRoom => contactsProvider.rooms,
MessageDestinationPreferences.destinationTypeContact =>
contactsProvider.chatContacts,
_ => contactsProvider.contacts,
};
return candidates.where((contact) {
return contact.publicKeyHex == publicKeyHex;
}).firstOrNull;
}
/// Show recipient selector bottom sheet /// Show recipient selector bottom sheet
void _showRecipientSelector() { void _showRecipientSelector() {
if (_isDestinationLocked) {
return;
}
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
@@ -553,7 +609,11 @@ class _MessagesTabState extends State<MessagesTab> {
} }
/// Handle recipient selection /// Handle recipient selection
Future<void> _onRecipientSelected(String type, Contact? recipient) async { Future<void> _onRecipientSelected(
String type,
Contact? recipient, {
bool persistSelection = true,
}) async {
setState(() { setState(() {
_destinationType = type; _destinationType = type;
_selectedRecipient = recipient; _selectedRecipient = recipient;
@@ -566,11 +626,12 @@ class _MessagesTabState extends State<MessagesTab> {
// Load region scope for channel destinations // Load region scope for channel destinations
await _loadRegionScope(); await _loadRegionScope();
// Save to preferences if (persistSelection) {
await MessageDestinationPreferences.setDestination( await MessageDestinationPreferences.setDestination(
type, type,
recipientPublicKey: recipient?.publicKeyHex, recipientPublicKey: recipient?.publicKeyHex,
); );
}
// Show confirmation toast // Show confirmation toast
if (!mounted) return; if (!mounted) return;
@@ -621,23 +682,6 @@ class _MessagesTabState extends State<MessagesTab> {
}); });
} }
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, {TextRange? replacementRange}) { void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
final trimmedName = displayName.trim(); final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return; if (trimmedName.isEmpty) return;
@@ -747,7 +791,11 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
await _onRecipientSelected(destinationType, recipient); await _onRecipientSelected(
destinationType,
recipient,
persistSelection: !_isDestinationLocked,
);
if (!mounted) return; if (!mounted) return;
if ((message.isChannelMessage || recipient?.isRoom == true) && if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null && senderDisplayName != null &&
@@ -944,6 +992,69 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
bool _isContactDestination() {
return _destinationType ==
MessageDestinationPreferences.destinationTypeContact &&
_selectedRecipient != null;
}
void _showSendModeSheet() {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.route),
title: Text(l10n.autoSend),
subtitle: Text(l10n.autoSendDescription),
onTap: () {
Navigator.pop(sheetContext);
_sendMessage();
},
),
ListTile(
leading: const Icon(Icons.near_me),
title: Text(l10n.sendDirect),
subtitle: Text(l10n.sendDirectDescription),
onTap: () async {
Navigator.pop(sheetContext);
final connectionProvider = context.read<ConnectionProvider>();
if (_selectedRecipient != null &&
connectionProvider.deviceInfo.isConnected) {
await connectionProvider
.setContactDirect(_selectedRecipient!);
}
await _sendMessage();
},
),
ListTile(
leading: const Icon(Icons.cell_tower),
title: Text(l10n.sendFlood),
subtitle: Text(l10n.sendFloodDescription),
onTap: () async {
Navigator.pop(sheetContext);
final connectionProvider = context.read<ConnectionProvider>();
if (_selectedRecipient != null &&
connectionProvider.deviceInfo.isConnected) {
await connectionProvider
.resetPath(_selectedRecipient!.publicKey);
}
await _sendMessage();
},
),
],
),
);
},
);
}
Future<void> _startTicTacToeGame() async { Future<void> _startTicTacToeGame() async {
if (!mounted) return; if (!mounted) return;
if (_destinationType != if (_destinationType !=
@@ -1763,12 +1874,7 @@ class _MessagesTabState extends State<MessagesTab> {
void _showRegionScopeSheet() { void _showRegionScopeSheet() {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final contactsProvider = context.read<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>();
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final repeaters = contactsProvider.contacts
.where((c) => c.isRepeater)
.toList();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -1777,8 +1883,6 @@ class _MessagesTabState extends State<MessagesTab> {
builder: (sheetContext) { builder: (sheetContext) {
return _RegionScopeSheet( return _RegionScopeSheet(
currentScopeName: _channelRegionScopeName, currentScopeName: _channelRegionScopeName,
repeaters: repeaters,
connectionProvider: connectionProvider,
l10n: l10n, l10n: l10n,
onScopeSelected: (String? name) async { onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
@@ -2387,6 +2491,7 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding, bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(), destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context), destinationAvatar: _buildDestinationAvatar(context),
destinationLocked: _isDestinationLocked,
mentionSuggestions: _mentionSuggestions, mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery, mentionQuery: _mentionQuery,
onMentionSelected: _selectMention, onMentionSelected: _selectMention,
@@ -2395,6 +2500,9 @@ class _MessagesTabState extends State<MessagesTab> {
onStartVoiceRecording: _startVoiceRecording, onStartVoiceRecording: _startVoiceRecording,
onStopAndSendVoice: _stopAndSendVoice, onStopAndSendVoice: _stopAndSendVoice,
onSendMessage: _sendMessage, onSendMessage: _sendMessage,
onLongPressSend: _isContactDestination()
? _showSendModeSheet
: null,
regionScopeName: _channelRegionScopeName, regionScopeName: _channelRegionScopeName,
onRegionScopeTap: _channelRegionScopeName != null onRegionScopeTap: _channelRegionScopeName != null
? _showRegionScopeSheet ? _showRegionScopeSheet
@@ -2412,15 +2520,11 @@ class _MessagesTabState extends State<MessagesTab> {
/// Bottom sheet for selecting a region scope for the current channel. /// Bottom sheet for selecting a region scope for the current channel.
class _RegionScopeSheet extends StatefulWidget { class _RegionScopeSheet extends StatefulWidget {
final String? currentScopeName; final String? currentScopeName;
final List<Contact> repeaters;
final ConnectionProvider connectionProvider;
final AppLocalizations l10n; final AppLocalizations l10n;
final ValueChanged<String?> onScopeSelected; final ValueChanged<String?> onScopeSelected;
const _RegionScopeSheet({ const _RegionScopeSheet({
required this.currentScopeName, required this.currentScopeName,
required this.repeaters,
required this.connectionProvider,
required this.l10n, required this.l10n,
required this.onScopeSelected, required this.onScopeSelected,
}); });
@@ -2431,8 +2535,6 @@ class _RegionScopeSheet extends StatefulWidget {
class _RegionScopeSheetState extends State<_RegionScopeSheet> { class _RegionScopeSheetState extends State<_RegionScopeSheet> {
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
List<String> _discoveredRegions = [];
bool _isDiscovering = false;
@override @override
void dispose() { void dispose() {
@@ -2440,30 +2542,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
super.dispose(); super.dispose();
} }
Future<void> _discoverRegions() async {
if (widget.repeaters.isEmpty) return;
setState(() => _isDiscovering = true);
final allRegions = <String>{};
for (final repeater in widget.repeaters) {
final regions = await RegionDiscoveryService.discoverFromRepeater(
repeaterPublicKey: repeater.publicKey,
connectionProvider: widget.connectionProvider,
);
allRegions.addAll(regions);
}
if (!mounted) return;
setState(() {
_discoveredRegions = allRegions.toList()..sort();
_isDiscovering = false;
});
if (_discoveredRegions.isEmpty && mounted) {
ToastLogger.info(context, widget.l10n.noRegionsFound);
}
}
void _submitManualName() { void _submitManualName() {
var name = _nameController.text.trim(); var name = _nameController.text.trim();
if (name.isEmpty) return; if (name.isEmpty) return;
@@ -2504,7 +2582,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// "None" option
_RegionOptionTile( _RegionOptionTile(
label: l10n.regionScopeNone, label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null, isSelected: widget.currentScopeName == null,
@@ -2512,7 +2589,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Manual entry
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -2540,38 +2616,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
), ),
], ],
), ),
const SizedBox(height: 16),
// Discover button
if (widget.repeaters.isNotEmpty)
FilledButton.tonalIcon(
onPressed: _isDiscovering ? null : _discoverRegions,
icon: _isDiscovering
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search_rounded, size: 18),
label: Text(
_isDiscovering
? l10n.discoveringRegions
: l10n.discoverRegions,
),
),
// Discovered regions
if (_discoveredRegions.isNotEmpty) ...[
const SizedBox(height: 12),
for (final region in _discoveredRegions) ...[
_RegionOptionTile(
label: region,
isSelected: widget.currentScopeName == region,
onTap: () => widget.onScopeSelected(region),
),
const SizedBox(height: 4),
],
],
], ],
), ),
), ),

View File

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart';
import '../providers/sensors_provider.dart'; import '../providers/sensors_provider.dart';
import '../widgets/sensors/bthome_met_history_sheet.dart'; import '../widgets/sensors/bthome_met_history_sheet.dart';
import '../widgets/sensors/sensor_telemetry_card.dart'; import '../widgets/sensors/sensor_telemetry_card.dart';
@@ -21,7 +22,10 @@ class SensorsTab extends StatefulWidget {
} }
class _SensorsTabState extends State<SensorsTab> { class _SensorsTabState extends State<SensorsTab> {
static const Duration _autoRefreshTickInterval = Duration(seconds: 30);
Timer? _minuteTicker; Timer? _minuteTicker;
final Map<String, DateTime> _lastCenteredTelemetryAtBySensor =
<String, DateTime>{};
@override @override
void initState() { void initState() {
@@ -61,22 +65,8 @@ class _SensorsTabState extends State<SensorsTab> {
return; return;
} }
final now = DateTime.now(); _minuteTicker = Timer.periodic(_autoRefreshTickInterval, (_) {
final nextMinute = DateTime(
now.year,
now.month,
now.day,
now.hour,
now.minute + 1,
);
final delay = nextMinute.difference(now);
_minuteTicker = Timer(delay, () {
if (!mounted) return;
unawaited(_handleMinuteTick()); unawaited(_handleMinuteTick());
_minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
unawaited(_handleMinuteTick());
});
}); });
} }
@@ -225,6 +215,65 @@ class _SensorsTabState extends State<SensorsTab> {
); );
} }
void _maybeCenterMapOnTelemetryUpdate(
Iterable<String> sensorKeys, {
required SensorsProvider sensorsProvider,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
if (!widget.isActive) {
return;
}
Contact? latestContact;
DateTime? latestTimestamp;
for (final key in sensorKeys) {
final contact = sensorsProvider.contactForDisplay(
key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final timestamp = contact?.telemetry?.timestamp;
final location = contact?.displayLocation;
if (contact == null || timestamp == null || location == null) {
continue;
}
final previousTimestamp = _lastCenteredTelemetryAtBySensor[key];
if (previousTimestamp != null && !timestamp.isAfter(previousTimestamp)) {
continue;
}
if (latestTimestamp == null || timestamp.isAfter(latestTimestamp)) {
latestContact = contact;
latestTimestamp = timestamp;
}
}
if (latestContact == null || latestTimestamp == null) {
return;
}
_lastCenteredTelemetryAtBySensor[latestContact.publicKeyHex] =
latestTimestamp;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.isActive) {
return;
}
final location = latestContact!.displayLocation;
if (location == null) {
return;
}
context.read<MapProvider>().navigateToLocation(
location: location,
zoom: 15.0,
);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -247,6 +296,16 @@ class _SensorsTabState extends State<SensorsTab> {
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
connectionProvider: connectionProvider, connectionProvider: connectionProvider,
); );
final displayKeys = <String>[
...?selfDisplayKey == null ? null : <String>[selfDisplayKey],
...watchedKeys,
];
_maybeCenterMapOnTelemetryUpdate(
displayKeys,
sensorsProvider: sensorsProvider,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final hasAnyCards = final hasAnyCards =
selfDisplayKey != null || watchedKeys.isNotEmpty; selfDisplayKey != null || watchedKeys.isNotEmpty;

View File

@@ -25,6 +25,7 @@ import '../services/update_checker_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/message_destination_preferences.dart';
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
@@ -60,6 +61,8 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
static const String _publicChannelPublicKeyHex =
'0000000000000000000000000000000000000000000000000000000000000000';
late AppThemeMode _selectedTheme; late AppThemeMode _selectedTheme;
late Locale? _selectedLocale; late Locale? _selectedLocale;
PackageInfo? _packageInfo; PackageInfo? _packageInfo;
@@ -91,6 +94,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _muteForegroundNotifications = true; bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false; bool _isDeveloperModeEnabled = false;
bool _profilesEnabled = false; bool _profilesEnabled = false;
bool _messageDestinationLockEnabled = false;
String _messageDestinationLockType =
MessageDestinationPreferences.destinationTypeChannel;
String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex;
DateTime? _onlineTraceCacheUpdatedAt; DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false; bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0; int _versionTapCount = 0;
@@ -114,6 +121,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadOnlineTraceCacheStatus(); _loadOnlineTraceCacheStatus();
_loadMapPreferences(); _loadMapPreferences();
_loadNotificationPreferences(); _loadNotificationPreferences();
_loadMessageDestinationLock();
} }
@override @override
@@ -184,6 +192,94 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _loadMessageDestinationLock() async {
final lockedDestination =
await MessageDestinationPreferences.getLockedDestination();
if (!mounted) return;
setState(() {
_messageDestinationLockEnabled = lockedDestination != null;
_messageDestinationLockType =
lockedDestination?['publicKey'] == null
? MessageDestinationPreferences.destinationTypeChannel
: lockedDestination?['type'] ??
MessageDestinationPreferences.destinationTypeChannel;
_messageDestinationLockPublicKey =
lockedDestination?['publicKey'] ?? _publicChannelPublicKeyHex;
});
}
List<Contact> _messageDestinationLockOptions(
ContactsProvider contactsProvider,
) {
final channels = List<Contact>.from(contactsProvider.channels)
..sort((a, b) {
if (a.isPublicChannel != b.isPublicChannel) {
return a.isPublicChannel ? -1 : 1;
}
return a.displayName.toLowerCase().compareTo(
b.displayName.toLowerCase(),
);
});
final rooms = List<Contact>.from(contactsProvider.rooms)
..sort(
(a, b) => a.displayName.toLowerCase().compareTo(
b.displayName.toLowerCase(),
),
);
return [...channels, ...rooms];
}
String _messageDestinationLockLabel(BuildContext context, Contact contact) {
final name = contact.isChannel
? contact.getLocalizedDisplayName(context)
: contact.displayName;
return contact.isRoom ? 'Room: $name' : 'Channel: $name';
}
String _messageDestinationLockTypeForContact(Contact contact) {
return contact.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeChannel;
}
String? _selectedMessageDestinationLockValue(List<Contact> destinations) {
final currentValue = _messageDestinationLockPublicKey;
if (currentValue != null &&
destinations.any((contact) => contact.publicKeyHex == currentValue)) {
return currentValue;
}
return destinations.isEmpty ? null : destinations.first.publicKeyHex;
}
Future<void> _setMessageDestinationLock({
required bool enabled,
String? type,
String? recipientPublicKey,
}) async {
final nextType = type ?? _messageDestinationLockType;
final nextRecipientPublicKey =
recipientPublicKey ??
_messageDestinationLockPublicKey ??
_publicChannelPublicKeyHex;
await MessageDestinationPreferences.setLockedDestination(
enabled: enabled,
type: nextType,
recipientPublicKey: enabled ? nextRecipientPublicKey : null,
);
if (!mounted) return;
setState(() {
_messageDestinationLockEnabled = enabled;
_messageDestinationLockType = nextType;
_messageDestinationLockPublicKey = nextRecipientPublicKey;
});
}
Future<void> _handleVersionTap() async { Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) { if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false); await DeveloperModeService.setEnabled(false);
@@ -341,7 +437,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Meters', labelText: 'Meters',
helperText: 'Valid range: 1 to 1000 meters', helperText: 'Valid range: 10 to 1000 meters',
), ),
), ),
actions: [ actions: [
@@ -353,7 +449,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () { onPressed: () {
final parsed = double.tryParse(controller.text.trim()); final parsed = double.tryParse(controller.text.trim());
if (parsed == null) return; if (parsed == null) return;
Navigator.pop(context, parsed.clamp(1.0, 1000.0)); Navigator.pop(context, parsed.clamp(10.0, 1000.0));
}, },
child: const Text('Save'), child: const Text('Save'),
), ),
@@ -382,7 +478,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Seconds', labelText: 'Seconds',
helperText: 'Valid range: 5 to 60 seconds', helperText: 'Valid range: 10 to 31 seconds',
), ),
), ),
actions: [ actions: [
@@ -394,7 +490,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () { onPressed: () {
final parsed = int.tryParse(controller.text.trim()); final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return; if (parsed == null) return;
Navigator.pop(context, parsed.clamp(5, 60)); Navigator.pop(context, parsed.clamp(10, 31));
}, },
child: const Text('Save'), child: const Text('Save'),
), ),
@@ -426,9 +522,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
return 'Channel $channelIdx unavailable'; return 'Channel $channelIdx unavailable';
} }
Future<void> _sendTestFastLocationUpdate() async {
final appProvider = context.read<AppProvider>();
final sent = await appProvider.sendTestFastLocationUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
sent
? 'Test fast GPS update sent.'
: 'Unable to send test fast GPS update.',
),
backgroundColor: sent ? Colors.green : Colors.orange,
),
);
}
Future<void> _editFastLocationChannel() async { Future<void> _editFastLocationChannel() async {
final channels = final channels =
List<Contact>.from(context.read<ContactsProvider>().channels) List<Contact>.from(context.read<ContactsProvider>().channels)
..removeWhere((c) => c.isPublicChannel)
..sort((a, b) { ..sort((a, b) {
final aIdx = a.publicKey.length > 1 ? a.publicKey[1] : 0; final aIdx = a.publicKey.length > 1 ? a.publicKey[1] : 0;
final bIdx = b.publicKey.length > 1 ? b.publicKey[1] : 0; final bIdx = b.publicKey.length > 1 ? b.publicKey[1] : 0;
@@ -1412,6 +1526,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
SwitchListTile(
secondary: const Icon(Icons.lock_outline),
title: const Text('Lock messages to one channel or room'),
subtitle: const Text(
'Keep the Messages tab and composer fixed on one destination. Direct messages from Contacts still open as usual.',
),
value: _messageDestinationLockEnabled,
onChanged: (value) async {
final contactsProvider = context.read<ContactsProvider>();
final options = _messageDestinationLockOptions(
contactsProvider,
);
final selectedPublicKey =
_selectedMessageDestinationLockValue(options) ??
_publicChannelPublicKeyHex;
final selectedContact = options.where((contact) {
return contact.publicKeyHex == selectedPublicKey;
}).firstOrNull;
await _setMessageDestinationLock(
enabled: value,
type: selectedContact == null
? MessageDestinationPreferences.destinationTypeChannel
: _messageDestinationLockTypeForContact(selectedContact),
recipientPublicKey: selectedPublicKey,
);
},
),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final options = _messageDestinationLockOptions(
contactsProvider,
);
final selectedValue =
_selectedMessageDestinationLockValue(options);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: DropdownButtonFormField<String>(
key: ValueKey(selectedValue),
initialValue: selectedValue,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Locked channel or room',
prefixIcon: Icon(Icons.forum_outlined),
border: OutlineInputBorder(),
),
items: [
for (final contact in options)
DropdownMenuItem<String>(
value: contact.publicKeyHex,
child: Text(
_messageDestinationLockLabel(context, contact),
overflow: TextOverflow.ellipsis,
),
),
],
onChanged:
_messageDestinationLockEnabled && options.isNotEmpty
? (value) async {
if (value == null) {
return;
}
final selectedContact = options.where((contact) {
return contact.publicKeyHex == value;
}).firstOrNull;
if (selectedContact == null) {
return;
}
await _setMessageDestinationLock(
enabled: true,
type: _messageDestinationLockTypeForContact(
selectedContact,
),
recipientPublicKey: value,
);
}
: null,
),
);
},
),
ListTile( ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red), leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text( title: const Text(
@@ -1853,6 +2051,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationChannel, onTap: _editFastLocationChannel,
), ),
ListTile(
leading: const Icon(Icons.send),
title: const Text('Test send update'),
subtitle: const Text(
'Send one fast GPS update immediately to the configured channel.',
),
trailing: const Icon(Icons.chevron_right),
onTap: _sendTestFastLocationUpdate,
),
ListTile( ListTile(
leading: Icon(Icons.location_on), leading: Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission), title: Text(AppLocalizations.of(context)!.locationPermission),

View File

@@ -144,11 +144,11 @@ class AppConfigSnapshotService {
} }
if (section.fastLocationMovementThresholdMeters != null) { if (section.fastLocationMovementThresholdMeters != null) {
locationTracking.fastLocationMovementThresholdMeters = locationTracking.fastLocationMovementThresholdMeters =
section.fastLocationMovementThresholdMeters!; section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0);
} }
if (section.fastLocationActiveCadenceSeconds != null) { if (section.fastLocationActiveCadenceSeconds != null) {
locationTracking.fastLocationActiveCadenceSeconds = locationTracking.fastLocationActiveCadenceSeconds =
section.fastLocationActiveCadenceSeconds!; section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
} }
await locationTracking.saveSettings(); await locationTracking.saveSettings();
await appProvider.reloadProfileScopedSettings(); await appProvider.reloadProfileScopedSettings();

View File

@@ -3,12 +3,136 @@ import '../utils/log_rx_route_decoder.dart';
enum LiveTrafficBusyness { quiet, active, busy } enum LiveTrafficBusyness { quiet, active, busy }
class LiveTrafficPacketTypeDetails {
final int payloadType;
final String title;
final String label;
final String summary;
final String description;
const LiveTrafficPacketTypeDetails({
required this.payloadType,
required this.title,
required this.label,
required this.summary,
required this.description,
});
}
class LiveTrafficEntry { class LiveTrafficEntry {
final BlePacketLog log; final BlePacketLog log;
final DecodedLogRxRoute? route; final DecodedLogRxRoute? route;
const LiveTrafficEntry({required this.log, required this.route}); const LiveTrafficEntry({required this.log, required this.route});
static const List<LiveTrafficPacketTypeDetails> _knownPayloadTypes = [
LiveTrafficPacketTypeDetails(
payloadType: 0x00,
title: 'FLOOD REQUEST',
label: 'Request',
summary: 'Encrypted request to a known peer',
description:
'Encrypted request to a known peer. The wire payload carries destination and source hashes plus a MAC, and the decrypted body starts with a timestamp followed by application-defined request data such as stats or keepalive requests.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x01,
title: 'FLOOD RESPONSE',
label: 'Response',
summary: 'Encrypted reply to a request',
description:
'Encrypted reply to a Request or Anonymous request. After decryption, the body is application-defined response data with no single generic response envelope.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x02,
title: 'FLOOD TEXT',
label: 'Text message',
summary: 'Encrypted direct text with timestamp and retry flags',
description:
'Encrypted direct text message to a known peer. The decrypted body contains a timestamp, a flags and attempt byte, and the message text.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x03,
title: 'FLOOD ACK',
label: 'Ack',
summary: '4-byte acknowledgement for an earlier message',
description:
'Short acknowledgement proving that a prior message was received. It carries a 4-byte checksum derived from the original message data.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x04,
title: 'FLOOD ADVERTISEMENT',
label: 'Advertisement',
summary: 'Signed node identity broadcast',
description:
'Signed node advertisement announcing a device identity plus app data such as a name or location. Receivers verify the signature before accepting it.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x05,
title: 'FLOOD GROUP_TEXT',
label: 'Group text',
summary: 'Encrypted channel text matched by channel hash',
description:
'Encrypted channel text message. It is matched by the first byte of SHA256(channel secret), then decrypted with the channel key. The plaintext is usually in the form "<sender name>: <message body>".',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x06,
title: 'FLOOD GROUP_DATA',
label: 'Group datagram',
summary: 'Encrypted channel data with type and length',
description:
'Encrypted channel datagram. After channel-hash matching and decryption, the body starts with a 16-bit data type and a 1-byte data length before the application payload.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x07,
title: 'FLOOD ANON_REQUEST',
label: 'Anonymous request',
summary: 'Request using an ephemeral sender key',
description:
'Encrypted request to a destination hash without using a stored sender identity. The packet includes the sender\'s ephemeral public key so the receiver can derive the shared secret.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x08,
title: 'FLOOD RETURNED_PATH',
label: 'Returned path',
summary:
'Return route back to the sender, with optional bundled ACK or response',
description:
'Path reply sent back to the original author to describe the route a received packet took. MeshCore stores that returned path as the peer\'s direct out-path and can bundle an ACK or response in the same payload.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x09,
title: 'FLOOD TRACE_PATH',
label: 'Trace path',
summary: 'Direct trace that records SNR at each hop',
description:
'Direct diagnostic packet that walks a supplied path and appends one SNR sample per hop. When it reaches the end of the path, the initiator can inspect hop-by-hop link quality.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x0A,
title: 'FLOOD MULTIPART',
label: 'Multipart packet',
summary: 'Wrapper for one packet in a multipart sequence',
description:
'Packet wrapper used when a logical message is split into a sequence. Current MeshCore code uses it for multipart ACKs, where the first nibble says how many parts remain.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x0B,
title: 'FLOOD CONTROL',
label: 'Control packet',
summary: 'Discovery or other control data',
description:
'Control or discovery payload, typically unencrypted. Current documented subtypes are discovery request and response packets used to find nearby nodes and report SNR.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x0F,
title: 'RAW CUSTOM',
label: 'Custom packet',
summary: 'Application-defined custom packet',
description:
'Application-defined raw packet bytes for custom encryption or custom payload formats. MeshCore leaves the inner format up to the higher-level application.',
),
];
bool get isMultiHop => (route?.hopCount ?? 0) > 1; bool get isMultiHop => (route?.hopCount ?? 0) > 1;
int? get hopCount => route?.hopCount; int? get hopCount => route?.hopCount;
@@ -37,66 +161,39 @@ class LiveTrafficEntry {
.join(' -> '); .join(' -> ');
} }
static String payloadTypeLabel(int payloadType) { static List<LiveTrafficPacketTypeDetails> get knownPayloadTypes =>
switch (payloadType) { _knownPayloadTypes;
case 0x00:
return 'Request'; static LiveTrafficPacketTypeDetails payloadTypeDetails(int payloadType) {
case 0x01: for (final details in _knownPayloadTypes) {
return 'Response'; if (details.payloadType == payloadType) {
case 0x02: return details;
return 'Text message'; }
case 0x03:
return 'Ack';
case 0x04:
return 'Advertisement';
case 0x05:
return 'Group text';
case 0x06:
return 'Group datagram';
case 0x07:
return 'Anonymous request';
case 0x08:
return 'Returned path';
case 0x09:
return 'Trace path';
case 0x0A:
return 'Multipart packet';
case 0x0B:
return 'Control packet';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
} }
return LiveTrafficPacketTypeDetails(
payloadType: payloadType,
title: '0x${payloadType.toRadixString(16).padLeft(2, '0').toUpperCase()}',
label: '0x${payloadType.toRadixString(16).padLeft(2, '0')}',
summary: 'Unknown or application-specific protocol payload',
description:
'Unknown or application-specific packet type. Check the current MeshCore firmware or app-specific protocol docs for the exact payload format.',
);
}
static String payloadTypeLabel(int payloadType) {
return payloadTypeDetails(payloadType).label;
}
static String payloadTypeTitle(int payloadType) {
return payloadTypeDetails(payloadType).title;
} }
static String payloadTypeMeaning(int payloadType) { static String payloadTypeMeaning(int payloadType) {
switch (payloadType) { return payloadTypeDetails(payloadType).summary;
case 0x00: }
return 'Request (destination/source hashes + MAC)';
case 0x01: static String payloadTypeDescription(int payloadType) {
return 'Response to Request or Anonymous request'; return payloadTypeDetails(payloadType).description;
case 0x02:
return 'Plain text message';
case 0x03:
return 'Simple acknowledgement';
case 0x04:
return 'Node advertisement';
case 0x05:
return 'Unverified group text message';
case 0x06:
return 'Unverified group datagram';
case 0x07:
return 'Generic anonymous request';
case 0x08:
return 'Returned path payload';
case 0x09:
return 'Trace path collecting hop SNR';
case 0x0A:
return 'One packet from a multipart set';
case 0x0B:
return 'Control or discovery packet';
default:
return 'protocol payload';
}
} }
} }

View File

@@ -20,6 +20,14 @@ import 'profiles_feature_service.dart';
/// - MeshCore mesh network integration /// - MeshCore mesh network integration
/// - Real-time position updates via callbacks /// - Real-time position updates via callbacks
class LocationTrackingService { class LocationTrackingService {
static const double _defaultFastLocationMovementThresholdMeters = 10.0;
static const double _minFastLocationMovementThresholdMeters = 10.0;
static const int _defaultFastLocationActiveCadenceSeconds = 10;
static const int _minFastLocationActiveCadenceSeconds = 10;
static const int _maxFastLocationActiveCadenceSeconds = 31;
static const Duration _fastLocationMinimumUpdateInterval = Duration(
seconds: 31,
);
// ============================================================================ // ============================================================================
// Singleton Pattern // Singleton Pattern
// ============================================================================ // ============================================================================
@@ -75,10 +83,12 @@ class LocationTrackingService {
bool fastLocationUpdatesEnabled = false; bool fastLocationUpdatesEnabled = false;
/// Distance threshold for fast GPS updates /// Distance threshold for fast GPS updates
double fastLocationMovementThresholdMeters = 10.0; double fastLocationMovementThresholdMeters =
_defaultFastLocationMovementThresholdMeters;
/// Cadence for active-use fast GPS updates /// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10; int fastLocationActiveCadenceSeconds =
_defaultFastLocationActiveCadenceSeconds;
/// Target channel index for fast GPS updates; null means disabled/unset. /// Target channel index for fast GPS updates; null means disabled/unset.
int? fastLocationChannelIdx; int? fastLocationChannelIdx;
@@ -522,12 +532,18 @@ class LocationTrackingService {
} }
Future<void> updateFastLocationMovementThreshold(double meters) async { Future<void> updateFastLocationMovementThreshold(double meters) async {
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0); fastLocationMovementThresholdMeters = meters.clamp(
_minFastLocationMovementThresholdMeters,
1000.0,
);
await saveSettings(); await saveSettings();
} }
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async { Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60); fastLocationActiveCadenceSeconds = seconds.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
await saveSettings(); await saveSettings();
_refreshFastLocationTimer(); _refreshFastLocationTimer();
} }
@@ -583,6 +599,11 @@ class LocationTrackingService {
final now = DateTime.now(); final now = DateTime.now();
final previous = _lastFastLocationSentPosition; final previous = _lastFastLocationSentPosition;
final previousTime = _lastFastLocationSentAt; final previousTime = _lastFastLocationSentAt;
if (previousTime != null &&
now.difference(previousTime) < _fastLocationMinimumUpdateInterval) {
return;
}
if (previous != null && previousTime != null) { if (previous != null && previousTime != null) {
final distance = Geolocator.distanceBetween( final distance = Geolocator.distanceBetween(
previous.latitude, previous.latitude,
@@ -673,12 +694,14 @@ class LocationTrackingService {
prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false; prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
fastLocationMovementThresholdMeters = fastLocationMovementThresholdMeters =
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ?? (prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
gpsUpdateDistance) _defaultFastLocationMovementThresholdMeters)
.clamp(1.0, 1000.0); .clamp(_minFastLocationMovementThresholdMeters, 1000.0);
fastLocationActiveCadenceSeconds = fastLocationActiveCadenceSeconds =
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp( (prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ??
5, _defaultFastLocationActiveCadenceSeconds)
60, .clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
); );
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx)); fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));

View File

@@ -5,6 +5,12 @@ import 'package:shared_preferences/shared_preferences.dart';
class MessageDestinationPreferences { class MessageDestinationPreferences {
static const String _destinationTypeKey = 'message_destination_type'; static const String _destinationTypeKey = 'message_destination_type';
static const String _recipientPublicKeyKey = 'message_recipient_public_key'; static const String _recipientPublicKeyKey = 'message_recipient_public_key';
static const String _lockedDestinationEnabledKey =
'message_locked_destination_enabled';
static const String _lockedDestinationTypeKey =
'message_locked_destination_type';
static const String _lockedRecipientPublicKeyKey =
'message_locked_recipient_public_key';
/// Destination types /// Destination types
static const String destinationTypeAll = 'all'; static const String destinationTypeAll = 'all';
@@ -12,6 +18,10 @@ class MessageDestinationPreferences {
static const String destinationTypeContact = 'contact'; static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room'; static const String destinationTypeRoom = 'room';
static bool isLockableDestinationType(String type) {
return type == destinationTypeChannel || type == destinationTypeRoom;
}
/// Get the saved destination configuration /// Get the saved destination configuration
/// Returns a map with 'type' and optional 'publicKey' /// Returns a map with 'type' and optional 'publicKey'
/// Returns null if no preference is saved (defaults to public channel) /// Returns null if no preference is saved (defaults to public channel)
@@ -53,6 +63,53 @@ class MessageDestinationPreferences {
await prefs.remove(_recipientPublicKeyKey); await prefs.remove(_recipientPublicKeyKey);
} }
/// Get the saved locked destination configuration.
/// Returns null when the lock is disabled.
static Future<Map<String, String>?> getLockedDestination() async {
final prefs = await SharedPreferences.getInstance();
final isEnabled = prefs.getBool(_lockedDestinationEnabledKey) ?? false;
if (!isEnabled) {
return null;
}
final savedType =
prefs.getString(_lockedDestinationTypeKey) ?? destinationTypeChannel;
final type = isLockableDestinationType(savedType)
? savedType
: destinationTypeChannel;
final publicKey = prefs.getString(_lockedRecipientPublicKeyKey);
return {'type': type, 'publicKey': ?publicKey};
}
static Future<void> setLockedDestination({
required bool enabled,
String type = destinationTypeChannel,
String? recipientPublicKey,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_lockedDestinationEnabledKey, enabled);
if (!enabled) {
await prefs.remove(_lockedDestinationTypeKey);
await prefs.remove(_lockedRecipientPublicKeyKey);
return;
}
final sanitizedType = isLockableDestinationType(type)
? type
: destinationTypeChannel;
await prefs.setString(_lockedDestinationTypeKey, sanitizedType);
if (recipientPublicKey != null) {
await prefs.setString(_lockedRecipientPublicKeyKey, recipientPublicKey);
} else {
await prefs.remove(_lockedRecipientPublicKeyKey);
}
}
/// Get display name for destination type /// Get display name for destination type
static String getDestinationTypeName(String type) { static String getDestinationTypeName(String type) {
switch (type) { switch (type) {

View File

@@ -58,10 +58,7 @@ class MessageStorageService {
: jsonList; : jsonList;
final jsonString = jsonEncode(limitedList); final jsonString = jsonEncode(limitedList);
await prefs.setString( final messagesKey = _key(_messagesKey, namespace: namespace);
_key(_messagesKey, namespace: namespace),
jsonString,
);
final retainedMessageIds = limitedList final retainedMessageIds = limitedList
.map((entry) => entry['id'] as String) .map((entry) => entry['id'] as String)
.toSet(); .toSet();
@@ -89,22 +86,41 @@ class MessageStorageService {
routeMetadataJson[entry.key] = entry.value.toJson(); routeMetadataJson[entry.key] = entry.value.toJson();
} }
} }
await prefs.setString( final contactLocationsKey = _key(
_key(_messageContactLocationsKey, namespace: namespace), _messageContactLocationsKey,
jsonEncode(locationJson), namespace: namespace,
); );
await prefs.setString( final receptionDetailsKey = _key(
_key(_messageReceptionDetailsKey, namespace: namespace), _messageReceptionDetailsKey,
jsonEncode(receptionJson), namespace: namespace,
); );
await prefs.setString( final transferDetailsKey = _key(
_key(_messageTransferDetailsKey, namespace: namespace), _messageTransferDetailsKey,
jsonEncode(transferJson), namespace: namespace,
); );
await prefs.setString( final routeMetadataKey = _key(
_key(_messageRouteMetadataKey, namespace: namespace), _messageRouteMetadataKey,
jsonEncode(routeMetadataJson), namespace: namespace,
); );
final locationJsonString = jsonEncode(locationJson);
final receptionJsonString = jsonEncode(receptionJson);
final transferJsonString = jsonEncode(transferJson);
final routeMetadataJsonString = jsonEncode(routeMetadataJson);
final hasChanges =
prefs.getString(messagesKey) != jsonString ||
prefs.getString(contactLocationsKey) != locationJsonString ||
prefs.getString(receptionDetailsKey) != receptionJsonString ||
prefs.getString(transferDetailsKey) != transferJsonString ||
prefs.getString(routeMetadataKey) != routeMetadataJsonString;
if (!hasChanges) {
return;
}
await prefs.setString(messagesKey, jsonString);
await prefs.setString(contactLocationsKey, locationJsonString);
await prefs.setString(receptionDetailsKey, receptionJsonString);
await prefs.setString(transferDetailsKey, transferJsonString);
await prefs.setString(routeMetadataKey, routeMetadataJsonString);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage', '✅ [MessageStorage] Saved ${limitedList.length} messages to storage',

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -9,7 +10,7 @@ import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1'; static const String _storageKey = 'contact_path_history_v2';
static const int _maxDirectPaths = 20; static const int _maxDirectPaths = 20;
static const int _topRotationCount = 3; static const int _topRotationCount = 3;
@@ -59,6 +60,11 @@ class PathHistoryService {
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(), lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
); );
await _saveHistory( await _saveHistory(
@@ -104,6 +110,11 @@ class PathHistoryService {
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(), lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
); );
await _saveHistory( await _saveHistory(
@@ -172,6 +183,10 @@ class PathHistoryService {
PathSelection selection, { PathSelection selection, {
required bool success, required bool success,
int? roundTripTimeMs, int? roundTripTimeMs,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async { }) async {
await initialize(); await initialize();
final history = _historyFor(contactPublicKeyHex); final history = _historyFor(contactPublicKeyHex);
@@ -206,6 +221,13 @@ class PathHistoryService {
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0) ? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0), : (existing?.lastRoundTripTimeMs ?? 0),
lastUsedAt: DateTime.now(), lastUsedAt: DateTime.now(),
lastSucceededAt: success ? DateTime.now() : existing?.lastSucceededAt,
senderLatitude: success ? senderLatitude : existing?.senderLatitude,
senderLongitude: success ? senderLongitude : existing?.senderLongitude,
recipientLatitude:
success ? recipientLatitude : existing?.recipientLatitude,
recipientLongitude:
success ? recipientLongitude : existing?.recipientLongitude,
); );
await _saveHistory( await _saveHistory(
contactPublicKeyHex, contactPublicKeyHex,
@@ -215,11 +237,70 @@ class PathHistoryService {
); );
} }
Future<PathSelection?> getLastSuccessfulDirectSelection(
Contact contact, {
String? excludeSignature,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize();
final history = _historyFor(contact.publicKeyHex);
final ranked = history.directPaths
.where(
(record) =>
record.successCount > 0 &&
record.lastSucceededAt != null &&
record.signature != excludeSignature,
)
.toList()
..sort((a, b) {
final locationCompare = _compareLocationFit(
a,
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
if (locationCompare != 0) return locationCompare;
final succeededCompare = b.lastSucceededAt!.compareTo(
a.lastSucceededAt!,
);
if (succeededCompare != 0) return succeededCompare;
return _comparePathRecords(a, b);
});
if (ranked.isEmpty) {
return null;
}
final record = ranked.first;
return PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
);
}
ContactPathHistory historyFor(String contactPublicKeyHex) { ContactPathHistory historyFor(String contactPublicKeyHex) {
return _cache[contactPublicKeyHex] ?? return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex); ContactPathHistory.empty(contactPublicKeyHex);
} }
Future<void> clearHistoryFor(String contactPublicKeyHex) async {
await initialize();
_cache.remove(contactPublicKeyHex);
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));
}
ContactPathHistory _historyFor(String contactPublicKeyHex) { ContactPathHistory _historyFor(String contactPublicKeyHex) {
return _cache.putIfAbsent( return _cache.putIfAbsent(
contactPublicKeyHex, contactPublicKeyHex,
@@ -279,4 +360,68 @@ class PathHistoryService {
String _signature(Uint8List bytes) => String _signature(Uint8List bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
int _compareLocationFit(
PathRecord a,
PathRecord b, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
final aDistance = _locationDistanceScore(
a,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
final bDistance = _locationDistanceScore(
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
return aDistance.compareTo(bDistance);
}
double _locationDistanceScore(
PathRecord record, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
var total = 0.0;
var matched = false;
if (senderLatitude != null &&
senderLongitude != null &&
record.senderLatitude != null &&
record.senderLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
senderLatitude,
senderLongitude,
record.senderLatitude!,
record.senderLongitude!,
);
}
if (recipientLatitude != null &&
recipientLongitude != null &&
record.recipientLatitude != null &&
record.recipientLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
recipientLatitude,
recipientLongitude,
record.recipientLatitude!,
record.recipientLongitude!,
);
}
return matched ? total : double.infinity;
}
} }

View File

@@ -1,72 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../providers/connection_provider.dart';
/// Discovers available regions from repeater contacts via anonymous requests.
///
/// The firmware repeater responds to ANON_REQ_TYPE_REGIONS (0x01) with a
/// comma-separated list of region names that have flood allowed.
class RegionDiscoveryService {
static const int _anonReqTypeRegions = 0x01;
/// Discover regions from a single repeater.
///
/// Sends an anonymous request to the repeater and waits for the response.
/// Returns a list of region names (with `#` prefix).
/// Returns empty list on timeout or error.
static Future<List<String>> discoverFromRepeater({
required Uint8List repeaterPublicKey,
required ConnectionProvider connectionProvider,
Duration timeout = const Duration(seconds: 10),
}) async {
final result = await connectionProvider.sendAnonRequest(
contactPublicKey: repeaterPublicKey,
requestData: Uint8List.fromList([_anonReqTypeRegions]),
);
if (result == null) return [];
final tag = result.tag;
final completer = Completer<List<String>>();
void onResponse(Uint8List publicKeyPrefix, int responseTag, Uint8List data) {
if (responseTag != tag || completer.isCompleted) return;
completer.complete(_parseRegionResponse(data));
}
connectionProvider.onBinaryResponse = onResponse;
try {
return await completer.future.timeout(
timeout,
onTimeout: () => <String>[],
);
} catch (e) {
debugPrint('⚠️ [RegionDiscovery] Error discovering regions: $e');
return [];
} finally {
// Restore previous handler — callers should re-set if needed
if (connectionProvider.onBinaryResponse == onResponse) {
connectionProvider.onBinaryResponse = null;
}
}
}
/// Parse the region response payload.
///
/// Format: [4B sender_timestamp][4B repeater_clock][comma-separated names]
/// Names are returned without `#` prefix from firmware; we add it back.
static List<String> _parseRegionResponse(Uint8List data) {
if (data.length <= 8) return [];
final namesStr = utf8.decode(data.sublist(8), allowMalformed: true).trim();
if (namesStr.isEmpty || namesStr == '-none-') return [];
return namesStr
.split(',')
.map((name) => name.trim())
.where((name) => name.isNotEmpty && name != '*' && !name.startsWith('\$'))
.map((name) => name.startsWith('#') ? name : '#$name')
.toList();
}
}

View File

@@ -20,6 +20,30 @@ Future<void> _initializeConnectedWorkspace({
await appProvider.initialize(); await appProvider.initialize();
} }
String _normalizeConnectionError(Object error) {
var message = error.toString();
if (message.startsWith('Exception: ')) {
message = message.substring('Exception: '.length);
}
if (message.startsWith('Connection failed: Exception: ')) {
return message.substring('Connection failed: Exception: '.length);
}
if (message.startsWith('Connection failed: ')) {
return message.substring('Connection failed: '.length);
}
return message;
}
void _showConnectionErrorSnackBar(BuildContext context, Object error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_normalizeConnectionError(error)),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
Future<bool> showConnectionDialogFlow( Future<bool> showConnectionDialogFlow(
BuildContext context, { BuildContext context, {
Color? backgroundColor, Color? backgroundColor,
@@ -36,6 +60,21 @@ Future<bool> showConnectionDialogFlow(
return result == _ConnectionDialogResult.connected; return result == _ConnectionDialogResult.connected;
} }
try {
await _initializeConnectedWorkspace(
profileWorkspaceCoordinator: context.read<ProfileWorkspaceCoordinator>(),
appProvider: context.read<AppProvider>(),
);
} catch (error) {
if (context.mounted) {
_showConnectionErrorSnackBar(context, error);
}
}
if (!context.mounted) {
return true;
}
if (!offerPostConnectRepeaterDiscovery) { if (!offerPostConnectRepeaterDiscovery) {
return true; return true;
} }
@@ -182,43 +221,14 @@ class _ConnectionDialogState extends State<ConnectionDialog>
return Colors.red; return Colors.red;
} }
Future<void> _handleSuccessfulConnection() async { void _closeOnSuccessfulConnection() {
final appProvider = context.read<AppProvider>();
final profileWorkspaceCoordinator = context
.read<ProfileWorkspaceCoordinator>();
await _initializeConnectedWorkspace(
profileWorkspaceCoordinator: profileWorkspaceCoordinator,
appProvider: appProvider,
);
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(_ConnectionDialogResult.connected); Navigator.of(context).pop(_ConnectionDialogResult.connected);
} }
String _normalizeConnectionError(Object error) {
var message = error.toString();
if (message.startsWith('Exception: ')) {
message = message.substring('Exception: '.length);
}
if (message.startsWith('Connection failed: Exception: ')) {
return message.substring('Connection failed: Exception: '.length);
}
if (message.startsWith('Connection failed: ')) {
return message.substring('Connection failed: '.length);
}
return message;
}
void _showConnectionError(Object error) { void _showConnectionError(Object error) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( _showConnectionErrorSnackBar(context, error);
SnackBar(
content: Text(_normalizeConnectionError(error)),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
} }
@override @override
@@ -554,7 +564,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
'Failed to connect to $name', 'Failed to connect to $name',
); );
} }
await _handleSuccessfulConnection(); _closeOnSuccessfulConnection();
} catch (error) { } catch (error) {
_showConnectionError(error); _showConnectionError(error);
} finally { } finally {
@@ -676,7 +686,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
'Failed to connect to ${server.ipAddress}:${server.port}', 'Failed to connect to ${server.ipAddress}:${server.port}',
); );
} }
await _handleSuccessfulConnection(); _closeOnSuccessfulConnection();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -883,11 +893,6 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
await _initializeConnectedWorkspace(
profileWorkspaceCoordinator: context
.read<ProfileWorkspaceCoordinator>(),
appProvider: context.read<AppProvider>(),
);
widget.onConnected(_ConnectionDialogResult.connected); widget.onConnected(_ConnectionDialogResult.connected);
} else { } else {
await connection.disconnect(); await connection.disconnect();

View File

@@ -10,7 +10,6 @@ import '../../models/path_history.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../services/contact_route_resolver.dart';
import '../../services/path_history_service.dart'; import '../../services/path_history_service.dart';
import '../../services/relay_candidate_sorter.dart'; import '../../services/relay_candidate_sorter.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
@@ -80,7 +79,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
ParsedContactRoute? _parsedRoute; ParsedContactRoute? _parsedRoute;
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
bool _showManualEditor = false;
List<Contact> _selectedMapHops = const []; List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory; ContactPathHistory? _pathHistory;
@@ -92,7 +90,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
_relaySearchController = TextEditingController(); _relaySearchController = TextEditingController();
_controller.addListener(_reparse); _controller.addListener(_reparse);
_showManualEditor = widget.contact.routeCanonicalText.isNotEmpty;
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory(); _loadPathHistory();
_reparse(); _reparse();
@@ -240,19 +237,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}); });
} }
void _applyResolvedPlan(ResolvedContactRoutePlan plan) {
setState(() {
_selectedMapHops = plan.selectedContacts;
_controller.text = plan.canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
_showManualEditor = false;
});
_reparse();
}
void _applyHistoryRecord(PathRecord record) { void _applyHistoryRecord(PathRecord record) {
final canonicalText = _canonicalRouteFromBytes( final canonicalText = _canonicalRouteFromBytes(
record.pathBytes, record.pathBytes,
@@ -264,7 +248,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
TextPosition(offset: _controller.text.length), TextPosition(offset: _controller.text.length),
); );
_errorText = null; _errorText = null;
_showManualEditor = true;
}); });
_reparse(); _reparse();
} }
@@ -319,39 +302,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
void _resolvePathAutomatically() {
final connectionProvider = context.read<ConnectionProvider>();
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
final recipientLocation = widget.contact.displayLocation;
if (advLat == null ||
advLon == null ||
(advLat == 0 && advLon == 0) ||
recipientLocation == null) {
setState(() {
_errorText =
'Automatic resolve needs both your advertised location and the contact location.';
});
return;
}
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: LatLng(advLat / 1e6, advLon / 1e6),
recipient: widget.contact,
availableContacts: widget.availableContacts,
hashSize: _selectedHashSize,
);
if (plan == null) {
setState(() {
_errorText =
'Could not resolve a route from available repeater locations.';
});
return;
}
_applyResolvedPlan(plan);
}
String _canonicalRouteFromBytes( String _canonicalRouteFromBytes(
List<int> pathBytes, { List<int> pathBytes, {
required int hashSize, required int hashSize,
@@ -434,32 +384,32 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
Widget _buildPreviewSection() { Widget _buildPreviewSection() {
final previewRoute = _effectiveRoute; final previewRoute = _effectiveRoute;
if (previewRoute == null) {
return const SizedBox.shrink();
}
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outlineVariant), border: Border.all(color: colorScheme.outlineVariant),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
previewRoute == null ? 'Route preview' : previewRoute.summary, previewRoute.summary,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 2),
Text( Text(
previewRoute == null '${previewRoute.byteLength}B • 0x${previewRoute.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
? 'Pick relays from the list below or open manual edit if you need exact hop tokens.'
: '${previewRoute.byteLength} bytes • descriptor 0x${previewRoute.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
if (previewRoute != null && if (previewRoute.canonicalText.isNotEmpty) ...[
previewRoute.canonicalText.isNotEmpty) ...[ const SizedBox(height: 8),
const SizedBox(height: 10),
SelectableText( SelectableText(
previewRoute.canonicalText, previewRoute.canonicalText,
style: Theme.of( style: Theme.of(
@@ -474,18 +424,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
Widget _buildSelectedHopSection() { Widget _buildSelectedHopSection() {
if (_selectedMapHops.isEmpty) { if (_selectedMapHops.isEmpty) {
return Container( return const SizedBox.shrink();
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Text(
'No relays selected. Save now to use a direct path, or add repeaters below.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
} }
return Column( return Column(
@@ -493,28 +432,21 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
children: [ children: [
Text('Selected relays', style: Theme.of(context).textTheme.titleSmall), Text('Selected relays', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8), const SizedBox(height: 8),
..._selectedMapHops.asMap().entries.map((entry) { Wrap(
final index = entry.key; spacing: 8,
final contact = entry.value; runSpacing: 8,
return Card( children: _selectedMapHops.asMap().entries.map((entry) {
margin: const EdgeInsets.only(bottom: 8), final index = entry.key;
child: ListTile( final contact = entry.value;
leading: CircleAvatar(child: Text('${index + 1}')), return InputChip(
title: Text(contact.displayName), label: Text('${index + 1}. ${contact.displayName}'),
subtitle: Text( deleteIcon: const Icon(Icons.close),
_tokenFor(contact, _selectedHashSize), onDeleted: () => _toggleHop(contact),
style: Theme.of( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
context, visualDensity: VisualDensity.compact,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), );
), }).toList(),
trailing: IconButton( ),
tooltip: 'Remove relay',
onPressed: () => _toggleHop(contact),
icon: const Icon(Icons.close),
),
),
);
}),
], ],
); );
} }
@@ -563,6 +495,12 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
leading: Icon( leading: Icon(
isSelected isSelected
? Icons.check_circle ? Icons.check_circle
@@ -593,41 +531,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
Widget _buildManualEditor() {
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
initiallyExpanded: _showManualEditor,
onExpansionChanged: (expanded) {
setState(() {
_showManualEditor = expanded;
});
},
title: Text(AppLocalizations.of(context)!.manualRouteEdit),
subtitle: const Text(
'Use this when you need to paste or tweak hop tokens directly.',
),
children: [
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Comma-separated hops using the selected path size. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
],
);
}
Widget _buildMapPreview({ Widget _buildMapPreview({
required List<Contact> routeCandidates, required List<Contact> routeCandidates,
required List<LatLng> mapPoints, required List<LatLng> mapPoints,
@@ -709,15 +612,14 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
Widget _buildBuilderTab({ Widget _buildBuilderTab({
required List<Contact> routeCandidates, required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
}) { }) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text('Path Size', style: Theme.of(context).textTheme.labelLarge), Text('Path size', style: Theme.of(context).textTheme.labelLarge),
const Spacer(), const Spacer(),
SegmentedButton<int>( SegmentedButton<int>(
segments: [ segments: [
@@ -734,36 +636,32 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
), ),
], ],
), ),
if (_errorText != null) ...[
const SizedBox(height: 12),
Text(
_errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (_selectedMapHops.isNotEmpty) ...[
const SizedBox(height: 12),
_buildSelectedHopSection(),
],
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _resolvePathAutomatically,
icon: Icon(Icons.auto_fix_high),
label: Text(AppLocalizations.of(context)!.autoResolve),
),
OutlinedButton.icon(
onPressed: _selectedMapHops.isEmpty
? null
: () {
setState(() {
_selectedMapHops = const [];
_syncControllerFromSelectedHops();
});
},
icon: Icon(Icons.clear_all),
label: Text(AppLocalizations.of(context)!.clearRelays),
),
],
),
const SizedBox(height: 16),
_buildPreviewSection(),
const SizedBox(height: 16),
_buildSelectedHopSection(),
const SizedBox(height: 16),
_buildRelayPicker(routeCandidates), _buildRelayPicker(routeCandidates),
],
);
}
Widget _buildInfoTab({
required AppProvider appProvider,
required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
}) {
return ListView(
children: [
_buildPreviewSection(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildMapPreview( _buildMapPreview(
routeCandidates: routeCandidates, routeCandidates: routeCandidates,
@@ -771,7 +669,18 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
routePoints: routePoints, routePoints: routePoints,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildManualEditor(), _AutomationRoutingInfo(
isExpanded: _showRoutingInfo,
onToggle: () {
setState(() {
_showRoutingInfo = !_showRoutingInfo;
});
},
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
),
const SizedBox(height: 24),
], ],
); );
} }
@@ -808,6 +717,24 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () async {
await _pathHistoryService.clearHistoryFor(
widget.contact.publicKeyHex,
);
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
},
child: const Text('Clear history'),
),
),
const SizedBox(height: 8),
if (observedRecord != null) ...[ if (observedRecord != null) ...[
_buildHistoryRecordTile(observedRecord, title: AppLocalizations.of(context)!.observedMeshRoute), _buildHistoryRecordTile(observedRecord, title: AppLocalizations.of(context)!.observedMeshRoute),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -877,7 +804,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
]; ];
return DefaultTabController( return DefaultTabController(
length: 2, length: 3,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Set Path for ${widget.contact.displayName}'), title: Text('Set Path for ${widget.contact.displayName}'),
@@ -885,56 +812,34 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
tabs: [ tabs: [
Tab(text: 'Build'), Tab(text: 'Build'),
Tab(text: 'History'), Tab(text: 'History'),
Tab(text: 'Info'),
], ],
), ),
), ),
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column( child: TabBarView(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( ListView(
'Plan the route on its own screen, then save it once the preview looks right.', children: [
style: Theme.of(context).textTheme.bodyMedium, _buildBuilderTab(
routeCandidates: routeCandidates,
),
const SizedBox(height: 24),
],
), ),
const SizedBox(height: 16), ListView(
Expanded( children: [
child: TabBarView( _buildHistoryTab(),
children: [ const SizedBox(height: 24),
ListView( ],
children: [ ),
_buildBuilderTab( _buildInfoTab(
routeCandidates: routeCandidates, appProvider: appProvider,
mapPoints: mapPoints, routeCandidates: routeCandidates,
routePoints: routePoints, mapPoints: mapPoints,
), routePoints: routePoints,
const SizedBox(height: 16),
_AutomationRoutingInfo(
isExpanded: _showRoutingInfo,
onToggle: () {
setState(() {
_showRoutingInfo = !_showRoutingInfo;
});
},
autoRouteRotationEnabled:
appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled:
appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry:
appProvider.clearPathOnMaxRetry,
),
const SizedBox(height: 24),
],
),
ListView(
children: [
_buildHistoryTab(),
const SizedBox(height: 24),
],
),
],
),
), ),
], ],
), ),

View File

@@ -11,6 +11,7 @@ import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import '../../services/location_tracking_service.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart'; import 'contact_trace_sheet.dart';
@@ -157,6 +158,7 @@ class ContactTile extends StatelessWidget {
icon: Icons.folder_copy_outlined, icon: Icons.folder_copy_outlined,
label: label, label: label,
), ),
..._buildSignalPills(context),
], ],
), ),
if (location != null) ...[ if (location != null) ...[
@@ -324,6 +326,34 @@ class ContactTile extends StatelessWidget {
); );
} }
List<Widget> _buildSignalPills(BuildContext context) {
if (!contact.isRepeater && !contact.isSensor) return [];
final contactsProvider = context.read<ContactsProvider>();
final advert = contactsProvider.pendingAdvertByKey(contact.publicKey);
if (advert == null) return [];
final pills = <Widget>[];
if (advert.rxRssiDbm != null) {
pills.add(
_buildMetaPill(
context,
icon: Icons.arrow_downward_rounded,
label: '${advert.rxRssiDbm} dBm',
),
);
}
if (advert.repeaterLastRssi != null) {
pills.add(
_buildMetaPill(
context,
icon: Icons.arrow_upward_rounded,
label: '${advert.repeaterLastRssi} dBm',
),
);
}
return pills;
}
Widget _buildCompactSubtitle(BuildContext context, String? distanceText) { Widget _buildCompactSubtitle(BuildContext context, String? distanceText) {
final location = contact.displayLocation; final location = contact.displayLocation;
final compactPills = <Widget>[ final compactPills = <Widget>[
@@ -338,6 +368,7 @@ class ContactTile extends StatelessWidget {
icon: Icons.location_disabled_outlined, icon: Icons.location_disabled_outlined,
label: AppLocalizations.of(context)!.noGpsData, label: AppLocalizations.of(context)!.noGpsData,
), ),
..._buildSignalPills(context),
]; ];
return Padding( return Padding(
@@ -499,6 +530,16 @@ class ContactTile extends StatelessWidget {
_showNeighbours(context, contact); _showNeighbours(context, contact);
}, },
), ),
if (contact.type == ContactType.repeater ||
contact.type == ContactType.room)
_ContactSheetAction(
icon: Icons.network_ping,
label: 'Ping',
onTap: () async {
Navigator.pop(context);
_pingRelay(context, contact);
},
),
if (!contact.isPublicChannel) if (!contact.isPublicChannel)
_ContactSheetAction( _ContactSheetAction(
icon: Icons.edit_outlined, icon: Icons.edit_outlined,
@@ -634,6 +675,18 @@ class ContactTile extends StatelessWidget {
); );
} }
void _pingRelay(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => _PingRelaySheet(contact: contact),
);
}
void _showDeleteConfirmation( void _showDeleteConfirmation(
BuildContext context, BuildContext context,
Contact contact, { Contact contact, {
@@ -1809,16 +1862,24 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
final parts = trimmed.split(':'); final parts = trimmed.split(':');
if (parts.length >= 3) { if (parts.length >= 3) {
final keyHex = parts[0]; final keyHex = parts[0];
final timestampOrMs = int.tryParse(parts[1]); final timestampOrSeconds = int.tryParse(parts[1]);
final snrRaw = int.tryParse(parts[2]); final snrRaw = int.tryParse(parts[2]);
final isMs = timestampOrMs != null && timestampOrMs < 1e9; final isDurationSeconds =
timestampOrSeconds != null && timestampOrSeconds < 1000000000;
parsed.add( parsed.add(
_Neighbour( _Neighbour(
publicKeyHex: keyHex, publicKeyHex: keyHex,
lastSeenAt: !isMs && timestampOrMs != null lastSeenAt: !isDurationSeconds && timestampOrSeconds != null
? DateTime.fromMillisecondsSinceEpoch(timestampOrMs * 1000) ? timestampOrSeconds >= 1000000000000
? DateTime.fromMillisecondsSinceEpoch(
timestampOrSeconds,
)
: DateTime.fromMillisecondsSinceEpoch(
timestampOrSeconds * 1000,
)
: null, : null,
lastSeenMs: isMs ? timestampOrMs : null, lastSeenSeconds:
isDurationSeconds ? timestampOrSeconds : null,
snrDb: snrRaw != null ? snrRaw / 4.0 : null, snrDb: snrRaw != null ? snrRaw / 4.0 : null,
), ),
); );
@@ -1866,12 +1927,12 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
.difference(neighbour.lastSeenAt!) .difference(neighbour.lastSeenAt!)
.toLocalizedTimeAgoWithSeconds(context); .toLocalizedTimeAgoWithSeconds(context);
} }
if (neighbour.lastSeenMs != null) { if (neighbour.lastSeenSeconds != null) {
if (neighbour.lastSeenMs! < 1000) { if (neighbour.lastSeenSeconds! < 1) {
return AppLocalizations.of(context)!.justNow; return AppLocalizations.of(context)!.justNow;
} }
return Duration( return Duration(
milliseconds: neighbour.lastSeenMs!, seconds: neighbour.lastSeenSeconds!,
).toLocalizedTimeAgoWithSeconds(context); ).toLocalizedTimeAgoWithSeconds(context);
} }
return AppLocalizations.of(context)!.justNow; return AppLocalizations.of(context)!.justNow;
@@ -2265,13 +2326,13 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
class _Neighbour { class _Neighbour {
final String publicKeyHex; final String publicKeyHex;
final DateTime? lastSeenAt; final DateTime? lastSeenAt;
final int? lastSeenMs; final int? lastSeenSeconds;
final double? snrDb; final double? snrDb;
const _Neighbour({ const _Neighbour({
required this.publicKeyHex, required this.publicKeyHex,
this.lastSeenAt, this.lastSeenAt,
this.lastSeenMs, this.lastSeenSeconds,
this.snrDb, this.snrDb,
}); });
} }
@@ -2287,3 +2348,337 @@ class _MappedNeighbour {
required this.location, required this.location,
}); });
} }
/// Bottom sheet for pinging a relay/repeater with history and distance.
class _PingRelaySheet extends StatefulWidget {
final Contact contact;
const _PingRelaySheet({required this.contact});
@override
State<_PingRelaySheet> createState() => _PingRelaySheetState();
}
class _PingEntry {
final RelayPingResult result;
final DateTime timestamp;
final String? distance;
const _PingEntry({
required this.result,
required this.timestamp,
this.distance,
});
}
class _PingRelaySheetState extends State<_PingRelaySheet> {
bool _pinging = false;
final List<_PingEntry> _history = [];
@override
void initState() {
super.initState();
_doPing();
}
Future<void> _doPing() async {
setState(() => _pinging = true);
final connectionProvider = context.read<ConnectionProvider>();
final distance = _distanceText();
final result = await connectionProvider.pingRelay(widget.contact);
if (!mounted) return;
setState(() {
_pinging = false;
_history.insert(
0,
_PingEntry(
result: result,
timestamp: DateTime.now(),
distance: distance,
),
);
});
}
String? _distanceText() {
final location = widget.contact.displayLocation;
if (location == null) return null;
final currentPosition = LocationTrackingService().currentPosition;
if (currentPosition == null) return null;
final meters = Geolocator.distanceBetween(
currentPosition.latitude,
currentPosition.longitude,
location.latitude,
location.longitude,
);
if (meters < 1000) return '${meters.round()} m';
if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km';
return '${(meters / 1000).toStringAsFixed(1)} km';
}
Widget _buildPill(
BuildContext context, {
required IconData icon,
required String label,
Color? iconColor,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: iconColor ?? colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _buildSnrPill(BuildContext context, String direction, double snrDb) {
final quality = linkQualityLabel(null, snrDb);
final color = linkQualityColor(quality);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
direction == 'there'
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 12,
color: color,
),
const SizedBox(width: 4),
Text(
'${snrDb.toStringAsFixed(1)} dB',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _buildResultRow(BuildContext context, _PingEntry entry, int seq) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final r = entry.result;
final age = DateTime.now().difference(entry.timestamp);
final timeAgo = age.toLocalizedTimeAgoWithSeconds(context);
if (!r.success) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor: colorScheme.error.withValues(alpha: 0.15),
child: Text(
'$seq',
style: TextStyle(
color: colorScheme.error,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
Text(
'Timeout',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
timeAgo,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
child: Text(
'$seq',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
_buildPill(
context,
icon: Icons.timer_outlined,
label: '${r.durationMs} ms',
),
const SizedBox(width: 6),
_buildSnrPill(context, 'there', r.snrThere),
const SizedBox(width: 6),
_buildSnrPill(context, 'back', r.snrBack),
],
),
Padding(
padding: const EdgeInsets.only(left: 34, top: 4),
child: Row(
children: [
if (entry.distance != null) ...[
Icon(Icons.straighten, size: 10,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
const SizedBox(width: 3),
Text(
entry.distance!,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 10,
),
),
const SizedBox(width: 8),
],
Icon(Icons.schedule, size: 10,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
const SizedBox(width: 3),
Text(
timeAgo,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 10,
),
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final displayName = widget.contact.displayName;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: Text(
'Ping $displayName',
style: theme.textTheme.titleLarge,
),
),
if (_history.isNotEmpty)
IconButton(
onPressed: () => setState(() => _history.clear()),
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Clear history',
style: IconButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
),
),
FilledButton.icon(
onPressed: _pinging ? null : _doPing,
icon: _pinging
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
: const Icon(Icons.network_ping, size: 18),
label: Text(_pinging ? 'Pinging...' : 'Ping Again'),
),
],
),
const SizedBox(height: 16),
if (_history.isEmpty && _pinging)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_history.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'No results yet',
style: theme.textTheme.bodyMedium,
),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.separated(
shrinkWrap: true,
itemCount: _history.length,
separatorBuilder: (_, _) =>
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.3)),
itemBuilder: (context, index) {
final entry = _history[index];
final seq = _history.length - index;
return _buildResultRow(context, entry, seq);
},
),
),
],
),
),
);
}
}

View File

@@ -12,6 +12,7 @@ import '../../models/sar_template.dart';
import '../../models/map_drawing.dart'; import '../../models/map_drawing.dart';
import '../../models/map_coordinate_space.dart'; import '../../models/map_coordinate_space.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../providers/channels_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart'; import '../../providers/drawing_provider.dart';
@@ -1955,10 +1956,12 @@ class _MessageBubbleState extends State<MessageBubble> {
final isSarMarker = message.isSarMarker; final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final messageFontScale = context.watch<AppProvider>().messageFontScale; final messageFontScale = context.watch<AppProvider>().messageFontScale;
final l10n = AppLocalizations.of(context)!;
// Determine if this is own message // Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final channelsProvider = context.watch<ChannelsProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey); message.isSentMessage || message.isFromSelf(selfPublicKey);
@@ -2004,7 +2007,7 @@ class _MessageBubbleState extends State<MessageBubble> {
// Get rich display name (with emoji if available) // Get rich display name (with emoji if available)
final displayName = isOwnMessage final displayName = isOwnMessage
? AppLocalizations.of(context)!.you ? l10n.you
: message.getRichDisplayName(senderContact); : message.getRichDisplayName(senderContact);
// Look up destination/source display labels for direct/channel messages // Look up destination/source display labels for direct/channel messages
@@ -2039,15 +2042,30 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
} }
} else if (message.isChannelMessage) { } else if (message.isChannelMessage) {
if (message.channelIdx == 0) { final channelIdx = message.channelIdx ?? 0;
channelDisplayName = AppLocalizations.of(context)!.publicChannel; if (channelIdx == 0) {
channelDisplayName = l10n.publicChannel;
} else { } else {
final channelContact = contactsProvider.channels.where((c) { final channelContact = contactsProvider.channels.where((c) {
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx; return c.publicKey.length > 1 && c.publicKey[1] == channelIdx;
}).firstOrNull; }).firstOrNull;
final syncedChannel = channelsProvider.getChannel(channelIdx);
final syncedChannelDisplayName =
syncedChannel != null && syncedChannel.hasCustomName
? syncedChannel.displayName
: null;
final contactChannelDisplayName = channelContact
?.getLocalizedDisplayName(context)
.trim();
channelDisplayName = channelDisplayName =
channelContact?.getLocalizedDisplayName(context) ?? syncedChannelDisplayName ??
'${AppLocalizations.of(context)!.channel} ${message.channelIdx}'; (contactChannelDisplayName != null &&
contactChannelDisplayName.isNotEmpty
? contactChannelDisplayName
: null) ??
syncedChannel?.displayName ??
'${l10n.channel} $channelIdx';
} }
if (isOwnMessage) { if (isOwnMessage) {
@@ -2057,14 +2075,14 @@ class _MessageBubbleState extends State<MessageBubble> {
final recipientSubtitle = final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${AppLocalizations.of(context)!.channel}: $recipientDisplayName' ? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName; : recipientDisplayName;
final directCounterpartLabel = !message.isChannelMessage final directCounterpartLabel = !message.isChannelMessage
? (isOwnMessage ? recipientSubtitle : AppLocalizations.of(context)!.you) ? (isOwnMessage ? recipientSubtitle : l10n.you)
: null; : null;
final receivedChannelSubtitle = final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null !isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${AppLocalizations.of(context)!.channel}: $channelDisplayName' ? '${l10n.channel}: $channelDisplayName'
: null; : null;
final shouldFloatBubble = widget.isCompact; final shouldFloatBubble = widget.isCompact;

View File

@@ -19,6 +19,7 @@ class MessagesComposer extends StatelessWidget {
final double bottomPadding; final double bottomPadding;
final String destinationLabel; final String destinationLabel;
final Widget destinationAvatar; final Widget destinationAvatar;
final bool destinationLocked;
final List<Contact> mentionSuggestions; final List<Contact> mentionSuggestions;
final String mentionQuery; final String mentionQuery;
final ValueChanged<Contact> onMentionSelected; final ValueChanged<Contact> onMentionSelected;
@@ -27,6 +28,7 @@ class MessagesComposer extends StatelessWidget {
final Future<void> Function() onStartVoiceRecording; final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice; final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage; final Future<void> Function() onSendMessage;
final VoidCallback? onLongPressSend;
final String? regionScopeName; final String? regionScopeName;
final VoidCallback? onRegionScopeTap; final VoidCallback? onRegionScopeTap;
@@ -43,6 +45,7 @@ class MessagesComposer extends StatelessWidget {
required this.bottomPadding, required this.bottomPadding,
required this.destinationLabel, required this.destinationLabel,
required this.destinationAvatar, required this.destinationAvatar,
required this.destinationLocked,
required this.mentionSuggestions, required this.mentionSuggestions,
required this.mentionQuery, required this.mentionQuery,
required this.onMentionSelected, required this.onMentionSelected,
@@ -51,6 +54,7 @@ class MessagesComposer extends StatelessWidget {
required this.onStartVoiceRecording, required this.onStartVoiceRecording,
required this.onStopAndSendVoice, required this.onStopAndSendVoice,
required this.onSendMessage, required this.onSendMessage,
this.onLongPressSend,
this.regionScopeName, this.regionScopeName,
this.onRegionScopeTap, this.onRegionScopeTap,
}); });
@@ -116,10 +120,13 @@ class MessagesComposer extends StatelessWidget {
], ],
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: _DestinationSelector( child: _DestinationPill(
destinationLabel: destinationLabel, destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar, destinationAvatar: destinationAvatar,
onTap: onShowRecipientSelector, isLocked: destinationLocked,
onTap: destinationLocked
? null
: onShowRecipientSelector,
), ),
), ),
], ],
@@ -163,6 +170,7 @@ class MessagesComposer extends StatelessWidget {
messageByteCount: messageByteCount, messageByteCount: messageByteCount,
maxMessageBytes: maxMessageBytes, maxMessageBytes: maxMessageBytes,
onSendMessage: onSendMessage, onSendMessage: onSendMessage,
onLongPressSend: onLongPressSend,
onStartVoiceRecording: onStartVoiceRecording, onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice, onStopAndSendVoice: onStopAndSendVoice,
), ),
@@ -294,59 +302,73 @@ class _ComposerActionButton extends StatelessWidget {
} }
} }
class _DestinationSelector extends StatelessWidget { class _DestinationPill extends StatelessWidget {
final String destinationLabel; final String destinationLabel;
final Widget destinationAvatar; final Widget destinationAvatar;
final VoidCallback onTap; final bool isLocked;
final VoidCallback? onTap;
const _DestinationSelector({ const _DestinationPill({
required this.destinationLabel, required this.destinationLabel,
required this.destinationAvatar, required this.destinationAvatar,
required this.onTap, required this.isLocked,
this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final content = Ink(
key: ValueKey(
isLocked
? 'messages_composer_destination_locked'
: 'messages_composer_destination_selector',
),
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
if (!isLocked)
Icon(
Icons.expand_more_rounded,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
);
if (onTap == null) {
return Material(color: Colors.transparent, child: content);
}
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: onTap, onTap: onTap,
child: Ink( child: content,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
), ),
); );
} }
@@ -433,6 +455,7 @@ class _SendButton extends StatelessWidget {
final int messageByteCount; final int messageByteCount;
final int maxMessageBytes; final int maxMessageBytes;
final Future<void> Function() onSendMessage; final Future<void> Function() onSendMessage;
final VoidCallback? onLongPressSend;
final Future<void> Function() onStartVoiceRecording; final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice; final Future<void> Function() onStopAndSendVoice;
@@ -445,6 +468,7 @@ class _SendButton extends StatelessWidget {
required this.messageByteCount, required this.messageByteCount,
required this.maxMessageBytes, required this.maxMessageBytes,
required this.onSendMessage, required this.onSendMessage,
this.onLongPressSend,
required this.onStartVoiceRecording, required this.onStartVoiceRecording,
required this.onStopAndSendVoice, required this.onStopAndSendVoice,
}); });
@@ -456,28 +480,32 @@ class _SendButton extends StatelessWidget {
enabled: canSendText || (voiceSupported && !isSendingVoice), enabled: canSendText || (voiceSupported && !isSendingVoice),
label: semanticsLabel, label: semanticsLabel,
onTap: canSendText ? onSendMessage : null, onTap: canSendText ? onSendMessage : null,
onLongPress: (voiceSupported && !isSendingVoice) onLongPress: canSendText && onLongPressSend != null
? () { ? onLongPressSend
if (isRecording) { : (voiceSupported && !isSendingVoice)
onStopAndSendVoice(); ? () {
return; if (isRecording) {
} onStopAndSendVoice();
onStartVoiceRecording(); return;
} }
: null, onStartVoiceRecording();
}
: null,
child: Tooltip( child: Tooltip(
message: semanticsLabel, message: semanticsLabel,
excludeFromSemantics: true, excludeFromSemantics: true,
child: GestureDetector( child: GestureDetector(
excludeFromSemantics: true, excludeFromSemantics: true,
onTap: canSendText ? onSendMessage : null, onTap: canSendText ? onSendMessage : null,
onLongPressStart: (voiceSupported && !isSendingVoice) onLongPressStart: canSendText && onLongPressSend != null
? (_) => onStartVoiceRecording() ? (_) => onLongPressSend!()
: null, : (voiceSupported && !isSendingVoice)
onLongPressEnd: (voiceSupported && isRecording) ? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (!canSendText && voiceSupported && isRecording)
? (_) => onStopAndSendVoice() ? (_) => onStopAndSendVoice()
: null, : null,
onLongPressCancel: (voiceSupported && isRecording) onLongPressCancel: (!canSendText && voiceSupported && isRecording)
? onStopAndSendVoice ? onStopAndSendVoice
: null, : null,
child: Column( child: Column(

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/messages_provider.dart';
import '../../utils/avatar_label_helper.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
enum _RecipientSortMode { activity, favorites, alphabetical } enum _RecipientSortMode { activity, favorites, alphabetical }
@@ -17,6 +20,7 @@ class RecipientSelectorSheet extends StatefulWidget {
final String? currentRecipientPublicKey; final String? currentRecipientPublicKey;
final bool showAllOption; final bool showAllOption;
final Function(String type, Contact? recipient) onSelect; final Function(String type, Contact? recipient) onSelect;
final MessagesProvider? messagesProvider;
/// Region scope names per channel index (e.g. {0: "#auckland"}). /// Region scope names per channel index (e.g. {0: "#auckland"}).
final Map<int, String> channelRegionScopes; final Map<int, String> channelRegionScopes;
@@ -32,6 +36,7 @@ class RecipientSelectorSheet extends StatefulWidget {
this.currentRecipientPublicKey, this.currentRecipientPublicKey,
this.showAllOption = true, this.showAllOption = true,
required this.onSelect, required this.onSelect,
this.messagesProvider,
this.channelRegionScopes = const {}, this.channelRegionScopes = const {},
}); });
@@ -167,20 +172,162 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
} }
} }
String _channelSubtitle(BuildContext context, Contact channel) { MessagesProvider? _resolveMessagesProvider(BuildContext context) {
final l10n = AppLocalizations.of(context)!; if (widget.messagesProvider != null) {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; return widget.messagesProvider;
final scopeName = widget.channelRegionScopes[channelIdx];
if (channel.isPublicChannel) {
return scopeName != null
? '${l10n.broadcastToAllNearby}$scopeName'
: l10n.broadcastToAllNearby;
} }
final shortKey = channel.publicKeyShort.toUpperCase(); try {
final base = '${l10n.channel} $channelIdx$shortKey'; return Provider.of<MessagesProvider>(context);
return scopeName != null ? '$base$scopeName' : base; } on ProviderNotFoundException {
return null;
}
}
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);
}
_ChannelPreviewData _channelPreviewData(
BuildContext context,
Contact channel,
MessagesProvider? messagesProvider,
) {
if (messagesProvider == null) {
return const _ChannelPreviewData();
}
final lastActivityAt = messagesProvider.getLastActivityForDestination(
channel,
);
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final participantNames = <String>[];
for (final message in channelMessages) {
final senderName = message.senderName?.trim();
if (senderName == null || senderName.isEmpty) {
continue;
}
if (!participantNames.contains(senderName)) {
participantNames.add(senderName);
}
}
return _ChannelPreviewData(
activityLabel: lastActivityAt == null
? null
: _formatRelativeTime(context, lastActivityAt),
participantNames: participantNames,
);
}
Contact? _findParticipantContact(String name) {
final normalizedName = name.trim();
for (final contact in widget.contacts) {
if (!contact.isChannel && contact.advName.trim() == normalizedName) {
return contact;
}
}
for (final contact in widget.contacts) {
if (!contact.isChannel && contact.displayName.trim() == normalizedName) {
return contact;
}
}
return null;
}
String _contactActivityLabel(
BuildContext context,
Contact contact,
MessagesProvider? messagesProvider,
) {
final lastActivityAt =
messagesProvider?.getLastActivityForDestination(contact) ??
contact.lastSeenTime;
return _formatRelativeTime(context, lastActivityAt);
}
Widget _buildTextSubtitle(
BuildContext context,
Contact contact,
String subtitle,
) {
final colorScheme = Theme.of(context).colorScheme;
return Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: contact.isChannel ? null : 'monospace',
),
);
}
Widget _buildChannelSubtitle(
BuildContext context,
Contact channel,
_ChannelPreviewData previewData,
) {
final colorScheme = Theme.of(context).colorScheme;
if (previewData.participantNames.isEmpty) {
return Text(
'No recent chatters',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
);
}
return Row(
children: [
_ParticipantAvatarStack(
key: Key('channel-participants-${channel.publicKeyHex}'),
names: previewData.participantNames,
contactForName: _findParticipantContact,
),
],
);
}
Widget _buildChannelRecipientCard(
BuildContext context,
Contact channel,
MessagesProvider? messagesProvider,
) {
final previewData = _channelPreviewData(context, channel, messagesProvider);
return _buildRecipientCard(
context: context,
type: 'channel',
contact: channel,
title: channel.getLocalizedDisplayName(context),
subtitle: _buildChannelSubtitle(context, channel, previewData),
unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel),
compact: true,
activityLabel: previewData.activityLabel,
onTap: () {
widget.onSelect('channel', channel);
Navigator.pop(context);
},
);
} }
bool _isDenseSection(String type) => type == 'channel' || type == 'contact'; bool _isDenseSection(String type) => type == 'channel' || type == 'contact';
@@ -189,6 +336,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final messagesProvider = _resolveMessagesProvider(context);
final filteredContacts = _filterAndSortContacts(widget.contacts); final filteredContacts = _filterAndSortContacts(widget.contacts);
final filteredRooms = _filterAndSortContacts(widget.rooms); final filteredRooms = _filterAndSortContacts(widget.rooms);
final filteredChannels = _filterAndSortContacts( final filteredChannels = _filterAndSortContacts(
@@ -335,19 +483,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
emptyLabel: l10n.noChannelsFound, emptyLabel: l10n.noChannelsFound,
children: [ children: [
for (final channel in filteredChannels) for (final channel in filteredChannels)
_buildRecipientCard( _buildChannelRecipientCard(
context: context, context,
type: 'channel', channel,
contact: channel, messagesProvider,
title: channel.getLocalizedDisplayName(context),
subtitle: _channelSubtitle(context, channel),
unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel),
compact: true,
onTap: () {
widget.onSelect('channel', channel);
Navigator.pop(context);
},
), ),
], ],
), ),
@@ -366,7 +505,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
type: 'contact', type: 'contact',
contact: contact, contact: contact,
title: contact.displayName, title: contact.displayName,
subtitle: contact.publicKeyShort, activityLabel: _contactActivityLabel(
context,
contact,
messagesProvider,
),
unreadCount: _unreadFor(contact), unreadCount: _unreadFor(contact),
isSelected: _isSelected('contact', contact), isSelected: _isSelected('contact', contact),
compact: true, compact: true,
@@ -392,7 +535,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
type: 'room', type: 'room',
contact: room, contact: room,
title: room.displayName, title: room.displayName,
subtitle: room.publicKeyShort, subtitle: _buildTextSubtitle(
context,
room,
room.publicKeyShort,
),
unreadCount: _unreadFor(room), unreadCount: _unreadFor(room),
isSelected: _isSelected('room', room), isSelected: _isSelected('room', room),
onTap: () { onTap: () {
@@ -690,10 +837,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
required String type, required String type,
required Contact contact, required Contact contact,
required String title, required String title,
required String subtitle, Widget? subtitle,
required int unreadCount, required int unreadCount,
required bool isSelected, required bool isSelected,
bool compact = false, bool compact = false,
String? activityLabel,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -757,52 +905,70 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Flexible( Expanded(
child: Text( child: Row(
title, children: [
maxLines: 1, Flexible(
overflow: TextOverflow.ellipsis, child: Text(
style: Theme.of(context).textTheme.titleSmall title,
?.copyWith( maxLines: 1,
fontWeight: FontWeight.w800, overflow: TextOverflow.ellipsis,
letterSpacing: -0.2, style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
),
), ),
),
if (contact.isPublicChannel) ...[
SizedBox(width: compact ? 6 : 8),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 2 : 3,
),
decoration: BoxDecoration(
color: accentColor.withValues(
alpha: 0.10,
),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Public',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w800,
),
),
),
],
],
), ),
), ),
if (contact.isPublicChannel) ...[ if (activityLabel != null) ...[
SizedBox(width: compact ? 6 : 8), SizedBox(width: compact ? 8 : 10),
Container( Text(
padding: EdgeInsets.symmetric( activityLabel,
horizontal: compact ? 7 : 8, style: Theme.of(context).textTheme.labelSmall
vertical: compact ? 2 : 3, ?.copyWith(
), color: colorScheme.onSurfaceVariant,
decoration: BoxDecoration( fontWeight: FontWeight.w700,
color: accentColor.withValues(alpha: 0.10), ),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Public',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w800,
),
),
), ),
], ],
], ],
), ),
SizedBox(height: compact ? 2 : 4), if (subtitle != null) ...[
Text( SizedBox(height: compact ? 2 : 4),
subtitle, subtitle,
maxLines: 1, ],
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: contact.isChannel ? null : 'monospace',
),
),
], ],
), ),
), ),
@@ -858,3 +1024,140 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
); );
} }
} }
class _ChannelPreviewData {
final String? activityLabel;
final List<String> participantNames;
const _ChannelPreviewData({
this.activityLabel,
this.participantNames = const <String>[],
});
}
class _ParticipantAvatarStack extends StatelessWidget {
final List<String> names;
final Contact? Function(String name) contactForName;
static const int _visibleCount = 4;
const _ParticipantAvatarStack({
super.key,
required this.names,
required this.contactForName,
});
@override
Widget build(BuildContext context) {
final visibleNames = names.take(_visibleCount).toList();
final overflowCount = names.length - visibleNames.length;
const avatarSize = 20.0;
const spacing = 14.0;
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
return SizedBox(
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: contactForName(visibleNames[i]),
),
),
if (overflowCount > 0)
Positioned(
left: visibleNames.length * spacing,
top: 0,
child: _ParticipantOverflowAvatar(count: overflowCount),
),
],
),
);
}
}
class _ParticipantOverflowAvatar extends StatelessWidget {
final int count;
const _ParticipantOverflowAvatar({required this.count});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 20,
height: 20,
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: 8,
),
),
);
}
}
class _ParticipantAvatar extends StatelessWidget {
final String name;
final Contact? contact;
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: 20,
height: 20,
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: 6)),
),
),
);
}
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 20,
height: 20,
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: 8,
),
),
);
}
}

View File

@@ -29,6 +29,31 @@ class SensorMetricOption {
}); });
} }
const Set<String> _imperialMeasurementCountries = <String>{
'US',
'LR',
'MM',
};
bool _usesImperialSpeedUnits() {
final countryCode =
WidgetsBinding.instance.platformDispatcher.locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return false;
}
return _imperialMeasurementCountries.contains(countryCode.toUpperCase());
}
String _formatPreviewSpeed(num metersPerSecond) {
if (_usesImperialSpeedUnits()) {
final milesPerHour = metersPerSecond * 2.2369362920544;
return '${_formatPreviewNumber(milesPerHour, maxFractionDigits: 2)} mph';
}
final kilometersPerHour = metersPerSecond * 3.6;
return '${_formatPreviewNumber(kilometersPerHour, maxFractionDigits: 2)} km/h';
}
List<SensorMetricOption> sensorMetricOptionsFor( List<SensorMetricOption> sensorMetricOptionsFor(
Contact? contact, { Contact? contact, {
Map<String, String> labelOverrides = const <String, String>{}, Map<String, String> labelOverrides = const <String, String>{},
@@ -795,17 +820,17 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) {
case 'speed': case 'speed':
final metersPerSecond = _previewAsDouble(value); final metersPerSecond = _previewAsDouble(value);
if (metersPerSecond == null) return null; if (metersPerSecond == null) return null;
return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; return _formatPreviewSpeed(metersPerSecond);
case 'signed_speed': case 'signed_speed':
final metersPerSecond = _previewAsDouble(value); final metersPerSecond = _previewAsDouble(value);
if (metersPerSecond == null) return null; if (metersPerSecond == null) return null;
return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; return _formatPreviewSpeed(metersPerSecond);
case 'gust': case 'gust':
final metersPerSecond = _previewAsDouble(value); final metersPerSecond = _previewAsDouble(value);
if (metersPerSecond == null) return null; if (metersPerSecond == null) return null;
return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; return _formatPreviewSpeed(metersPerSecond);
case 'dew': case 'dew':
final degreesCelsius = _previewAsDouble(value); final degreesCelsius = _previewAsDouble(value);
@@ -1066,6 +1091,16 @@ class SensorTelemetryCard extends StatelessWidget {
this.labelOverrides = const <String, String>{}, this.labelOverrides = const <String, String>{},
}); });
String _formatSpeed(num metersPerSecond) {
if (_usesImperialSpeedUnits()) {
final milesPerHour = metersPerSecond * 2.2369362920544;
return '${_formatNumber(milesPerHour, maxFractionDigits: 2)} mph';
}
final kilometersPerHour = metersPerSecond * 3.6;
return '${_formatNumber(kilometersPerHour, maxFractionDigits: 2)} km/h';
}
bool get _showsMenu => bool get _showsMenu =>
onRefresh != null || onRefresh != null ||
onCustomize != null || onCustomize != null ||
@@ -1759,7 +1794,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.air, icon: Icons.air,
label: label, label: label,
value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', value: _formatSpeed(metersPerSecond),
accent: const Color(0xFF2B78A0), accent: const Color(0xFF2B78A0),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -1771,7 +1806,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.air, icon: Icons.air,
label: label, label: label,
value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', value: _formatSpeed(metersPerSecond),
accent: const Color(0xFF2B78A0), accent: const Color(0xFF2B78A0),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -1783,7 +1818,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.air, icon: Icons.air,
label: label, label: label,
value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', value: _formatSpeed(metersPerSecond),
accent: const Color(0xFF1E88A8), accent: const Color(0xFF1E88A8),
channel: metricKey.channel, channel: metricKey.channel,
); );
@@ -2252,7 +2287,7 @@ class _InlineAlertBadge extends StatelessWidget {
} }
} }
class SensorMetricTile extends StatelessWidget { class SensorMetricTile extends StatefulWidget {
final SensorMetricCardData data; final SensorMetricCardData data;
final double width; final double width;
final String keyPrefix; final String keyPrefix;
@@ -2268,8 +2303,43 @@ class SensorMetricTile extends StatelessWidget {
this.onLongPress, this.onLongPress,
}); });
@override
State<SensorMetricTile> createState() => _SensorMetricTileState();
}
class _SensorMetricTileState extends State<SensorMetricTile> {
final flutter_map.MapController _previewMapController =
flutter_map.MapController();
@override
void didUpdateWidget(covariant SensorMetricTile oldWidget) {
super.didUpdateWidget(oldWidget);
final previousLocation = oldWidget.data.mapLocation;
final nextLocation = widget.data.mapLocation;
if (!_sameMapLocation(previousLocation, nextLocation) &&
nextLocation != null &&
widget.allowMapPreview) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_previewMapController.move(
nextLocation,
_previewMapController.camera.zoom,
);
});
}
}
bool _sameMapLocation(LatLng? a, LatLng? b) {
if (a == null || b == null) {
return a == b;
}
return a.latitude == b.latitude && a.longitude == b.longitude;
}
Future<void> _showExpandedMap(BuildContext context) async { Future<void> _showExpandedMap(BuildContext context) async {
final location = data.mapLocation; final location = widget.data.mapLocation;
if (location == null) return; if (location == null) return;
await Navigator.of(context).push( await Navigator.of(context).push(
@@ -2280,9 +2350,9 @@ class SensorMetricTile extends StatelessWidget {
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(data.label), Text(widget.data.label),
Text( Text(
data.value, widget.data.value,
style: Theme.of(pageContext).textTheme.bodySmall, style: Theme.of(pageContext).textTheme.bodySmall,
), ),
], ],
@@ -2291,11 +2361,11 @@ class SensorMetricTile extends StatelessWidget {
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (data.secondaryValue != null) if (widget.data.secondaryValue != null)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Text( child: Text(
data.secondaryValue!, widget.data.secondaryValue!,
style: Theme.of(pageContext).textTheme.bodyMedium, style: Theme.of(pageContext).textTheme.bodyMedium,
), ),
), ),
@@ -2320,7 +2390,7 @@ class SensorMetricTile extends StatelessWidget {
height: 40, height: 40,
child: Icon( child: Icon(
Icons.location_on, Icons.location_on,
color: data.accent, color: widget.data.accent,
size: 34, size: 34,
), ),
), ),
@@ -2340,14 +2410,15 @@ class SensorMetricTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = widget.data;
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
key: ValueKey('${keyPrefix}_${data.fieldKey}'), key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
onLongPress: onLongPress, onLongPress: widget.onLongPress,
child: Container( child: Container(
width: width, width: widget.width,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.08), color: data.accent.withValues(alpha: 0.08),
@@ -2368,7 +2439,10 @@ class SensorMetricTile extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix), child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
), ),
], ],
), ),
@@ -2387,7 +2461,7 @@ class SensorMetricTile extends StatelessWidget {
), ),
], ],
) )
: data.mapLocation == null || !allowMapPreview : data.mapLocation == null || !widget.allowMapPreview
? Stack( ? Stack(
children: [ children: [
Row( Row(
@@ -2396,7 +2470,10 @@ class SensorMetricTile extends StatelessWidget {
_MetricIcon(accent: data.accent, icon: data.icon), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix), child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
), ),
], ],
), ),
@@ -2424,7 +2501,10 @@ class SensorMetricTile extends StatelessWidget {
_MetricIcon(accent: data.accent, icon: data.icon), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix), child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
), ),
], ],
), ),
@@ -2439,12 +2519,13 @@ class SensorMetricTile extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: 104, height: 104,
width: double.infinity, width: double.infinity,
child: Stack( child: Stack(
children: [ children: [
flutter_map.FlutterMap( flutter_map.FlutterMap(
options: flutter_map.MapOptions( mapController: _previewMapController,
initialCenter: data.mapLocation!, options: flutter_map.MapOptions(
initialZoom: 14, initialCenter: data.mapLocation!,
initialZoom: 14,
interactionOptions: interactionOptions:
const flutter_map.InteractionOptions( const flutter_map.InteractionOptions(
flags: flags:

View File

@@ -827,8 +827,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: a0deff8 ref: "0bb339f684f709cf5b9d40c07827cc62beb18a4d"
resolved-ref: a0deff80fcbca974f0e18fe47971b76bec583109 resolved-ref: "0bb339f684f709cf5b9d40c07827cc62beb18a4d"
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"

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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 2026.0322.1+38 version: 2026.0324.1+43
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -44,7 +44,7 @@ dependencies:
meshcore_client: meshcore_client:
git: git:
url: https://github.com/dz0ny/meshcore_client.git url: https://github.com/dz0ny/meshcore_client.git
ref: a0deff8 ref: 0bb339f684f709cf5b9d40c07827cc62beb18a4d
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: codec2_flutter:

View File

@@ -1,6 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/channel.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart';
void main() { void main() {
@@ -22,4 +23,38 @@ void main() {
expect(provider.selectedChannel, isNull); expect(provider.selectedChannel, isNull);
}); });
}); });
group('ChannelsProvider mesh hash lookup', () {
test('resolves a unique channel display name by hash byte', () {
final provider = ChannelsProvider();
final channel = Channel.create(index: 3, name: '#ops');
provider.addOrUpdateChannelObject(channel);
expect(
provider.getUniqueChannelDisplayNameByHashByte(channel.hashByte),
'#ops',
);
});
test('does not resolve ambiguous hash matches', () {
final provider = ChannelsProvider();
final secret = Uint8List.fromList(List<int>.filled(16, 7));
final firstChannel = Channel.create(
index: 1,
name: 'Ops 1',
explicitSecret: secret,
);
provider.addOrUpdateChannelObject(firstChannel);
provider.addOrUpdateChannelObject(
Channel.create(index: 2, name: 'Ops 2', explicitSecret: secret),
);
expect(
provider.getUniqueChannelDisplayNameByHashByte(firstChannel.hashByte),
isNull,
);
});
});
} }

View File

@@ -781,6 +781,19 @@ void main() {
expect(updated.advLat, equals((44.123456 * 1e6).round())); expect(updated.advLat, equals((44.123456 * 1e6).round()));
expect(updated.advLon, equals((13.654321 * 1e6).round())); expect(updated.advLon, equals((13.654321 * 1e6).round()));
expect(updated.lastAdvert, equals(1700001234)); expect(updated.lastAdvert, equals(1700001234));
expect(updated.lastMod, equals(1700001234));
expect(
provider.estimatedLocationFor(updated.publicKeyHex),
isNotNull,
);
expect(
provider.estimatedLocationFor(updated.publicKeyHex)!.latitude,
closeTo(44.123456, 0.000001),
);
expect(
provider.estimatedLocationFor(updated.publicKeyHex)!.longitude,
closeTo(13.654321, 0.000001),
);
}); });
test('ignores unknown sender prefix safely', () { test('ignores unknown sender prefix safely', () {
@@ -880,7 +893,7 @@ void main() {
}); });
test( test(
'clears runtime contacts before sync without erasing persisted contacts or saved groups', 'keeps runtime contacts visible during sync without erasing persisted contacts or saved groups',
() async { () async {
final key = createPublicKey(140); final key = createPublicKey(140);
final pendingKey = createPublicKey(180); final pendingKey = createPublicKey(180);
@@ -893,8 +906,11 @@ void main() {
await provider.prepareForDeviceContactSync(); await provider.prepareForDeviceContactSync();
expect(provider.chatContacts, isEmpty); expect(
expect(provider.pendingAdverts, isEmpty); provider.chatContacts.map((contact) => contact.advName),
contains('Synced Later'),
);
expect(provider.pendingAdverts, hasLength(1));
expect(provider.savedGroupsForSection('teamMembers'), hasLength(1)); expect(provider.savedGroupsForSection('teamMembers'), hasLength(1));
final restored = ContactsProvider(); final restored = ContactsProvider();

View File

@@ -214,7 +214,7 @@ void main() {
}); });
}); });
test('channel warning clears when replay is deduped into sent bubble', () { test('channel warning clears when replay arrives after send', () {
fakeAsync((async) { fakeAsync((async) {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
@@ -240,10 +240,11 @@ void main() {
expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse); expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse);
expect(provider.messages, hasLength(1)); expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-warn-replay'));
}); });
}); });
test('channel replay is deduped for self sender within repeat window', () { test('channel replay merges into sent message for self sender within repeat window', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage( provider.addSentMessage(
@@ -261,7 +262,7 @@ void main() {
expect(provider.messages, hasLength(1)); expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-echo')); expect(provider.messages.single.id, equals('c-echo'));
expect(provider.messages.single.senderName, equals('dz0ny (SI)')); expect(provider.messages.single.pathLen, equals(1));
}); });
test( test(
@@ -308,7 +309,7 @@ void main() {
expect(provider.messages, hasLength(2)); expect(provider.messages, hasLength(2));
}); });
test('channel replay can dedupe using lazily resolved self name', () { test('channel replay merges using lazily resolved self name', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.addSentMessage( provider.addSentMessage(
_buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400), _buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400),
@@ -328,7 +329,7 @@ void main() {
expect(provider.messages.single.id, equals('c-lazy')); expect(provider.messages.single.id, equals('c-lazy'));
}); });
test('channel replay dedupes meshcore-prefixed self sender name', () { test('channel replay merges for meshcore-prefixed self sender name', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)';
provider.addSentMessage( provider.addSentMessage(
@@ -422,6 +423,133 @@ void main() {
expect(provider.messages.single.id, equals('handle-1')); expect(provider.messages.single.id, equals('handle-1'));
}); });
test('channel duplicates only dedupe against the latest channel message', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([9, 8, 7, 6, 5, 4]);
provider.addMessage(
Message(
id: 'channel-first',
messageType: MessageType.channel,
channelIdx: 2,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000820,
text: 'same payload',
senderName: 'Radio Alpha',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'channel-middle',
messageType: MessageType.channel,
channelIdx: 2,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000825,
text: 'different payload',
senderName: 'Radio Bravo',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
),
);
provider.addMessage(
Message(
id: 'channel-repeat',
messageType: MessageType.channel,
channelIdx: 2,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000830,
text: 'same payload',
senderName: 'Radio Alpha',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(3));
expect(provider.messages.last.id, equals('channel-repeat'));
});
test('adjacent channel duplicates still dedupe within 5 seconds', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([4, 5, 6, 7, 8, 9]);
final baseTime = DateTime.fromMillisecondsSinceEpoch(1700000840000);
provider.addMessage(
Message(
id: 'adjacent-1',
messageType: MessageType.channel,
channelIdx: 3,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000840,
text: 'same payload',
senderName: 'Radio Charlie',
receivedAt: baseTime,
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'adjacent-2',
messageType: MessageType.channel,
channelIdx: 3,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000845,
text: 'same payload',
senderName: 'Radio Charlie',
receivedAt: baseTime.add(const Duration(seconds: 4)),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('adjacent-1'));
});
test('channel duplicates outside 5 second window are kept', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([6, 7, 8, 9, 0, 1]);
final baseTime = DateTime.fromMillisecondsSinceEpoch(1700000850000);
provider.addMessage(
Message(
id: 'window-1',
messageType: MessageType.channel,
channelIdx: 4,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000850,
text: 'same payload',
senderName: 'Radio Delta',
receivedAt: baseTime,
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'window-2',
messageType: MessageType.channel,
channelIdx: 4,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000855,
text: 'same payload',
senderName: 'Radio Delta',
receivedAt: baseTime.add(const Duration(seconds: 6)),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(2));
expect(provider.messages.last.id, equals('window-2'));
});
test('display list collapses stored duplicates and sums copy counts', () { test('display list collapses stored duplicates and sums copy counts', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]); final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
@@ -462,6 +590,30 @@ void main() {
expect(display.single.occurrenceCount, equals(2)); expect(display.single.occurrenceCount, equals(2));
}); });
test('display list keeps a single sent message for self replay', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(id: 'display-sent', senderTimestamp: 1700000900),
);
provider.markMessageSent('display-sent', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'display-received',
senderTimestamp: 1700000901,
senderName: 'dz0ny (SI)',
),
);
final display = provider.buildDisplayMessages(
provider.getRecentMessages(),
);
expect(display, hasLength(1));
expect(display.single.message.id, equals('display-sent'));
});
test('missing ACK schedules a delayed retransmission', () { test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) { fakeAsync((async) {
final provider = MessagesProvider(); final provider = MessagesProvider();

View File

@@ -356,7 +356,7 @@ void main() {
expect(connectionProvider.pingCalls, 2); expect(connectionProvider.pingCalls, 2);
}); });
test('refreshDueSensors refreshes self every minute', () async { test('refreshDueSensors refreshes self every 30 seconds', () async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
final selfKey = Uint8List(32)..[0] = 0x66; final selfKey = Uint8List(32)..[0] = 0x66;
final contactsProvider = ContactsProvider(); final contactsProvider = ContactsProvider();
@@ -383,14 +383,21 @@ void main() {
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
connectionProvider: connectionProvider, connectionProvider: connectionProvider,
); );
expect(connectionProvider.pingCalls, 1); expect(connectionProvider.pingCalls, 2);
await provider.refreshDueSensors(
now: start.add(const Duration(seconds: 59)),
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
expect(connectionProvider.pingCalls, 2);
await provider.refreshDueSensors( await provider.refreshDueSensors(
now: start.add(const Duration(minutes: 1)), now: start.add(const Duration(minutes: 1)),
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
connectionProvider: connectionProvider, connectionProvider: connectionProvider,
); );
expect(connectionProvider.pingCalls, 2); expect(connectionProvider.pingCalls, 3);
}); });
test( test(

View File

@@ -2,9 +2,12 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/channel.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart'; import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart'; import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
import 'package:provider/provider.dart';
BlePacketLog _log({ BlePacketLog _log({
required DateTime timestamp, required DateTime timestamp,
@@ -51,12 +54,16 @@ List<int> _multiHopRaw({
]; ];
} }
Widget _testApp(Widget child) { Widget _testApp(Widget child, {ChannelsProvider? channelsProvider}) {
return MaterialApp( final app = MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: child, home: child,
); );
if (channelsProvider == null) {
return app;
}
return ChangeNotifierProvider.value(value: channelsProvider, child: app);
} }
void main() { void main() {
@@ -190,6 +197,82 @@ void main() {
expect(find.textContaining('Size: 3 bytes'), findsOneWidget); expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
}); });
testWidgets('shows known channel name for group traffic', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
final channel = Channel.create(index: 3, name: '#ops');
final channelsProvider = ChannelsProvider()
..initializePublicChannel()
..addOrUpdateChannelObject(channel);
final now = DateTime(2026, 3, 12, 12, 0, 0);
logs.add(
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: [
..._multiHopRaw(hops: [0xC0, 0x10], payloadType: 0x05),
channel.hashByte,
],
responseCode: 0x88,
),
);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
channelsProvider: channelsProvider,
),
);
expect(find.text('#ops'), findsOneWidget);
expect(find.text('Channel: #ops (${channel.hashHex})'), findsOneWidget);
expect(find.text('FLOOD GROUP_TEXT'), findsNothing);
});
testWidgets('shows packet type help sheet from the app bar', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
final now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
await tester.tap(find.byTooltip('Packet type help'));
await tester.pumpAndSettle();
expect(find.text('Packet Types'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('FLOOD RETURNED_PATH'),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
expect(find.text('FLOOD RETURNED_PATH'), findsOneWidget);
expect(
find.textContaining('stores that returned path as the peer\'s direct out-path'),
findsOneWidget,
);
await tester.scrollUntilVisible(
find.text('FLOOD CONTROL'),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
expect(find.text('FLOOD CONTROL'), findsOneWidget);
});
testWidgets('summary metrics expand across wide layouts', (tester) async { testWidgets('summary metrics expand across wide layouts', (tester) async {
tester.view.physicalSize = const Size(1200, 900); tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;

View File

@@ -13,6 +13,7 @@ import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart'; import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/screens/messages_tab.dart'; import 'package:meshcore_sar_app/screens/messages_tab.dart';
import 'package:meshcore_sar_app/services/message_destination_preferences.dart';
import 'package:meshcore_sar_app/services/voice_codec_service.dart'; import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -208,4 +209,227 @@ void main() {
connectionProvider.dispose(); connectionProvider.dispose();
channelsProvider.dispose(); channelsProvider.dispose();
}); });
testWidgets(
'locks messages tab to the configured channel and removes the selector affordance',
(tester) async {
final lockedChannel = buildContact(
name: '#ops',
type: ContactType.channel,
secondByte: 2,
);
SharedPreferences.setMockInitialValues({
'message_locked_destination_enabled': true,
'message_locked_destination_type':
MessageDestinationPreferences.destinationTypeChannel,
'message_locked_recipient_public_key': lockedChannel.publicKeyHex,
});
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([lockedChannel]);
messagesProvider.addMessage(
Message(
id: 'public-message',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000200,
text: 'Public chatter',
receivedAt: DateTime.now(),
channelIdx: 0,
),
);
messagesProvider.addMessage(
Message(
id: 'locked-message',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000201,
text: 'Ops chatter',
receivedAt: DateTime.now(),
channelIdx: 2,
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Ops chatter'), findsOneWidget);
expect(find.text('Public chatter'), findsNothing);
expect(
find.byKey(const ValueKey('messages_composer_destination_locked')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('messages_composer_destination_selector')),
findsNothing,
);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
},
);
testWidgets(
'contact navigation temporarily overrides the lock while keeping the selector hidden',
(tester) async {
final directContact = buildContact(
name: 'Tim',
type: ContactType.chat,
secondByte: 9,
);
SharedPreferences.setMockInitialValues({
'message_locked_destination_enabled': true,
'message_locked_destination_type':
MessageDestinationPreferences.destinationTypeChannel,
});
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([directContact]);
messagesProvider.addMessage(
Message(
id: 'public-message',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000300,
text: 'Public chatter',
receivedAt: DateTime.now(),
channelIdx: 0,
),
);
messagesProvider.addMessage(
Message(
id: 'direct-message',
messageType: MessageType.contact,
senderPublicKeyPrefix: directContact.publicKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000301,
text: 'Direct chatter',
receivedAt: DateTime.now(),
),
);
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeContact,
recipientPublicKeyHex: directContact.publicKeyHex,
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Direct chatter'), findsOneWidget);
expect(find.text('Public chatter'), findsNothing);
expect(
find.byKey(const ValueKey('messages_composer_destination_locked')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('messages_composer_destination_selector')),
findsNothing,
);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
},
);
} }

View File

@@ -196,5 +196,18 @@ void main() {
1, 1,
); );
}); });
test('exposes detailed descriptions for known packet types', () {
final returnedPath = LiveTrafficEntry.payloadTypeDetails(0x08);
final control = LiveTrafficEntry.payloadTypeDetails(0x0B);
expect(returnedPath.title, 'FLOOD RETURNED_PATH');
expect(
returnedPath.description,
contains('stores that returned path as the peer\'s direct out-path'),
);
expect(control.label, 'Control packet');
expect(control.description, contains('discovery request and response'));
});
}); });
} }

View File

@@ -19,6 +19,13 @@ void main() {
service.fastLocationChannelIdx = null; service.fastLocationChannelIdx = null;
}); });
test('loads conservative fast location defaults', () async {
await service.loadSettings();
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 10);
});
test('persists and restores fast location channel idx', () async { test('persists and restores fast location channel idx', () async {
await service.updateFastLocationChannelIdx(3); await service.updateFastLocationChannelIdx(3);
@@ -37,4 +44,12 @@ void main() {
expect(service.fastLocationChannelIdx, isNull); expect(service.fastLocationChannelIdx, isNull);
}); });
test('clamps fast location settings to conservative limits', () async {
await service.updateFastLocationMovementThreshold(3);
await service.updateFastLocationActiveCadenceSeconds(45);
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 31);
});
} }

View File

@@ -1,5 +1,4 @@
import 'dart:typed_data'; import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart'; import 'package:meshcore_sar_app/models/message_reception_details.dart';
@@ -8,11 +7,16 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final originalDebugPrint = debugPrint;
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
tearDown(() {
debugPrint = originalDebugPrint;
});
test( test(
'retains path bytes for stored unread message when reception sidecar is missing', 'retains path bytes for stored unread message when reception sidecar is missing',
() async { () async {
@@ -155,4 +159,34 @@ void main() {
expect(customMessages.single.id, customMessage.id); expect(customMessages.single.id, customMessage.id);
expect(customMessages.single.text, customMessage.text); expect(customMessages.single.text, customMessage.text);
}); });
test('skips unchanged message snapshots', () async {
final storage = MessageStorageService();
final logs = <String>[];
debugPrint = (String? message, {int? wrapWidth}) {
if (message != null) {
logs.add(message);
}
};
final message = Message(
id: 'msg-stable',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([3, 3, 3, 3, 3, 3]),
channelIdx: 3,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700003000,
text: 'Stable snapshot',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700003000500),
isRead: true,
);
await storage.saveMessages([message]);
await storage.saveMessages([message]);
expect(
logs.where((log) => log.contains('Saved 1 messages to storage')),
hasLength(1),
);
});
} }

View File

@@ -206,4 +206,75 @@ void main() {
expect(history.directPaths.single.source, PathRecordSource.observed); expect(history.directPaths.single.source, PathRecordSource.observed);
}, },
); );
test('clear history removes stored direct paths for one contact', () async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [0x01, 0x02], 1);
await service.recordReceivedBytePath('def456', [0x03, 0x04], 1);
expect(service.historyFor('abc123').directPaths, hasLength(1));
expect(service.historyFor('def456').directPaths, hasLength(1));
await service.clearHistoryFor('abc123');
expect(service.historyFor('abc123').directPaths, isEmpty);
expect(service.historyFor('def456').directPaths, hasLength(1));
});
test('last successful direct path is chosen by location fit', () async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 7,
pathBytes: [0xAA],
hopCount: 1,
hashSize: 1,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 120,
senderLatitude: 46.0,
senderLongitude: 14.0,
recipientLatitude: 46.1,
recipientLongitude: 14.1,
);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x22]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 90,
senderLatitude: 46.0001,
senderLongitude: 14.0001,
recipientLatitude: 46.1001,
recipientLongitude: 14.1001,
);
final selection = await service.getLastSuccessfulDirectSelection(
contact,
excludeSignature: 'aa',
senderLatitude: 46.0002,
senderLongitude: 14.0002,
recipientLatitude: 46.1002,
recipientLongitude: 14.1002,
);
expect(selection, isNotNull);
expect(selection!.mode, PathSelectionMode.directHistorical);
expect(selection.canonicalPath, '22');
});
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
@@ -34,6 +35,24 @@ class _FakeConnectionProvider extends ConnectionProvider {
} }
} }
class _ConnectableFakeConnectionProvider extends ConnectionProvider {
int connectCalls = 0;
@override
List<ScannedDevice> get scannedDevices => [
ScannedDevice(device: BluetoothDevice.fromId('test-device'), rssi: -55),
];
@override
String? get error => null;
@override
Future<bool> connect(BluetoothDevice device) async {
connectCalls += 1;
return true;
}
}
void main() { void main() {
testWidgets('BLE scan waits for explicit user action', (tester) async { testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider(); final connectionProvider = _FakeConnectionProvider();
@@ -64,4 +83,47 @@ void main() {
expect(connectionProvider.stopScanCalls, 1); expect(connectionProvider.stopScanCalls, 1);
expect(connectionProvider.startScanCalls, 1); expect(connectionProvider.startScanCalls, 1);
}); });
testWidgets('successful BLE connect closes the dialog immediately', (
tester,
) async {
final connectionProvider = _ConnectableFakeConnectionProvider();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => const ConnectionDialog(),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(ConnectionDialog), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle();
expect(connectionProvider.connectCalls, 1);
expect(find.byType(ConnectionDialog), findsNothing);
});
} }

View File

@@ -181,6 +181,44 @@ void main() {
} }
}); });
testWidgets('channel bubbles refresh to synced channel names', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'channel-name-refresh',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(61),
channelIdx: 3,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Team update',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
expect(find.text('Channel 3'), findsOneWidget);
expect(find.text('#slovenija'), findsNothing);
harness.channelsProvider.addOrUpdateChannel(
index: 3,
name: '#slovenija',
secret: Uint8List(16),
);
await tester.pumpAndSettle();
expect(find.text('#slovenija'), findsOneWidget);
expect(find.text('Channel 3'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble detects and opens links', (tester) async { testWidgets('message bubble detects and opens links', (tester) async {
final harness = await _TestHarness.create(); final harness = await _TestHarness.create();
try { try {

View File

@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart'; import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart';
void main() { void main() {
@@ -31,13 +33,18 @@ void main() {
); );
} }
Future<void> pumpSheet(WidgetTester tester) async { Future<void> pumpSheet(
final channel = buildContact( WidgetTester tester, {
name: 'Ops', List<Contact>? contacts,
type: ContactType.channel, List<Contact>? channels,
secondByte: 3, MessagesProvider? messagesProvider,
); bool showAllOption = true,
final contact = buildContact(name: 'John Smith', type: ContactType.chat); }) async {
final resolvedChannels =
channels ??
[buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3)];
final resolvedContacts =
contacts ?? [buildContact(name: 'John Smith', type: ContactType.chat)];
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@@ -45,15 +52,17 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold( home: Scaffold(
body: RecipientSelectorSheet( body: RecipientSelectorSheet(
contacts: [contact], contacts: resolvedContacts,
rooms: const [], rooms: const [],
channels: [channel], channels: resolvedChannels,
unreadCount: 11, unreadCount: 11,
unreadCountsByPublicKey: { unreadCountsByPublicKey: {
channel.publicKeyHex: 7, for (final channel in resolvedChannels) channel.publicKeyHex: 7,
contact.publicKeyHex: 3, for (final contact in resolvedContacts) contact.publicKeyHex: 3,
}, },
currentDestinationType: 'all', currentDestinationType: 'all',
showAllOption: showAllOption,
messagesProvider: messagesProvider,
onSelect: (selectedContact, destinationType) {}, onSelect: (selectedContact, destinationType) {},
), ),
), ),
@@ -150,4 +159,88 @@ void main() {
expect(charlieY, lessThan(bravoY)); expect(charlieY, lessThan(bravoY));
expect(bravoY, lessThan(alphaY)); expect(bravoY, lessThan(alphaY));
}); });
testWidgets('shows channel activity and participants instead of raw ids', (
tester,
) async {
final channel = buildContact(
name: 'Ops',
type: ContactType.channel,
secondByte: 3,
);
final messagesProvider = MessagesProvider()
..addMessage(
Message(
id: 'channel-activity',
messageType: MessageType.channel,
senderName: 'Radio Alpha',
channelIdx: 3,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp:
DateTime.now()
.subtract(const Duration(minutes: 5))
.millisecondsSinceEpoch ~/
1000,
text: 'status update',
receivedAt: DateTime.now().subtract(const Duration(minutes: 5)),
),
);
await pumpSheet(
tester,
contacts: const [],
channels: [channel],
messagesProvider: messagesProvider,
showAllOption: false,
);
expect(
find.byKey(Key('channel-participants-${channel.publicKeyHex}')),
findsOneWidget,
);
expect(find.text('Radio Alpha'), findsNothing);
expect(find.text('5m ago'), findsOneWidget);
expect(find.textContaining('Channel 3'), findsNothing);
expect(find.text(channel.publicKeyShort.toUpperCase()), findsNothing);
});
testWidgets('shows contact activity instead of the public key', (
tester,
) async {
final contact = buildContact(
name: 'John Smith',
type: ContactType.chat,
secondByte: 4,
);
final messagesProvider = MessagesProvider()
..addMessage(
Message(
id: 'contact-activity',
messageType: MessageType.contact,
recipientPublicKey: contact.publicKey,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp:
DateTime.now()
.subtract(const Duration(hours: 2))
.millisecondsSinceEpoch ~/
1000,
text: 'check-in',
receivedAt: DateTime.now().subtract(const Duration(hours: 2)),
),
);
await pumpSheet(
tester,
contacts: [contact],
channels: const [],
messagesProvider: messagesProvider,
showAllOption: false,
);
expect(find.text('John Smith'), findsOneWidget);
expect(find.text('2h ago'), findsOneWidget);
expect(find.text(contact.publicKeyShort), findsNothing);
});
} }

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart';
@@ -140,6 +142,8 @@ void main() {
}); });
testWidgets('renders MeshCore custom weather metrics', (tester) async { testWidgets('renders MeshCore custom weather metrics', (tester) async {
tester.platformDispatcher.localeTestValue = const Locale('sl', 'SI');
addTearDown(tester.platformDispatcher.clearLocaleTestValue);
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[0] = 0x46; publicKey[0] = 0x46;
final contact = Contact( final contact = Contact(
@@ -185,11 +189,130 @@ void main() {
expect(find.text('Wind gust'), findsOneWidget); expect(find.text('Wind gust'), findsOneWidget);
expect(find.text('Dew point'), findsOneWidget); expect(find.text('Dew point'), findsOneWidget);
expect(find.text('Rain'), findsOneWidget); expect(find.text('Rain'), findsOneWidget);
expect(find.text('3.7 m/s'), findsOneWidget); expect(find.textContaining('km/h'), findsOneWidget);
expect(find.textContaining('m/s'), findsNothing);
expect(find.text('2°C'), findsOneWidget); expect(find.text('2°C'), findsOneWidget);
expect(find.text('12.3 mm'), findsOneWidget); expect(find.text('12.3 mm'), findsOneWidget);
}); });
testWidgets('renders speed in mph for imperial system locale', (
tester,
) async {
tester.platformDispatcher.localeTestValue = const Locale('en', 'US');
addTearDown(tester.platformDispatcher.clearLocaleTestValue);
final publicKey = Uint8List(32);
publicKey[0] = 0x4A;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'speed_2': 3.7},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'extra:speed_2'},
fieldSpans: sensorFullWidthFieldSpans(const {'extra:speed_2'}),
),
),
),
);
expect(find.textContaining('mph'), findsOneWidget);
expect(find.textContaining('m/s'), findsNothing);
});
testWidgets('gps preview map recenters when telemetry location changes', (
tester,
) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x4B;
Contact buildGpsContact(double latitude, double longitude) => Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'GPS Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
gpsLocation: LatLng(latitude, longitude),
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
var contact = buildGpsContact(46.0569, 14.5058);
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Column(
children: [
ElevatedButton(
onPressed: () {
setState(() {
contact = buildGpsContact(46.1000, 14.6000);
});
},
child: const Text('Update'),
),
SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'gps'},
fieldSpans: sensorFullWidthFieldSpans(const {'gps'}),
),
],
),
),
);
},
),
);
final initialMap = tester.widget<flutter_map.FlutterMap>(
find.byType(flutter_map.FlutterMap).first,
);
final initialCenter = initialMap.mapController!.camera.center;
expect(initialCenter.latitude, closeTo(46.0569, 0.0001));
expect(initialCenter.longitude, closeTo(14.5058, 0.0001));
await tester.tap(find.text('Update'));
await tester.pump();
final updatedMap = tester.widget<flutter_map.FlutterMap>(
find.byType(flutter_map.FlutterMap).first,
);
final updatedCenter = updatedMap.mapController!.camera.center;
expect(updatedCenter.latitude, closeTo(46.1000, 0.0001));
expect(updatedCenter.longitude, closeTo(14.6000, 0.0001));
});
testWidgets('renders generic percentage separately from UV for weather payload', (tester) async { testWidgets('renders generic percentage separately from UV for weather payload', (tester) async {
tester.view.physicalSize = const Size(1600, 2600); tester.view.physicalSize = const Size(1600, 2600);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
@@ -415,8 +538,11 @@ void main() {
), ),
); );
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
scaffoldMessengerKey: scaffoldKey,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold( home: Scaffold(
@@ -431,15 +557,43 @@ void main() {
); );
await tester.tap(find.byIcon(Icons.more_vert)); await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle(); await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Copy raw response'), findsOneWidget); expect(find.text('Copy raw response'), findsOneWidget);
// Capture clipboard writes via the test platform channel mock.
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(MethodCall call) async {
if (call.method == 'Clipboard.setData') {
final args = call.arguments as Map<dynamic, dynamic>;
clipboardText = args['text'] as String?;
}
if (call.method == 'Clipboard.getData') {
return <String, dynamic>{'text': clipboardText};
}
return null;
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
await tester.tap(find.text('Copy raw response')); await tester.tap(find.text('Copy raw response'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); expect(clipboardText, '01 67 00 d7');
expect(clipboardData?.text, '01 67 00 d7');
expect(find.text('Raw response copied'), findsOneWidget); expect(find.text('Raw response copied'), findsOneWidget);
// Clear the SnackBar to prevent its timer from blocking teardown.
scaffoldKey.currentState?.clearSnackBars();
await tester.pump(const Duration(seconds: 5));
await tester.pump(const Duration(seconds: 5));
}); });
} }