Compare commits

...

36 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
Janez T
2b42e7dd15 feat: Add compass and region support 2026-03-22 19:04:43 +01:00
Janez T
7ea6334f02 fix: Keep composer and sensor sheets closable #123 2026-03-22 18:27:57 +01:00
Janez Troha
6f0a66dc68 Merge pull request #35 from dz0ny/feat/channel-datagrams
feat: Add repeater discovery onboarding flow
2026-03-22 18:06:59 +01:00
Janez T
a26ae96acd feat: Add repeater discovery onboarding flow 2026-03-22 18:06:24 +01:00
Janez Troha
a6dc137e51 Merge pull request #34 from dz0ny/feat/channel-datagrams
feat: Add channel datagrams #1928
2026-03-22 17:08:34 +01:00
Janez Troha
c2501442e3 Merge pull request #33 from dz0ny/feat/update-changelog-with-highlights
Update changelog with weekly highlights and PR links
2026-03-22 17:08:13 +01:00
Janez T
71eac21df9 feat: Add channel datagrams #1928
ref:
2026-03-22 16:46:58 +01:00
Janez T
c299349b6a chore: Update changelog #0 2026-03-22 16:02:17 +01:00
Janez T
ab05f13de9 fix: Remove contact trails from map #123 2026-03-22 15:48:55 +01:00
Janez T
0e61dc5bf1 fix: Revert Darwin BLE override 2026-03-22 13:57:14 +01:00
Janez T
d45924d7b4 fix: Vendor Darwin BLE override 2026-03-22 13:56:23 +01:00
Janez T
a24e699e22 fix: Refresh MeshCore lock #31
ref: dz0ny/meshcore-sar#31
2026-03-22 09:49:16 +01:00
Janez T
7486837ad0 fix: Compact recipient selector #0 2026-03-22 09:37:53 +01:00
Janez T
059125c714 fix: Restore telemetry refresh behavior
ref:
2026-03-22 09:19:33 +01:00
Janez T
9279ff2158 chore: Bump iOS build number #123 2026-03-21 20:45:09 +01:00
Janez T
dc297b0b9f fix: Tighten device settings layout 2026-03-21 20:44:29 +01:00
Janez T
2d24481aba feat: Add MET history isolation 2026-03-21 19:22:43 +01:00
Janez T
20a1df94eb fix: Support MeshCore message links #123 2026-03-21 18:36:56 +01:00
Janez T
5cc81bef65 feat: Show self sensor default 2026-03-20 19:08:24 +01:00
97 changed files with 12872 additions and 3532 deletions

View File

@@ -193,6 +193,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
- name: Install macOS native build tools
run: brew install automake libtool
- name: Build macOS release - name: Build macOS release
run: flutter build macos --release run: flutter build macos --release
@@ -298,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

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2026-03-15 to 2026-03-22
- Device connectivity and routing: added Android USB serial companion support, serial compass support, GPS module controls, per-contact path size selection, and favourites support via firmware flags.
- Location and map intelligence: added RSSI-based repeater trilateration and contact location estimates, isolated MET history, matched the official advert import/discovery flow, and refreshed map presentation by removing contact trails.
- Telemetry and device info: surfaced self telemetry and sensor defaults, opened device info from signal and battery taps, improved signal chip behavior, fixed duplicate telemetry delivery, and restored telemetry refresh behavior.
- Contacts and messaging: added meshcore:// contact sharing and repeater neighbour views, supported MeshCore message links, fixed DM retry behavior, corrected room poster naming, and compacted the recipient selector.
- UI polish and platform upkeep: improved device settings layout, tightened sensor and contact presentation, expanded localization coverage, and refreshed platform dependencies and build numbers.
## Key PRs
- [#26](https://github.com/dz0ny/meshcore-sar/pull/26) Merge pull request #26 from `dz0ny/feat/sensor-telemetry-preview`
## 2026-03-08 to 2026-03-14 ## 2026-03-08 to 2026-03-14
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides. - Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.

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 = 122; 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 = 122; 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 = 122; 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 = 122; 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 = 122; 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 = 122; 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>122</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.000814"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000201">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.353323"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.700922">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="115.148883"> <testcase classname="fastlane.lanes" name="2: build_app" time="102.603385">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="204.902574"> <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"
@@ -2285,7 +2309,7 @@
"@change": { "@change": {
"description": "Generic change action" "description": "Generic change action"
}, },
"wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.", "wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.",
"@wizardOverviewDescription": { "@wizardOverviewDescription": {
"description": "Updated onboarding overview description" "description": "Updated onboarding overview description"
}, },
@@ -2297,7 +2321,7 @@
"@wizardOverviewFeature2": { "@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2" "description": "Updated onboarding overview feature 2"
}, },
"wizardOverviewFeature3": "Connect over BLE or TCP, then manage the companion radio from inside the app.", "wizardOverviewFeature3": "Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.",
"@wizardOverviewFeature3": { "@wizardOverviewFeature3": {
"description": "Updated onboarding overview feature 3" "description": "Updated onboarding overview feature 3"
}, },
@@ -2410,11 +2434,11 @@
"@wizardNetworkTitle": { "@wizardNetworkTitle": {
"description": "Updated onboarding network page title" "description": "Updated onboarding network page title"
}, },
"wizardNetworkDescription": "The Contacts tab organizes the network you discover and the routes you learn over time.", "wizardNetworkDescription": "The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.",
"@wizardNetworkDescription": { "@wizardNetworkDescription": {
"description": "Updated onboarding network page description" "description": "Updated onboarding network page description"
}, },
"wizardNetworkFeature1": "Review team members, repeaters, rooms, channels, and pending adverts in one list.", "wizardNetworkFeature1": "Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.",
"@wizardNetworkFeature1": { "@wizardNetworkFeature1": {
"description": "Updated onboarding network feature 1" "description": "Updated onboarding network feature 1"
}, },
@@ -2422,7 +2446,7 @@
"@wizardNetworkFeature2": { "@wizardNetworkFeature2": {
"description": "Updated onboarding network feature 2" "description": "Updated onboarding network feature 2"
}, },
"wizardNetworkFeature3": "Create channels and manage network destinations without leaving the app.", "wizardNetworkFeature3": "Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.",
"@wizardNetworkFeature3": { "@wizardNetworkFeature3": {
"description": "Updated onboarding network feature 3" "description": "Updated onboarding network feature 3"
}, },
@@ -2462,10 +2486,18 @@
"@wizardToolsFeature2": { "@wizardToolsFeature2": {
"description": "Updated onboarding tools feature 2" "description": "Updated onboarding tools feature 2"
}, },
"wizardToolsFeature3": "Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.", "wizardToolsFeature3": "Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.",
"@wizardToolsFeature3": { "@wizardToolsFeature3": {
"description": "Updated onboarding tools feature 3" "description": "Updated onboarding tools feature 3"
}, },
"postConnectDiscoveryTitle": "Discover repeaters now?",
"@postConnectDiscoveryTitle": {
"description": "Title for the prompt shown after a successful device connection"
},
"postConnectDiscoveryDescription": "Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.",
"@postConnectDiscoveryDescription": {
"description": "Body text for the prompt shown after a successful device connection"
},
"contactInSensors": "In Sensors", "contactInSensors": "In Sensors",
"@contactInSensors": { "@contactInSensors": {
"description": "Contact actions label when already in sensors" "description": "Contact actions label when already in sensors"
@@ -2770,5 +2802,24 @@
"captured": "Captured", "captured": "Captured",
"size": "Size", "size": "Size",
"noCustomChannelsToClear": "No custom channels to clear.", "noCustomChannelsToClear": "No custom channels to clear.",
"noDeviceContactsToClear": "No device contacts to clear." "noDeviceContactsToClear": "No device contacts to clear.",
"setRegionScope": "Set region scope",
"regionScope": "Region scope",
"regionScopeNone": "None (global)",
"clearRegionScope": "Clear scope",
"regionScopeWarning": "Only repeaters allowing this region will forward.",
"discoverRegions": "Discover from repeaters",
"discoveringRegions": "Discovering regions...",
"enterRegionName": "Region name (e.g. auckland)",
"noRegionsFound": "No regions found on this repeater.",
"regionScopeSet": "Region scope set to {name}",
"@regionScopeSet": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"regionScopeCleared": "Region scope cleared"
} }

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:
@@ -3219,7 +3255,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview description /// Updated onboarding overview description
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.'** /// **'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.'**
String get wizardOverviewDescription; String get wizardOverviewDescription;
/// Updated onboarding overview feature 1 /// Updated onboarding overview feature 1
@@ -3237,7 +3273,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview feature 3 /// Updated onboarding overview feature 3
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Connect over BLE or TCP, then manage the companion radio from inside the app.'** /// **'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.'**
String get wizardOverviewFeature3; String get wizardOverviewFeature3;
/// Updated onboarding messaging page title /// Updated onboarding messaging page title
@@ -3387,13 +3423,13 @@ abstract class AppLocalizations {
/// Updated onboarding network page description /// Updated onboarding network page description
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'The Contacts tab organizes the network you discover and the routes you learn over time.'** /// **'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.'**
String get wizardNetworkDescription; String get wizardNetworkDescription;
/// Updated onboarding network feature 1 /// Updated onboarding network feature 1
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Review team members, repeaters, rooms, channels, and pending adverts in one list.'** /// **'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.'**
String get wizardNetworkFeature1; String get wizardNetworkFeature1;
/// Updated onboarding network feature 2 /// Updated onboarding network feature 2
@@ -3405,7 +3441,7 @@ abstract class AppLocalizations {
/// Updated onboarding network feature 3 /// Updated onboarding network feature 3
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Create channels and manage network destinations without leaving the app.'** /// **'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.'**
String get wizardNetworkFeature3; String get wizardNetworkFeature3;
/// Updated onboarding map page title /// Updated onboarding map page title
@@ -3465,9 +3501,21 @@ abstract class AppLocalizations {
/// Updated onboarding tools feature 3 /// Updated onboarding tools feature 3
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'** /// **'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'**
String get wizardToolsFeature3; String get wizardToolsFeature3;
/// Title for the prompt shown after a successful device connection
///
/// In en, this message translates to:
/// **'Discover repeaters now?'**
String get postConnectDiscoveryTitle;
/// Body text for the prompt shown after a successful device connection
///
/// In en, this message translates to:
/// **'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'**
String get postConnectDiscoveryDescription;
/// Contact actions label when already in sensors /// Contact actions label when already in sensors
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4997,6 +5045,72 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'No device contacts to clear.'** /// **'No device contacts to clear.'**
String get noDeviceContactsToClear; String get noDeviceContactsToClear;
/// No description provided for @setRegionScope.
///
/// In en, this message translates to:
/// **'Set region scope'**
String get setRegionScope;
/// No description provided for @regionScope.
///
/// In en, this message translates to:
/// **'Region scope'**
String get regionScope;
/// No description provided for @regionScopeNone.
///
/// In en, this message translates to:
/// **'None (global)'**
String get regionScopeNone;
/// No description provided for @clearRegionScope.
///
/// In en, this message translates to:
/// **'Clear scope'**
String get clearRegionScope;
/// No description provided for @regionScopeWarning.
///
/// In en, this message translates to:
/// **'Only repeaters allowing this region will forward.'**
String get regionScopeWarning;
/// No description provided for @discoverRegions.
///
/// In en, this message translates to:
/// **'Discover from repeaters'**
String get discoverRegions;
/// No description provided for @discoveringRegions.
///
/// In en, this message translates to:
/// **'Discovering regions...'**
String get discoveringRegions;
/// No description provided for @enterRegionName.
///
/// In en, this message translates to:
/// **'Region name (e.g. auckland)'**
String get enterRegionName;
/// No description provided for @noRegionsFound.
///
/// In en, this message translates to:
/// **'No regions found on this repeater.'**
String get noRegionsFound;
/// No description provided for @regionScopeSet.
///
/// In en, this message translates to:
/// **'Region scope set to {name}'**
String regionScopeSet(String name);
/// No description provided for @regionScopeCleared.
///
/// In en, this message translates to:
/// **'Region scope cleared'**
String get regionScopeCleared;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

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';
@@ -1898,6 +1916,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.'; 'Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'In Sensoren'; String get contactInSensors => 'In Sensoren';
@@ -2698,4 +2723,40 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.'; String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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 => 'Συνδεδεμένος';
@@ -1905,6 +1923,13 @@ class AppLocalizationsEl extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.'; 'Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Στους αισθητήρες'; String get contactInSensors => 'Στους αισθητήρες';
@@ -2700,4 +2725,40 @@ class AppLocalizationsEl extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.'; String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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';
@@ -1717,7 +1735,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewDescription => String get wizardOverviewDescription =>
'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.'; 'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.';
@override @override
String get wizardOverviewFeature1 => String get wizardOverviewFeature1 =>
@@ -1729,7 +1747,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewFeature3 => String get wizardOverviewFeature3 =>
'Connect over BLE or TCP, then manage the companion radio from inside the app.'; 'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.';
@override @override
String get wizardMessagingTitle => 'Messaging and Field Reports'; String get wizardMessagingTitle => 'Messaging and Field Reports';
@@ -1819,11 +1837,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardNetworkDescription => String get wizardNetworkDescription =>
'The Contacts tab organizes the network you discover and the routes you learn over time.'; 'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.';
@override @override
String get wizardNetworkFeature1 => String get wizardNetworkFeature1 =>
'Review team members, repeaters, rooms, channels, and pending adverts in one list.'; 'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.';
@override @override
String get wizardNetworkFeature2 => String get wizardNetworkFeature2 =>
@@ -1831,7 +1849,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardNetworkFeature3 => String get wizardNetworkFeature3 =>
'Create channels and manage network destinations without leaving the app.'; 'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.';
@override @override
String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry'; String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry';
@@ -1869,7 +1887,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'; 'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'In Sensors'; String get contactInSensors => 'In Sensors';
@@ -2662,4 +2687,40 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'No device contacts to clear.'; String get noDeviceContactsToClear => 'No device contacts to clear.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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';
@@ -1901,6 +1919,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.'; 'Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'En Sensores'; String get contactInSensors => 'En Sensores';
@@ -2702,4 +2727,40 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'No hay contactos del dispositivo que borrar.'; 'No hay contactos del dispositivo que borrar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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é';
@@ -1905,6 +1923,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.'; 'Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Dans Capteurs'; String get contactInSensors => 'Dans Capteurs';
@@ -2706,4 +2731,40 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Aucun contact de l\'appareil à effacer.'; 'Aucun contact de l\'appareil à effacer.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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';
@@ -1881,6 +1899,13 @@ class AppLocalizationsHr extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.'; 'Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'U Senzorima'; String get contactInSensors => 'U Senzorima';
@@ -2675,4 +2700,40 @@ class AppLocalizationsHr extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.'; String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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';
@@ -1896,6 +1914,13 @@ class AppLocalizationsIt extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.'; 'Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Nei Sensori'; String get contactInSensors => 'Nei Sensori';
@@ -2692,4 +2717,40 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Nessun contatto dispositivo da cancellare.'; 'Nessun contatto dispositivo da cancellare.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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';
@@ -1893,6 +1911,13 @@ class AppLocalizationsPl extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.'; 'Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'W Czujnikach'; String get contactInSensors => 'W Czujnikach';
@@ -2689,4 +2714,40 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Brak kontaktów urządzenia do wyczyszczenia.'; 'Brak kontaktów urządzenia do wyczyszczenia.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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';
@@ -1900,6 +1918,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.'; 'Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Nos Sensores'; String get contactInSensors => 'Nos Sensores';
@@ -2700,4 +2725,40 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Sem contactos do dispositivo para limpar.'; 'Sem contactos do dispositivo para limpar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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 => 'Вход выполнен';
@@ -1890,6 +1908,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.'; 'Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'В датчиках'; String get contactInSensors => 'В датчиках';
@@ -2685,4 +2710,40 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.'; String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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';
@@ -1881,6 +1899,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.'; 'Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'V senzorjih'; String get contactInSensors => 'V senzorjih';
@@ -2675,4 +2700,40 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.'; String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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ı';
@@ -1883,6 +1901,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.'; 'Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'Sensörlerde'; String get contactInSensors => 'Sensörlerde';
@@ -2673,4 +2698,40 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.'; String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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 => 'Увійшли';
@@ -1896,6 +1914,13 @@ class AppLocalizationsUk extends AppLocalizations {
String get wizardToolsFeature3 => String get wizardToolsFeature3 =>
'Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.'; 'Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => 'У датчиках'; String get contactInSensors => 'У датчиках';
@@ -2696,4 +2721,40 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => String get noDeviceContactsToClear =>
'Немає контактів пристрою для очищення.'; 'Немає контактів пристрою для очищення.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

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 => '已登录';
@@ -1796,6 +1814,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。'; String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。';
@override
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
@override
String get postConnectDiscoveryDescription =>
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
@override @override
String get contactInSensors => '在传感器中'; String get contactInSensors => '在传感器中';
@@ -2568,4 +2593,40 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get noDeviceContactsToClear => '没有设备联系人可清除。'; String get noDeviceContactsToClear => '没有设备联系人可清除。';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
} }

View File

@@ -1 +1,287 @@
{} {
"de": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"el": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"es": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"fr": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"hr": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"it": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pl": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pt": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"ru": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"sl": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"tr": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"uk": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"zh": [
"autoSend",
"autoSendDescription",
"sendDirect",
"sendDirectDescription",
"sendFlood",
"sendFloodDescription",
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
]
}

View File

@@ -372,6 +372,8 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
mapProvider: mapProvider, mapProvider: mapProvider,
drawingProvider: context.read<DrawingProvider>(), drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(), channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
appProvider: appProvider, appProvider: appProvider,
), ),
), ),

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

@@ -263,7 +263,6 @@ class MapWorkspaceProfileSection {
final bool? showKrasFireZonesOverlay; final bool? showKrasFireZonesOverlay;
final bool? showPlaceNamesOverlay; final bool? showPlaceNamesOverlay;
final bool? showMunicipalityBordersOverlay; final bool? showMunicipalityBordersOverlay;
final bool? showAllContactTrails;
final bool? hideRepeatersOnMap; final bool? hideRepeatersOnMap;
const MapWorkspaceProfileSection({ const MapWorkspaceProfileSection({
@@ -284,7 +283,6 @@ class MapWorkspaceProfileSection {
this.showKrasFireZonesOverlay, this.showKrasFireZonesOverlay,
this.showPlaceNamesOverlay, this.showPlaceNamesOverlay,
this.showMunicipalityBordersOverlay, this.showMunicipalityBordersOverlay,
this.showAllContactTrails,
this.hideRepeatersOnMap, this.hideRepeatersOnMap,
}); });
@@ -306,7 +304,6 @@ class MapWorkspaceProfileSection {
showKrasFireZonesOverlay == null && showKrasFireZonesOverlay == null &&
showPlaceNamesOverlay == null && showPlaceNamesOverlay == null &&
showMunicipalityBordersOverlay == null && showMunicipalityBordersOverlay == null &&
showAllContactTrails == null &&
hideRepeatersOnMap == null; hideRepeatersOnMap == null;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -327,7 +324,6 @@ class MapWorkspaceProfileSection {
'showKrasFireZonesOverlay': showKrasFireZonesOverlay, 'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
'showPlaceNamesOverlay': showPlaceNamesOverlay, 'showPlaceNamesOverlay': showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay, 'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
'showAllContactTrails': showAllContactTrails,
'hideRepeatersOnMap': hideRepeatersOnMap, 'hideRepeatersOnMap': hideRepeatersOnMap,
}; };
@@ -356,7 +352,6 @@ class MapWorkspaceProfileSection {
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?, showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
showMunicipalityBordersOverlay: showMunicipalityBordersOverlay:
json['showMunicipalityBordersOverlay'] as bool?, json['showMunicipalityBordersOverlay'] as bool?,
showAllContactTrails: json['showAllContactTrails'] as bool?,
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?, hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
); );
} }

View File

@@ -71,6 +71,7 @@ class DeviceInfo {
final bool? autoAddRoomServers; final bool? autoAddRoomServers;
final bool? autoAddSensors; final bool? autoAddSensors;
final bool? autoAddOverwriteOldest; final bool? autoAddOverwriteOldest;
final int? autoAddMaxHops;
final int? radioFreq; final int? radioFreq;
final int? radioBw; final int? radioBw;
final int? radioSf; final int? radioSf;
@@ -95,6 +96,7 @@ class DeviceInfo {
// Repeat mode (firmware v9+) // Repeat mode (firmware v9+)
final bool? clientRepeat; final bool? clientRepeat;
final int? pathHashMode;
final bool? supportsSpectrumScan; final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz; final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz; final int? spectrumScanMaxKhz;
@@ -123,6 +125,7 @@ class DeviceInfo {
this.autoAddRoomServers, this.autoAddRoomServers,
this.autoAddSensors, this.autoAddSensors,
this.autoAddOverwriteOldest, this.autoAddOverwriteOldest,
this.autoAddMaxHops,
this.radioFreq, this.radioFreq,
this.radioBw, this.radioBw,
this.radioSf, this.radioSf,
@@ -139,6 +142,7 @@ class DeviceInfo {
this.manufacturerModel, this.manufacturerModel,
this.semanticVersion, this.semanticVersion,
this.clientRepeat, this.clientRepeat,
this.pathHashMode,
this.supportsSpectrumScan, this.supportsSpectrumScan,
this.spectrumScanMinKhz, this.spectrumScanMinKhz,
this.spectrumScanMaxKhz, this.spectrumScanMaxKhz,
@@ -254,6 +258,7 @@ class DeviceInfo {
bool? autoAddRoomServers, bool? autoAddRoomServers,
bool? autoAddSensors, bool? autoAddSensors,
bool? autoAddOverwriteOldest, bool? autoAddOverwriteOldest,
int? autoAddMaxHops,
int? radioFreq, int? radioFreq,
int? radioBw, int? radioBw,
int? radioSf, int? radioSf,
@@ -270,6 +275,7 @@ class DeviceInfo {
String? manufacturerModel, String? manufacturerModel,
String? semanticVersion, String? semanticVersion,
bool? clientRepeat, bool? clientRepeat,
int? pathHashMode,
bool? supportsSpectrumScan, bool? supportsSpectrumScan,
int? spectrumScanMinKhz, int? spectrumScanMinKhz,
int? spectrumScanMaxKhz, int? spectrumScanMaxKhz,
@@ -299,6 +305,7 @@ class DeviceInfo {
autoAddSensors: autoAddSensors ?? this.autoAddSensors, autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest: autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest, autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
radioFreq: radioFreq ?? this.radioFreq, radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw, radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf, radioSf: radioSf ?? this.radioSf,
@@ -315,6 +322,7 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel, manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion, semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat, clientRepeat: clientRepeat ?? this.clientRepeat,
pathHashMode: pathHashMode ?? this.pathHashMode,
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan, supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz, spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz, spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,

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

@@ -2,7 +2,8 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart' show BufferReader; import 'package:meshcore_client/meshcore_client.dart'
show BufferReader, MeshCoreConstants;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart'; import 'connection_provider.dart';
import 'contacts_provider.dart'; import 'contacts_provider.dart';
@@ -211,6 +212,8 @@ class AppProvider with ChangeNotifier {
static const int _maxPacketRetryAttempts = 4; static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {}; final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {}; final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Map<int, VoicePacket>> _pendingChannelVoicePackets = {};
final Map<String, Map<int, ImagePacket>> _pendingChannelImageFragments = {};
final Map<String, Timer> _voiceMissingRetryTimers = {}; final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {}; final Map<String, Timer> _imageMissingRetryTimers = {};
@@ -1315,6 +1318,7 @@ class AppProvider with ChangeNotifier {
.toLowerCase(); .toLowerCase();
} }
voiceProvider.registerEnvelope(voiceEnvelope); voiceProvider.registerEnvelope(voiceEnvelope);
_replayPendingChannelVoicePackets(voiceEnvelope.sessionId);
enrichedMessage = enrichedMessage.copyWith( enrichedMessage = enrichedMessage.copyWith(
isVoice: true, isVoice: true,
voiceId: voiceEnvelope.sessionId, voiceId: voiceEnvelope.sessionId,
@@ -1352,6 +1356,7 @@ class AppProvider with ChangeNotifier {
.toLowerCase(); .toLowerCase();
} }
imageProvider.registerEnvelope(imageEnvelope); imageProvider.registerEnvelope(imageEnvelope);
_replayPendingChannelImageFragments(imageEnvelope.sessionId);
messagesProvider.addMessage( messagesProvider.addMessage(
enrichedMessage, enrichedMessage,
contactLookup: (name) { contactLookup: (name) {
@@ -1469,15 +1474,7 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); if (_handleFastGpsPayload(payload)) {
if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(
sender.publicKey.sublist(0, 6),
fastGpsPacket,
);
}
return; return;
} }
@@ -1614,66 +1611,44 @@ class AppProvider with ChangeNotifier {
return; return;
} }
if (ImagePacket.isImageBinary(payload)) { if (_handleIncomingImageBinaryPayload(payload)) {
final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId);
if (session == null && frag.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact image fragment without envelope '
'for session ${frag.sessionId}',
);
return;
}
imageProvider.addFragment(
session == null
? frag
: ImagePacket(
sessionId: frag.sessionId,
format: session.format,
index: frag.index,
total: session.total,
data: frag.data,
),
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_scheduleImageMissingRetry(
frag.sessionId,
justComplete: imageProvider.isComplete(frag.sessionId),
);
return; return;
} }
_handleIncomingVoiceBinaryPayload(payload);
if (!VoicePacket.isVoiceBinary(payload)) return;
final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final session = voiceProvider.session(pkt.sessionId);
if (session == null && pkt.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact voice packet without envelope '
'for session ${pkt.sessionId}',
);
return;
}
final justComplete = voiceProvider.addPacket(
session == null
? pkt
: VoicePacket(
sessionId: pkt.sessionId,
mode: session.mode,
index: pkt.index,
total: session.total,
codec2Data: pkt.codec2Data,
),
);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
}; };
connectionProvider.onChannelDataReceived =
(channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) {
if (dataType != MeshCoreConstants.dataTypeDev) {
debugPrint(
'📦 [AppProvider] Ignoring channel datagram type '
'0x${dataType.toRadixString(16).padLeft(4, '0')} on channel $channelIdx',
);
return;
}
if (_handleFastGpsPayload(payload)) {
return;
}
if (_handleIncomingImageBinaryPayload(
payload,
allowPreEnvelopeBuffer: true,
)) {
return;
}
if (_handleIncomingVoiceBinaryPayload(
payload,
allowPreEnvelopeBuffer: true,
)) {
return;
}
debugPrint(
'📦 [AppProvider] Unknown developer channel payload on channel '
'$channelIdx: ${payload.isNotEmpty ? payload.first : -1}',
);
};
connectionProvider.onControlDataReceived = connectionProvider.onControlDataReceived =
(payload, snrRaw, rssiDbm, pathLen) { (payload, snrRaw, rssiDbm, pathLen) {
_handleControlDataDiscovery( _handleControlDataDiscovery(
@@ -1786,6 +1761,7 @@ class AppProvider with ChangeNotifier {
return _prepareDirectMessageSend( return _prepareDirectMessageSend(
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: retryAttempt,
); );
}; };
@@ -1855,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,
@@ -1882,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, {
@@ -2056,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,
), ),
); );
} }
@@ -2553,12 +2594,10 @@ class AppProvider with ChangeNotifier {
try { try {
_isReconnectSyncInProgress = true; _isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false; _hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self await contactsProvider.prepareForDeviceContactSync(
// If already initialized (from early load), this will just filter out self-contact
// This must happen before getContacts to ensure proper filtering
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
channelsProvider.prepareForDeviceSync();
// Note: Device clock is automatically synced during connection in MeshCoreBleService // Note: Device clock is automatically synced during connection in MeshCoreBleService
// No need to sync it again here // No need to sync it again here
@@ -2634,10 +2673,14 @@ class AppProvider with ChangeNotifier {
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages', '🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
); );
await contactsProvider.initialize( await contactsProvider.prepareForDeviceContactSync(
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
channelsProvider.prepareForDeviceSync();
await connectionProvider.getContacts(); await connectionProvider.getContacts();
await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels,
);
final messageCount = await connectionProvider.syncAllMessages( final messageCount = await connectionProvider.syncAllMessages(
force: true, force: true,
@@ -2803,6 +2846,23 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final channelIdx = locationTrackingService.fastLocationChannelIdx;
if (channelIdx == null) {
return;
}
final hasTargetChannel = contactsProvider.channels.any(
(channel) =>
(channel.publicKey.length > 1 ? channel.publicKey[1] : 0) ==
channelIdx,
);
if (!hasTargetChannel) {
debugPrint(
'⚠️ [AppProvider] Fast GPS target channel $channelIdx is unavailable',
);
return;
}
final publicKey = connectionProvider.deviceInfo.publicKey; final publicKey = connectionProvider.deviceInfo.publicKey;
if (publicKey == null || publicKey.length < 6) { if (publicKey == null || publicKey.length < 6) {
return; return;
@@ -2819,16 +2879,46 @@ 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}', 'reason=$reason '
'sender=$senderKey6 '
'channel=$channelIdx '
'lat=${position.latitude} '
'lon=${position.longitude} '
'ts=${packet.timestampSeconds}',
); );
try { try {
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary()); await connectionProvider.sendChannelData(
channelIdx: channelIdx,
dataType: MeshCoreConstants.dataTypeDev,
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) {
@@ -3361,6 +3451,173 @@ class AppProvider with ChangeNotifier {
}); });
} }
bool _handleFastGpsPayload(Uint8List payload) {
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
if (fastGpsPacket == null) {
return false;
}
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(
sender.publicKey.sublist(0, 6),
fastGpsPacket,
);
}
return true;
}
bool _handleIncomingImageBinaryPayload(
Uint8List payload, {
bool allowPreEnvelopeBuffer = false,
}) {
if (!ImagePacket.isImageBinary(payload)) {
return false;
}
final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) {
return true;
}
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId);
if (session == null && frag.total < 1) {
if (allowPreEnvelopeBuffer) {
_pendingChannelImageFragments.putIfAbsent(
frag.sessionId,
() => <int, ImagePacket>{},
)[frag.index] = frag;
debugPrint(
'📷 [AppProvider] Buffered compact image fragment before envelope '
'for session ${frag.sessionId} index=${frag.index}',
);
} else {
debugPrint(
'⚠️ [AppProvider] Dropping compact image fragment without envelope '
'for session ${frag.sessionId}',
);
}
return true;
}
imageProvider.addFragment(
session == null
? frag
: ImagePacket(
sessionId: frag.sessionId,
format: session.format,
index: frag.index,
total: session.total,
data: frag.data,
),
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_scheduleImageMissingRetry(
frag.sessionId,
justComplete: imageProvider.isComplete(frag.sessionId),
);
return true;
}
bool _handleIncomingVoiceBinaryPayload(
Uint8List payload, {
bool allowPreEnvelopeBuffer = false,
}) {
if (!VoicePacket.isVoiceBinary(payload)) {
return false;
}
final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) {
return true;
}
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final session = voiceProvider.session(pkt.sessionId);
if (session == null && pkt.total < 1) {
if (allowPreEnvelopeBuffer) {
_pendingChannelVoicePackets.putIfAbsent(
pkt.sessionId,
() => <int, VoicePacket>{},
)[pkt.index] = pkt;
debugPrint(
'🎙️ [AppProvider] Buffered compact voice packet before envelope '
'for session ${pkt.sessionId} index=${pkt.index}',
);
} else {
debugPrint(
'⚠️ [AppProvider] Dropping compact voice packet without envelope '
'for session ${pkt.sessionId}',
);
}
return true;
}
final normalizedPacket = session == null
? pkt
: VoicePacket(
sessionId: pkt.sessionId,
mode: session.mode,
index: pkt.index,
total: session.total,
codec2Data: pkt.codec2Data,
);
final justComplete = voiceProvider.addPacket(normalizedPacket);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
_handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete);
return true;
}
void _replayPendingChannelVoicePackets(String sessionId) {
final pending = _pendingChannelVoicePackets.remove(sessionId);
final session = voiceProvider.session(sessionId);
if (pending == null || pending.isEmpty || session == null) {
return;
}
final indices = pending.keys.toList()..sort();
for (final index in indices) {
final packet = pending[index]!;
final normalizedPacket = VoicePacket(
sessionId: packet.sessionId,
mode: session.mode,
index: packet.index,
total: session.total,
codec2Data: packet.codec2Data,
);
final justComplete = voiceProvider.addPacket(normalizedPacket);
_scheduleVoiceMissingRetry(sessionId, justComplete: justComplete);
_handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete);
}
}
void _replayPendingChannelImageFragments(String sessionId) {
final pending = _pendingChannelImageFragments.remove(sessionId);
final session = imageProvider.session(sessionId);
if (pending == null || pending.isEmpty || session == null) {
return;
}
final indices = pending.keys.toList()..sort();
for (final index in indices) {
final fragment = pending[index]!;
final normalizedFragment = ImagePacket(
sessionId: fragment.sessionId,
format: session.format,
index: fragment.index,
total: session.total,
data: fragment.data,
);
imageProvider.addFragment(
normalizedFragment,
width: session.width,
height: session.height,
);
_scheduleImageMissingRetry(
sessionId,
justComplete: imageProvider.isComplete(sessionId),
);
}
}
/// Insert or update a voice placeholder message for binary raw-data packets. /// Insert or update a voice placeholder message for binary raw-data packets.
/// ///
/// Binary voice packets arrive without a chat message, so we synthesise one /// Binary voice packets arrive without a chat message, so we synthesise one
@@ -3642,6 +3899,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.getContacts(); await connectionProvider.getContacts();
// Sync all channels so refresh reflects the full device state. // Sync all channels so refresh reflects the full device state.
channelsProvider.prepareForDeviceSync();
await connectionProvider.syncChannels( await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels, maxChannels: connectionProvider.deviceInfo.maxChannels,
); );
@@ -3764,6 +4022,8 @@ class AppProvider with ChangeNotifier {
_imageMissingRetryAttempts.clear(); _imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear(); _voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear(); _imageSessionSenderKey6.clear();
_pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear();
_lowBatteryNotifiedNodeIds.clear(); _lowBatteryNotifiedNodeIds.clear();
notifyListeners(); notifyListeners();
} }
@@ -3873,6 +4133,8 @@ class AppProvider with ChangeNotifier {
for (final timer in _imageMissingRetryTimers.values) { for (final timer in _imageMissingRetryTimers.values) {
timer.cancel(); timer.cancel();
} }
_pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear();
super.dispose(); super.dispose();
} }
} }

View File

@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
int _selectedChannelIndex = 0; // Default to public channel int _selectedChannelIndex = 0; // Default to public channel
/// Get all channels /// Get all channels
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index)); List<Channel> get channels =>
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
/// Get a specific channel by index /// Get a specific channel by index
Channel? getChannel(int index) => _channels[index]; Channel? getChannel(int index) => _channels[index];
@@ -28,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,
@@ -54,12 +65,12 @@ class ChannelsProvider with ChangeNotifier {
void removeChannel(int index) { void removeChannel(int index) {
if (_channels.containsKey(index)) { if (_channels.containsKey(index)) {
_channels.remove(index); _channels.remove(index);
// If the deleted channel was selected, switch to public channel // If the deleted channel was selected, switch to public channel
if (_selectedChannelIndex == index) { if (_selectedChannelIndex == index) {
_selectedChannelIndex = 0; _selectedChannelIndex = 0;
} }
notifyListeners(); notifyListeners();
} }
} }
@@ -96,6 +107,13 @@ class ChannelsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Clear runtime channel state before a live device sync begins.
void prepareForDeviceSync() {
_channels.clear();
_selectedChannelIndex = 0;
notifyListeners();
}
/// Check if channels have been loaded /// Check if channels have been loaded
bool get hasChannels => _channels.isNotEmpty; bool get hasChannels => _channels.isNotEmpty;

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;
@@ -105,8 +122,6 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false; bool _isScanning = false;
bool get isScanning => _isScanning; bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
final Set<int> _pendingDeletedChannelIndices = <int>{}; final Set<int> _pendingDeletedChannelIndices = <int>{};
String? _error; String? _error;
@@ -174,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 =>
@@ -212,6 +229,15 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected; onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Function(
int channelIdx,
int pathLen,
int dataType,
Uint8List payload,
int snrRaw,
int? rssiDbm,
)?
onChannelDataReceived;
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)? Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
onControlDataReceived; onControlDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
@@ -463,10 +489,6 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onMessageWaiting = () { service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) { if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true; _pendingAutomaticMessageSync = true;
debugPrint( debugPrint(
@@ -533,6 +555,16 @@ class ConnectionProvider with ChangeNotifier {
service.onRawDataReceived = (payload, snrRaw, rssiDbm) => service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
onRawDataReceived?.call(payload, snrRaw, rssiDbm); onRawDataReceived?.call(payload, snrRaw, rssiDbm);
service.onChannelDataReceived =
(channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) =>
onChannelDataReceived?.call(
channelIdx,
pathLen,
dataType,
payload,
snrRaw,
rssiDbm,
);
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) => service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen); onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
@@ -547,10 +579,15 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?, manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?, semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?, clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?, pathHashMode: deviceInfo['pathHashMode'] as int?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
); );
// Clear any stale flood scope on connect (firmware v8+)
final fwVer = deviceInfo['firmwareVersion'] as int?;
if (fwVer != null && fwVer >= 8) {
clearFloodScope();
}
notifyListeners(); notifyListeners();
}; };
@@ -563,6 +600,9 @@ class ConnectionProvider with ChangeNotifier {
publicKey: selfInfo['publicKey'] as Uint8List?, publicKey: selfInfo['publicKey'] as Uint8List?,
advLat: selfInfo['advLat'] as int?, advLat: selfInfo['advLat'] as int?,
advLon: selfInfo['advLon'] as int?, advLon: selfInfo['advLon'] as int?,
multiAcks: selfInfo['multiAcks'] as int?,
advertLocPolicy: selfInfo['advertLocPolicy'] as int?,
telemetryModes: selfInfo['telemetryModes'] as int?,
manualAddContacts: selfInfo['manualAddContacts'] as bool?, manualAddContacts: selfInfo['manualAddContacts'] as bool?,
radioFreq: selfInfo['radioFreq'] as int?, radioFreq: selfInfo['radioFreq'] as int?,
radioBw: selfInfo['radioBw'] as int?, radioBw: selfInfo['radioBw'] as int?,
@@ -605,10 +645,15 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?, autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?, autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
); );
notifyListeners(); notifyListeners();
}; };
service.onTraceDataReceived = (nonce, hopCount, snrThere, snrBack) {
_handleTraceDataReceived(nonce, hopCount, snrThere, snrBack);
};
service.onTxActivity = () { service.onTxActivity = () {
_txActivity = true; _txActivity = true;
notifyListeners(); notifyListeners();
@@ -1719,6 +1764,7 @@ class ConnectionProvider with ChangeNotifier {
required int channelIdx, required int channelIdx,
required String text, required String text,
String? messageId, String? messageId,
Uint8List? floodScopeKey,
}) async { }) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -1731,10 +1777,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Channel: $channelIdx'); debugPrint(' Channel: $channelIdx');
debugPrint(' Text: $text'); debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId'); debugPrint(' MessageID: $messageId');
debugPrint(' FloodScope: ${floodScopeKey != null ? "set (${floodScopeKey.length}B)" : "none"}');
await _activeService.sendChannelMessage( await _activeService.sendChannelMessage(
channelIdx: channelIdx, channelIdx: channelIdx,
text: text, text: text,
floodScopeKey: floodScopeKey,
); );
debugPrint('✅ [ConnectionProvider] BLE send completed'); debugPrint('✅ [ConnectionProvider] BLE send completed');
@@ -1799,6 +1847,42 @@ class ConnectionProvider with ChangeNotifier {
); );
} }
/// Clear the flood scope on the device (firmware v8+).
Future<void> clearFloodScope() async {
if (!_activeService.isConnected) return;
try {
await _activeService.clearFloodScope();
} catch (e) {
debugPrint('⚠️ [ConnectionProvider] Failed to clear flood scope: $e');
}
}
Future<void> sendChannelData({
required int channelIdx,
required int dataType,
required Uint8List payload,
Uint8List? floodScopeKey,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.sendChannelData(
channelIdx: channelIdx,
dataType: dataType,
payload: payload,
floodScopeKey: floodScopeKey,
);
} catch (e) {
_error = 'Failed to send channel data: $e';
notifyListeners();
rethrow;
}
}
/// Request telemetry from contact /// Request telemetry from contact
/// ///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -1853,11 +1937,13 @@ class ConnectionProvider with ChangeNotifier {
return pendingPing; return pendingPing;
} }
final future = _runSmartPing( final future = _isSelfPublicKey(contactPublicKey)
contactPublicKey: contactPublicKey, ? _runSelfTelemetryPing(contactPublicKey)
hasPath: hasPath, : _runSmartPing(
onRetryWithFlooding: onRetryWithFlooding, contactPublicKey: contactPublicKey,
); hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future; _pendingSmartPings[pingKey] = future;
notifyListeners(); notifyListeners();
@@ -1944,10 +2030,117 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<PingResult> _runSelfTelemetryPing(Uint8List devicePublicKey) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return PingResult(success: false, usedFlooding: false, timedOut: true);
}
try {
final pingFuture = _pingTracker.trackPing(
publicKey: devicePublicKey,
wasDirectAttempt: true,
);
// Firmware treats a 4-byte telemetry request as "self telemetry".
await _activeService.requestTelemetry(Uint8List(0), zeroHop: true);
final gotResponse = await pingFuture;
return PingResult(
success: gotResponse,
usedFlooding: false,
timedOut: !gotResponse,
);
} catch (e) {
_error = 'Failed to request self telemetry: $e';
notifyListeners();
return PingResult(success: false, usedFlooding: false, timedOut: true);
}
}
/// 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('');
} }
bool _isSelfPublicKey(Uint8List publicKey) {
final selfKey = _deviceInfo.publicKey;
if (selfKey == null || selfKey.length != publicKey.length) {
return false;
}
for (var i = 0; i < publicKey.length; i++) {
if (selfKey[i] != publicKey[i]) {
return false;
}
}
return true;
}
/// Send binary request to contact (modern replacement for requestTelemetry) /// Send binary request to contact (modern replacement for requestTelemetry)
/// ///
/// Supports multiple request types: /// Supports multiple request types:
@@ -2188,43 +2381,6 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<SpectrumScanResult?> scanSpectrum({
required int startFrequencyKhz,
required int stopFrequencyKhz,
required int bandwidthKhz,
required int stepKhz,
required int dwellMs,
required int thresholdDb,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
_isSpectrumScanActive = true;
_activeService.setSpectrumScanActive(true);
notifyListeners();
return await _activeService.scanSpectrum(
startFrequencyKhz: startFrequencyKhz,
stopFrequencyKhz: stopFrequencyKhz,
bandwidthKhz: bandwidthKhz,
stepKhz: stepKhz,
dwellMs: dwellMs,
thresholdDb: thresholdDb,
);
} catch (e) {
_error = 'Failed to scan spectrum: $e';
notifyListeners();
return null;
} finally {
_isSpectrumScanActive = false;
_activeService.setSpectrumScanActive(false);
notifyListeners();
}
}
/// Set transmit power /// Set transmit power
Future<void> setTxPower(int powerDbm) async { Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -2292,6 +2448,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?, autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?, autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
); );
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -2303,6 +2460,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: null, autoAddRoomServers: null,
autoAddSensors: null, autoAddSensors: null,
autoAddOverwriteOldest: null, autoAddOverwriteOldest: null,
autoAddMaxHops: null,
); );
notifyListeners(); notifyListeners();
return; return;
@@ -2322,6 +2480,7 @@ class ConnectionProvider with ChangeNotifier {
required bool autoAddRoomServers, required bool autoAddRoomServers,
required bool autoAddSensors, required bool autoAddSensors,
required bool overwriteOldest, required bool overwriteOldest,
int maxHops = 0,
}) async { }) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -2336,6 +2495,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers, autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors, autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest, overwriteOldest: overwriteOldest,
maxHops: maxHops,
); );
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
autoAddUsers: autoAddUsers, autoAddUsers: autoAddUsers,
@@ -2343,6 +2503,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers, autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors, autoAddSensors: autoAddSensors,
autoAddOverwriteOldest: overwriteOldest, autoAddOverwriteOldest: overwriteOldest,
autoAddMaxHops: maxHops,
); );
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -2351,6 +2512,23 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> setPathHashMode(int mode) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.setPathHashMode(mode);
_deviceInfo = _deviceInfo.copyWith(pathHashMode: mode);
notifyListeners();
} catch (e) {
_error = 'Failed to set path hash mode: $e';
notifyListeners();
}
}
/// Export a contact as a meshcore:// share URL. /// Export a contact as a meshcore:// share URL.
/// Pass null to export self. /// Pass null to export self.
Future<String?> exportContactUrl(Uint8List? publicKey) async { Future<String?> exportContactUrl(Uint8List? publicKey) async {
@@ -2392,7 +2570,6 @@ class ConnectionProvider with ChangeNotifier {
} }
Future<void> refreshDeviceInfo() async { Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
notifyListeners(); notifyListeners();
@@ -2440,14 +2617,8 @@ class ConnectionProvider with ChangeNotifier {
Future<void> requestSelfTelemetry() async { Future<void> requestSelfTelemetry() async {
if (!_activeService.isConnected) return; if (!_activeService.isConnected) return;
try { try {
// Request own telemetry by sending telemetry req with zero-length key // Firmware expects a 4-byte CMD_SEND_TELEMETRY_REQ frame for "self".
final deviceKey = _deviceInfo.publicKey; await _activeService.requestTelemetry(Uint8List(0), zeroHop: true);
if (deviceKey != null) {
await _activeService.requestTelemetry(
Uint8List.fromList(deviceKey),
zeroHop: true,
);
}
} catch (e) { } catch (e) {
debugPrint('⚠️ [Provider] requestSelfTelemetry failed: $e'); debugPrint('⚠️ [Provider] requestSelfTelemetry failed: $e');
} }
@@ -2471,7 +2642,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue /// Sync messages from device queue
/// Call this repeatedly until no more messages are available /// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async { Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers // Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Another sync (single or loop) is in progress // Another sync (single or loop) is in progress
@@ -2510,10 +2680,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device /// Sync all waiting messages from device
Future<int> syncAllMessages({bool force = false}) async { Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) { if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true; _pendingAutomaticMessageSync = true;
debugPrint( debugPrint(
@@ -2801,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

@@ -7,6 +7,7 @@ import '../models/contact_group.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart'; import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart'; import '../services/contact_storage_service.dart';
import '../services/profiles_feature_service.dart';
import '../utils/fast_gps_packet.dart'; import '../utils/fast_gps_packet.dart';
import '../utils/rssi_location_estimator.dart'; import '../utils/rssi_location_estimator.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
@@ -126,11 +127,13 @@ class _RetainedRoute {
class ContactsProvider with ChangeNotifier { class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0; static const double _firstHopFallbackOffsetMeters = 100.0;
static const String autoGroupIdPrefix = 'auto_group_'; static const String autoGroupIdPrefix = 'auto_group_';
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
final Map<String, Contact> _contacts = {}; final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[]; final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {}; final Map<String, PendingAdvert> _pendingAdverts = {};
final Map<String, LatLng> _estimatedLocations = {}; final Map<String, LatLng> _estimatedLocations = {};
final Map<String, List<RssiObservation>> _rssiObservations = {}; final Map<String, List<RssiObservation>> _rssiObservations = {};
final Map<String, Contact> _retainedContactsForSync = {};
final ContactStorageService _storageService = ContactStorageService(); final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false; bool _isInitialized = false;
bool _isPersisting = false; bool _isPersisting = false;
@@ -138,9 +141,12 @@ class ContactsProvider with ChangeNotifier {
bool _isPersistingPendingAdverts = false; bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false; bool _persistPendingAdvertsRequested = false;
String? _storageNamespace; String? _storageNamespace;
String? _selfPublicKeyHex;
ContactTelemetry? _selfTelemetry;
// Add default public channel on initialization // Add default public channel on initialization
ContactsProvider() { ContactsProvider()
: _storageNamespace = ProfileStorageScope.effectiveNamespace {
_ensurePublicChannelExists(); _ensurePublicChannelExists();
} }
@@ -160,6 +166,7 @@ class ContactsProvider with ChangeNotifier {
Uint8List? devicePublicKey, Uint8List? devicePublicKey,
}) async { }) async {
_storageNamespace = namespace; _storageNamespace = namespace;
_setSelfDevicePublicKey(devicePublicKey);
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
} }
@@ -251,6 +258,7 @@ class ContactsProvider with ChangeNotifier {
/// Initialize and load persisted contacts /// Initialize and load persisted contacts
/// [devicePublicKey] - device's own public key to exclude from loaded contacts /// [devicePublicKey] - device's own public key to exclude from loaded contacts
Future<void> initialize({Uint8List? devicePublicKey}) async { Future<void> initialize({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey);
if (_isInitialized) { if (_isInitialized) {
// If already initialized (from early load), just filter out self-contact // If already initialized (from early load), just filter out self-contact
if (devicePublicKey != null) { if (devicePublicKey != null) {
@@ -265,6 +273,36 @@ class ContactsProvider with ChangeNotifier {
} }
} }
/// Clear runtime contact state before a live device contact sync begins.
///
/// This intentionally does not touch persisted storage. It snapshots the
/// current in-memory contacts so sync updates can still merge against the
/// latest local state, but keeps the visible list intact so reconnects do
/// not blank the UI while the device is resyncing.
Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey);
if (!_isInitialized) {
final storedGroups = await _storageService.loadContactGroups(
namespace: _storageNamespace,
);
_savedContactGroups
..clear()
..addAll(storedGroups);
_isInitialized = true;
}
debugPrint(
'🧹 [ContactsProvider] Preparing retained contact state for device sync',
);
_retainedContactsForSync
..clear()
..addEntries(
_contactsForStorage().map(
(contact) => MapEntry(contact.publicKeyHex, contact),
),
);
}
/// Remove self-contact from loaded contacts (called after BLE connection established) /// Remove self-contact from loaded contacts (called after BLE connection established)
void _removeSelfContact(Uint8List devicePublicKey) { void _removeSelfContact(Uint8List devicePublicKey) {
final selfKeyHex = devicePublicKey final selfKeyHex = devicePublicKey
@@ -345,6 +383,7 @@ class ContactsProvider with ChangeNotifier {
} }
List<Contact> get contacts => _contacts.values.toList(); List<Contact> get contacts => _contacts.values.toList();
ContactTelemetry? get selfTelemetry => _selfTelemetry;
List<Contact> get favouriteContacts => List<Contact> get favouriteContacts =>
_contacts.values.where((c) => c.isFavourite).toList(); _contacts.values.where((c) => c.isFavourite).toList();
List<SavedContactGroup> get savedContactGroups => List<SavedContactGroup> get savedContactGroups =>
@@ -535,9 +574,11 @@ class ContactsProvider with ChangeNotifier {
// Replace existing observation from the same repeater, or add new // Replace existing observation from the same repeater, or add new
final repeaterKey = final repeaterKey =
'${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}'; '${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}';
observations.removeWhere((o) => observations.removeWhere(
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' == (o) =>
repeaterKey); '${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
repeaterKey,
);
observations.add(observation); observations.add(observation);
// Keep at most 8 observations (most recent per repeater) // Keep at most 8 observations (most recent per repeater)
@@ -637,7 +678,9 @@ class ContactsProvider with ChangeNotifier {
} }
// Check if this is a new contact // Check if this is a new contact
final existingContact = _contacts[contact.publicKeyHex]; final existingContact =
_contacts[contact.publicKeyHex] ??
_retainedContactsForSync[contact.publicKeyHex];
final isNewContact = existingContact == null; final isNewContact = existingContact == null;
debugPrint( debugPrint(
' isNew: $isNewContact, total contacts before: ${_contacts.length}', ' isNew: $isNewContact, total contacts before: ${_contacts.length}',
@@ -649,6 +692,7 @@ class ContactsProvider with ChangeNotifier {
); );
_contacts[contact.publicKeyHex] = updatedContact; _contacts[contact.publicKeyHex] = updatedContact;
_retainedContactsForSync.remove(contact.publicKeyHex);
// Keep repeaters and sensors in pending adverts so they remain visible // Keep repeaters and sensors in pending adverts so they remain visible
// in the discovery list (with a checkmark). Remove others. // in the discovery list (with a checkmark). Remove others.
if (contact.type != ContactType.repeater && if (contact.type != ContactType.repeater &&
@@ -678,11 +722,14 @@ class ContactsProvider with ChangeNotifier {
excluded++; excluded++;
continue; continue;
} }
final existingContact = _contacts[contact.publicKeyHex]; final existingContact =
_contacts[contact.publicKeyHex] ??
_retainedContactsForSync[contact.publicKeyHex];
_contacts[contact.publicKeyHex] = _mergeIncomingContact( _contacts[contact.publicKeyHex] = _mergeIncomingContact(
incomingContact: contact, incomingContact: contact,
existingContact: existingContact, existingContact: existingContact,
); );
_retainedContactsForSync.remove(contact.publicKeyHex);
if (contact.type != ContactType.repeater && if (contact.type != ContactType.repeater &&
contact.type != ContactType.sensor) { contact.type != ContactType.sensor) {
_pendingAdverts.remove(contact.publicKeyHex); _pendingAdverts.remove(contact.publicKeyHex);
@@ -954,17 +1001,29 @@ class ContactsProvider with ChangeNotifier {
// Find contact by public key prefix // Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix); final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) { final isSelfTelemetry =
contact == null && _matchesSelfPrefix(publicKeyPrefix);
if (contact == null && !isSelfTelemetry) {
debugPrint(' ❌ Contact not found for this prefix'); debugPrint(' ❌ Contact not found for this prefix');
return; return;
} }
debugPrint(' ✅ Found contact: ${contact.advName}'); if (isSelfTelemetry) {
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}'); debugPrint(' ✅ Matched self telemetry response');
debugPrint(
' Old self telemetry timestamp: ${_selfTelemetry?.timestamp}',
);
} else {
debugPrint(' ✅ Found contact: ${contact!.advName}');
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}');
}
try { try {
// Parse Cayenne LPP data // Parse Cayenne LPP data
var telemetry = CayenneLppParser.parse(lppData); var telemetry = _withRawTelemetryHex(
CayenneLppParser.parse(lppData),
lppData,
);
debugPrint(' ✅ Parsed new telemetry'); debugPrint(' ✅ Parsed new telemetry');
debugPrint(' New telemetry timestamp: ${telemetry.timestamp}'); debugPrint(' New telemetry timestamp: ${telemetry.timestamp}');
@@ -983,7 +1042,9 @@ class ContactsProvider with ChangeNotifier {
); );
} }
final previousTelemetry = contact.telemetry; final previousTelemetry = isSelfTelemetry
? _selfTelemetry
: contact!.telemetry;
final mergedTelemetry = _mergeTelemetryForContact( final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: previousTelemetry, existingTelemetry: previousTelemetry,
@@ -1006,29 +1067,38 @@ class ContactsProvider with ChangeNotifier {
timestamp: mergedTelemetry.timestamp, timestamp: mergedTelemetry.timestamp,
humidity: mergedTelemetry.humidity, humidity: mergedTelemetry.humidity,
pressure: mergedTelemetry.pressure, pressure: mergedTelemetry.pressure,
extraSensorData: telemetry.extraSensorData, extraSensorData: mergedTelemetry.extraSensorData,
); );
} }
if (isSelfTelemetry) {
_selfTelemetry = telemetry;
notifyListeners();
debugPrint(' ✅ Updated self telemetry');
return;
}
final resolvedContact = contact!;
// Update contact with new telemetry AND last seen time // Update contact with new telemetry AND last seen time
// lastAdvert is Unix timestamp in seconds // lastAdvert is Unix timestamp in seconds
final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000) final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000)
.round(); .round();
debugPrint(' Old lastAdvert: ${contact.lastAdvert}'); debugPrint(' Old lastAdvert: ${resolvedContact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp'); debugPrint(' New lastAdvert: $currentTimestamp');
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation); final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
final updatedContact = contact.copyWith( final updatedContact = resolvedContact.copyWith(
telemetry: telemetry, telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time lastAdvert: currentTimestamp, // Update last seen time
advLat: persistedGps != null advLat: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.latitude) ? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
: contact.advLat, : resolvedContact.advLat,
advLon: persistedGps != null advLon: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.longitude) ? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
: contact.advLon, : resolvedContact.advLon,
); );
_contacts[contact.publicKeyHex] = updatedContact; _contacts[resolvedContact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)'); debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
_persistContacts(); _persistContacts();
@@ -1067,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();
} }
@@ -1146,6 +1237,28 @@ class ContactsProvider with ChangeNotifier {
); );
} }
ContactTelemetry _withRawTelemetryHex(
ContactTelemetry telemetry,
Uint8List lppData,
) {
final extraSensorData = <String, dynamic>{...?telemetry.extraSensorData};
extraSensorData[_rawTelemetryHexKey] = _bytesToHex(lppData);
return ContactTelemetry(
gpsLocation: telemetry.gpsLocation,
batteryPercentage: telemetry.batteryPercentage,
batteryMilliVolts: telemetry.batteryMilliVolts,
temperature: telemetry.temperature,
timestamp: telemetry.timestamp,
humidity: telemetry.humidity,
pressure: telemetry.pressure,
extraSensorData: extraSensorData,
);
}
String _bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
Map<String, dynamic> _mergeExtraSensorData( Map<String, dynamic> _mergeExtraSensorData(
Map<String, dynamic>? existingExtraSensorData, Map<String, dynamic>? existingExtraSensorData,
Map<String, dynamic>? incomingExtraSensorData, Map<String, dynamic>? incomingExtraSensorData,
@@ -1235,6 +1348,36 @@ class ContactsProvider with ChangeNotifier {
return _contacts[keyHex]; return _contacts[keyHex];
} }
void _setSelfDevicePublicKey(Uint8List? devicePublicKey) {
final nextKeyHex = _publicKeyHexOrNull(devicePublicKey);
if (_selfPublicKeyHex != nextKeyHex) {
_selfTelemetry = null;
}
_selfPublicKeyHex = nextKeyHex;
}
String? _publicKeyHexOrNull(Uint8List? publicKey) {
if (publicKey == null || publicKey.isEmpty) {
return null;
}
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
bool _matchesSelfPrefix(Uint8List prefix) {
final selfKeyHex = _selfPublicKeyHex;
if (selfKeyHex == null || prefix.isEmpty) {
return false;
}
final takeLen = prefix.length < 6 ? prefix.length : 6;
final prefixHex = prefix
.sublist(0, takeLen)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
return selfKeyHex.startsWith(prefixHex);
}
/// Clear a contact's learned path locally so the UI and next send both /// Clear a contact's learned path locally so the UI and next send both
/// prefer flood routing until the radio reports a fresh route. /// prefer flood routing until the radio reports a fresh route.
void markPathUnhealthy(Uint8List publicKey) { void markPathUnhealthy(Uint8List publicKey) {
@@ -1532,6 +1675,7 @@ class ContactsProvider with ChangeNotifier {
void clearContacts() { void clearContacts() {
_contacts.clear(); _contacts.clear();
_pendingAdverts.clear(); _pendingAdverts.clear();
_retainedContactsForSync.clear();
_ensurePublicChannelExists(); _ensurePublicChannelExists();
_persistContacts(); _persistContacts();
_persistPendingAdverts(); _persistPendingAdverts();
@@ -1556,6 +1700,7 @@ class ContactsProvider with ChangeNotifier {
// Then remove from local storage // Then remove from local storage
_contacts.remove(publicKeyHex); _contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex); _pendingAdverts.remove(publicKeyHex);
_retainedContactsForSync.remove(publicKeyHex);
_persistContacts(); _persistContacts();
_persistPendingAdverts(); _persistPendingAdverts();
notifyListeners(); notifyListeners();
@@ -1638,6 +1783,10 @@ class ContactsProvider with ChangeNotifier {
_contacts.clear(); _contacts.clear();
_savedContactGroups.clear(); _savedContactGroups.clear();
_pendingAdverts.clear(); _pendingAdverts.clear();
_estimatedLocations.clear();
_rssiObservations.clear();
_retainedContactsForSync.clear();
_selfTelemetry = null;
} }
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) { Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {

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

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart'; import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
@@ -75,6 +76,8 @@ class ImageProvider with ChangeNotifier {
_restore(); _restore();
} }
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
// ── Accessors ──────────────────────────────────────────────────────────── // ── Accessors ────────────────────────────────────────────────────────────
ImageSession? session(String sessionId) => _sessions[sessionId]; ImageSession? session(String sessionId) => _sessions[sessionId];
@@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier {
// ── Persistence ────────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────────
Future<void> clearAll() async { Future<void> clearAll() async {
_sessions.clear(); _resetInMemoryState();
_outgoing.clear();
_ignoredIncomingSessions.clear();
notifyListeners(); notifyListeners();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey); await prefs.remove(_scopedStorageKey());
} catch (e) { } catch (e) {
debugPrint('❌ [ImageProvider] Failed to clear storage: $e'); debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
} }
} }
Future<void> reloadProfileScopedState() async {
_resetInMemoryState();
await _restore();
notifyListeners();
}
void _evictExpiredOutgoing() { void _evictExpiredOutgoing() {
final now = DateTime.now(); final now = DateTime.now();
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl); _outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
@@ -343,7 +350,7 @@ class ImageProvider with ChangeNotifier {
) )
.toList(), .toList(),
}; };
await prefs.setString(_storageKey, jsonEncode(payload)); await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
} catch (e) { } catch (e) {
debugPrint('❌ [ImageProvider] Failed to persist: $e'); debugPrint('❌ [ImageProvider] Failed to persist: $e');
} }
@@ -352,7 +359,7 @@ class ImageProvider with ChangeNotifier {
Future<void> _restore() async { Future<void> _restore() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey); final raw = prefs.getString(_scopedStorageKey());
if (raw == null || raw.isEmpty) return; if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>; final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -428,6 +435,12 @@ class ImageProvider with ChangeNotifier {
debugPrint('❌ [ImageProvider] Failed to restore: $e'); debugPrint('❌ [ImageProvider] Failed to restore: $e');
} }
} }
void _resetInMemoryState() {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
}
} }
class _OutgoingSession { class _OutgoingSession {

View File

@@ -37,8 +37,6 @@ class MapProvider with ChangeNotifier {
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo; MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
String? _targetMapId; String? _targetMapId;
final Set<String> _visibleContactPaths = {};
LocationTrail? _currentTrail; LocationTrail? _currentTrail;
bool _isTrailVisible = true; bool _isTrailVisible = true;
final List<LocationTrail> _trailHistory = []; final List<LocationTrail> _trailHistory = [];
@@ -55,7 +53,6 @@ class MapProvider with ChangeNotifier {
bool _showPlaceNamesOverlay = false; bool _showPlaceNamesOverlay = false;
bool _showMunicipalityBordersOverlay = false; bool _showMunicipalityBordersOverlay = false;
bool _showAllContactTrails = true;
bool _hideRepeatersOnMap = false; bool _hideRepeatersOnMap = false;
LocationTrail? _importedTrail; LocationTrail? _importedTrail;
@@ -72,8 +69,6 @@ class MapProvider with ChangeNotifier {
bool get shouldAnimate => _shouldAnimate; bool get shouldAnimate => _shouldAnimate;
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace; MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
String? get targetMapId => _targetMapId; String? get targetMapId => _targetMapId;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
LocationTrail? get currentTrail => _currentTrail; LocationTrail? get currentTrail => _currentTrail;
bool get isTrailVisible => _isTrailVisible; bool get isTrailVisible => _isTrailVisible;
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory); List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
@@ -91,7 +86,6 @@ class MapProvider with ChangeNotifier {
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay; bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay; bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap; bool get hideRepeatersOnMap => _hideRepeatersOnMap;
LocationTrail? get importedTrail => _importedTrail; LocationTrail? get importedTrail => _importedTrail;
@@ -365,30 +359,6 @@ class MapProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex);
} else {
_visibleContactPaths.add(publicKeyHex);
}
notifyListeners();
}
bool isContactPathVisible(String publicKeyHex) {
return _visibleContactPaths.contains(publicKeyHex);
}
void hideAllPaths() {
_visibleContactPaths.clear();
notifyListeners();
}
void showOnlyPath(String publicKeyHex) {
_visibleContactPaths.clear();
_visibleContactPaths.add(publicKeyHex);
notifyListeners();
}
void startTrail() { void startTrail() {
if (_currentTrail != null && _currentTrail!.isActive) { if (_currentTrail != null && _currentTrail!.isActive) {
endTrail(); endTrail();
@@ -556,12 +526,17 @@ class MapProvider with ChangeNotifier {
Future<void> _loadInitialState() async { Future<void> _loadInitialState() async {
await Future.wait([ await Future.wait([
loadOverlayState(), loadOverlayState(),
loadTrailSettings(),
loadRepeaterVisibilitySettings(), loadRepeaterVisibilitySettings(),
_loadCustomMapState(), _loadCustomMapState(),
_removeDeprecatedSettings(),
]); ]);
} }
Future<void> _removeDeprecatedSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_scopedKey('map_show_all_contact_trails'));
}
Future<void> _saveOverlayState() async { Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool( await prefs.setBool(
@@ -610,27 +585,6 @@ class MapProvider with ChangeNotifier {
); );
} }
Future<void> toggleAllContactTrails() async {
_showAllContactTrails = !_showAllContactTrails;
notifyListeners();
await _saveTrailSettings();
}
Future<void> loadTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
_showAllContactTrails =
prefs.getBool(_scopedKey('map_show_all_contact_trails')) ?? true;
notifyListeners();
}
Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey('map_show_all_contact_trails'),
_showAllContactTrails,
);
}
Future<void> setHideRepeatersOnMap(bool hide) async { Future<void> setHideRepeatersOnMap(bool hide) async {
if (_hideRepeatersOnMap == hide) return; if (_hideRepeatersOnMap == hide) return;
_hideRepeatersOnMap = hide; _hideRepeatersOnMap = hide;
@@ -755,7 +709,6 @@ class MapProvider with ChangeNotifier {
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay, 'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
'showPlaceNamesOverlay': _showPlaceNamesOverlay, 'showPlaceNamesOverlay': _showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay, 'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
'showAllContactTrails': _showAllContactTrails,
'hideRepeatersOnMap': _hideRepeatersOnMap, 'hideRepeatersOnMap': _hideRepeatersOnMap,
}; };
} }
@@ -792,7 +745,6 @@ class MapProvider with ChangeNotifier {
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false; _showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
_showMunicipalityBordersOverlay = _showMunicipalityBordersOverlay =
json['showMunicipalityBordersOverlay'] as bool? ?? false; json['showMunicipalityBordersOverlay'] as bool? ?? false;
_showAllContactTrails = json['showAllContactTrails'] as bool? ?? true;
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false; _hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
notifyListeners(); notifyListeners();
} }

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,16 +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;
return existing.senderKeyShort == message.senderKeyShort && }
existing.senderTimestamp == message.senderTimestamp; return existing.senderTimestamp == message.senderTimestamp &&
_matchesDuplicateSenderIdentity(existing, message);
} }
if (message.isChannelMessage) { if (message.isChannelMessage) {
@@ -746,36 +806,9 @@ 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;
} }
// 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;
}
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
if (existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
return true;
}
return false; return false;
} }
@@ -783,6 +816,85 @@ class MessagesProvider with ChangeNotifier {
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) {
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
return existingSenderName != null &&
incomingSenderName != null &&
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;
@@ -1180,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) {
@@ -1200,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;
@@ -2347,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,6 +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 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';
@@ -373,6 +374,20 @@ class SensorsProvider with ChangeNotifier {
return List<String>.unmodifiable(dueKeys); return List<String>.unmodifiable(dueKeys);
} }
bool _isRefreshDueForInterval(
String publicKeyHex, {
required Duration interval,
required DateTime refreshTime,
}) {
if (interval <= Duration.zero) {
return false;
}
final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex];
return lastRefreshAt == null ||
refreshTime.difference(lastRefreshAt) >= interval;
}
Future<void> toggleMetric( Future<void> toggleMetric(
String publicKeyHex, String publicKeyHex,
String fieldKey, String fieldKey,
@@ -474,6 +489,28 @@ class SensorsProvider with ChangeNotifier {
bool isWatched(String publicKeyHex) => bool isWatched(String publicKeyHex) =>
_watchedSensorKeys.contains(publicKeyHex); _watchedSensorKeys.contains(publicKeyHex);
Contact? selfContact(
ContactsProvider contactsProvider,
ConnectionProvider connectionProvider,
) {
final selfKey = connectionProvider.deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) {
return null;
}
final existing = contactsProvider.findContactByKey(
Uint8List.fromList(selfKey),
);
final selfTelemetry = contactsProvider.selfTelemetry;
if (existing != null) {
return selfTelemetry == null
? existing
: existing.copyWith(telemetry: selfTelemetry);
}
return _buildSelfCandidate(connectionProvider, telemetry: selfTelemetry);
}
Future<void> addSensor(Contact contact) async { Future<void> addSensor(Contact contact) async {
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) { if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
return; return;
@@ -549,22 +586,117 @@ class SensorsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
List<Contact> availableCandidates(ContactsProvider contactsProvider) { Future<void> reorderSensors(int oldIndex, int newIndex) async {
if (_watchedSensorKeys.length < 2) {
return;
}
if (oldIndex < 0 ||
oldIndex >= _watchedSensorKeys.length ||
newIndex < 0 ||
newIndex > _watchedSensorKeys.length) {
return;
}
var targetIndex = newIndex;
if (oldIndex < targetIndex) {
targetIndex -= 1;
}
if (oldIndex == targetIndex) {
return;
}
final movedKey = _watchedSensorKeys.removeAt(oldIndex);
_watchedSensorKeys.insert(targetIndex, movedKey);
await _persistWatchedSensors();
notifyListeners();
}
List<Contact> availableCandidates(
ContactsProvider contactsProvider, {
ConnectionProvider? connectionProvider,
}) {
final candidates = <Contact>[ final candidates = <Contact>[
...contactsProvider.chatContacts, ...contactsProvider.chatContacts,
...contactsProvider.repeaters, ...contactsProvider.repeaters,
...contactsProvider.sensorContacts, ...contactsProvider.sensorContacts,
]; ];
// Add "myself" (own device) as a candidate if it has a public key
if (connectionProvider != null) {
final self = selfContact(contactsProvider, connectionProvider);
if (self != null &&
!candidates.any((c) => c.publicKeyHex == self.publicKeyHex)) {
candidates.insert(0, self);
}
}
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
return candidates; return candidates;
} }
List<String> displaySensorKeys({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
if (_watchedSensorKeys.isNotEmpty) {
return List<String>.unmodifiable(_watchedSensorKeys);
}
final self = selfContact(contactsProvider, connectionProvider);
if (self == null) {
return const <String>[];
}
return <String>[self.publicKeyHex];
}
String? displaySelfKey({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
final self = selfContact(contactsProvider, connectionProvider);
if (self == null || _watchedSensorKeys.contains(self.publicKeyHex)) {
return null;
}
return self.publicKeyHex;
}
Contact? contactForDisplay(
String publicKeyHex, {
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
return entry;
}
}
final self = selfContact(contactsProvider, connectionProvider);
if (self?.publicKeyHex == publicKeyHex) {
return self;
}
return null;
}
Future<void> refreshAll({ Future<void> refreshAll({
required ContactsProvider contactsProvider, required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider, required ConnectionProvider connectionProvider,
}) async { }) async {
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) { if (_isRefreshingAll) {
return;
}
final self = selfContact(contactsProvider, connectionProvider);
final keysToRefresh = <String>[
if (self != null) self.publicKeyHex,
..._watchedSensorKeys.where(
(key) => self == null || key != self.publicKeyHex,
),
];
if (keysToRefresh.isEmpty) {
return; return;
} }
@@ -572,7 +704,7 @@ class SensorsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
for (final key in _watchedSensorKeys) { for (final key in keysToRefresh) {
await refreshSensor( await refreshSensor(
publicKeyHex: key, publicKeyHex: key,
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
@@ -597,13 +729,11 @@ class SensorsProvider with ChangeNotifier {
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now(); _lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
Contact? contact; final contact = contactForDisplay(
for (final entry in contactsProvider.contacts) { publicKeyHex,
if (entry.publicKeyHex == publicKeyHex) { contactsProvider: contactsProvider,
contact = entry; connectionProvider: connectionProvider,
break; );
}
}
if (contact == null) { if (contact == null) {
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable); _setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
@@ -635,7 +765,17 @@ class SensorsProvider with ChangeNotifier {
} }
final refreshTime = now ?? DateTime.now(); final refreshTime = now ?? DateTime.now();
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime); final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime).toList();
final self = selfContact(contactsProvider, connectionProvider);
if (self != null &&
!dueKeys.contains(self.publicKeyHex) &&
_isRefreshDueForInterval(
self.publicKeyHex,
interval: selfAutoRefreshInterval,
refreshTime: refreshTime,
)) {
dueKeys.insert(0, self.publicKeyHex);
}
if (dueKeys.isEmpty) { if (dueKeys.isEmpty) {
return; return;
} }
@@ -686,4 +826,29 @@ class SensorsProvider with ChangeNotifier {
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now(); _refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners(); notifyListeners();
} }
Contact? _buildSelfCandidate(
ConnectionProvider connectionProvider, {
ContactTelemetry? telemetry,
}) {
final deviceInfo = connectionProvider.deviceInfo;
final selfKey = deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) {
return null;
}
return Contact(
publicKey: Uint8List.fromList(selfKey),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: deviceInfo.selfName ?? 'My Device',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: deviceInfo.advLat ?? 0,
advLon: deviceInfo.advLon ?? 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: telemetry,
);
}
} }

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart'; import 'helpers/raw_session_retransmit.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
@@ -92,6 +93,9 @@ class VoiceProvider with ChangeNotifier {
_restorePersistedVoiceData(); _restorePersistedVoiceData();
} }
String _storageKey() =>
ProfileStorageScope.scopedKey(_voiceSessionsStorageKey);
// ── Session accessors ──────────────────────────────────────────────────── // ── Session accessors ────────────────────────────────────────────────────
VoiceSession? session(String sessionId) => _sessions[sessionId]; VoiceSession? session(String sessionId) => _sessions[sessionId];
@@ -329,19 +333,22 @@ class VoiceProvider with ChangeNotifier {
} }
Future<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {
_sessions.clear(); await _resetInMemoryState();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
notifyListeners(); notifyListeners();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_voiceSessionsStorageKey); await prefs.remove(_storageKey());
} catch (e) { } catch (e) {
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e'); debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
} }
} }
Future<void> reloadProfileScopedState() async {
await _resetInMemoryState();
await _restorePersistedVoiceData();
notifyListeners();
}
Future<void> _persistVoiceData() async { Future<void> _persistVoiceData() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -365,7 +372,7 @@ class VoiceProvider with ChangeNotifier {
) )
.toList(), .toList(),
}; };
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload)); await prefs.setString(_storageKey(), jsonEncode(payload));
} catch (e) { } catch (e) {
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e'); debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
} }
@@ -374,7 +381,7 @@ class VoiceProvider with ChangeNotifier {
Future<void> _restorePersistedVoiceData() async { Future<void> _restorePersistedVoiceData() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_voiceSessionsStorageKey); final raw = prefs.getString(_storageKey());
if (raw == null || raw.isEmpty) return; if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>; final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -437,6 +444,16 @@ class VoiceProvider with ChangeNotifier {
} }
} }
Future<void> _resetInMemoryState() async {
if (_playingSessionId != null || _player.isPlaying) {
await _player.stop();
}
_sessions.clear();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
}
@override @override
void dispose() { void dispose() {
_playerEventsSub.cancel(); _playerEventsSub.cancel();

View File

@@ -6,7 +6,9 @@ import '../providers/connection_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class AddContactScreen extends StatefulWidget { class AddContactScreen extends StatefulWidget {
const AddContactScreen({super.key}); final String? initialAdvert;
const AddContactScreen({super.key, this.initialAdvert});
@override @override
State<AddContactScreen> createState() => _AddContactScreenState(); State<AddContactScreen> createState() => _AddContactScreenState();
@@ -21,7 +23,12 @@ class _AddContactScreenState extends State<AddContactScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadClipboardIfPresent(); if (widget.initialAdvert != null &&
widget.initialAdvert!.trim().isNotEmpty) {
_advertController.text = widget.initialAdvert!.trim();
} else {
_loadClipboardIfPresent();
}
} }
@override @override
@@ -79,9 +86,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
final text = clipboardData?.text; final text = clipboardData?.text;
if (text == null || text.trim().isEmpty) { if (text == null || text.trim().isEmpty) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)),
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty))); );
return; return;
} }
@@ -140,9 +147,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
return; return;
} }
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)),
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.contactImported))); );
setState(() { setState(() {
_importSucceeded = true; _importSucceeded = true;
}); });

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 {
@@ -519,62 +521,100 @@ class _ContactsTabState extends State<ContactsTab> {
final canExportHashChannelPsk = Channel.isHashChannelName( final canExportHashChannelPsk = Channel.isHashChannelName(
channel.advName.trim(), channel.advName.trim(),
); );
final actions = <_ChannelSheetAction>[
_ChannelSheetAction(
icon: Icons.message_outlined,
label: l10n.messages,
onTap: () async {
Navigator.pop(context);
await _openMessagesForChannel(context, channel);
},
),
if (channel.displayLocation != null)
_ChannelSheetAction(
icon: Icons.map_outlined,
label: l10n.viewOnMap,
onTap: () async {
Navigator.pop(context);
_showChannelOnMap(context, channel);
},
),
if (canExportHashChannelPsk)
_ChannelSheetAction(
icon: Icons.key_outlined,
label: '${l10n.exportToClipboard} psk_base64',
onTap: () async {
Navigator.pop(context);
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)
_ChannelSheetAction(
icon: Icons.delete_outline_rounded,
label: l10n.deleteChannel,
destructive: true,
onTap: () async {
Navigator.pop(context);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
_showDeleteChannelDialog(context, channel);
});
},
),
];
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ChannelActionSheet(
channel: channel,
actions: actions,
onClose: () => Navigator.pop(sheetContext),
),
);
}
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( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder( showDragHandle: true,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), isScrollControlled: true,
), builder: (sheetContext) {
builder: (sheetContext) => SafeArea( return _ContactsRegionScopeSheet(
child: Column( currentScopeName: currentScope?.name,
mainAxisSize: MainAxisSize.min, l10n: l10n,
children: [ onScopeSelected: (String? name) async {
ListTile( Navigator.of(sheetContext).pop();
leading: Icon(Icons.message_outlined), if (name == null) {
title: Text(l10n.messages), await RegionScopePreferences.clearScope(channelIdx);
onTap: () async { if (!context.mounted) return;
Navigator.pop(sheetContext); ToastLogger.success(context, l10n.regionScopeCleared);
await _openMessagesForChannel(context, channel); } else {
}, final key = RegionScopePreferences.deriveRegionKey(name);
), await RegionScopePreferences.setScope(channelIdx, name, key);
if (channel.displayLocation != null) if (!context.mounted) return;
ListTile( ToastLogger.success(context, l10n.regionScopeSet(name));
leading: Icon(Icons.map_outlined), }
title: Text(l10n.viewOnMap), },
onTap: () { );
Navigator.pop(sheetContext); },
_showChannelOnMap(context, channel);
},
),
if (canExportHashChannelPsk)
ListTile(
leading: const Icon(Icons.key_outlined),
title: Text('${l10n.exportToClipboard} psk_base64'),
onTap: () async {
Navigator.pop(sheetContext);
await _exportHashChannelPskBase64(context, channel);
},
),
if (!channel.isPublicChannel)
ListTile(
leading: Icon(Icons.delete, color: Colors.red),
title: Text(
l10n.deleteChannel,
style: const TextStyle(color: Colors.red),
),
onTap: () async {
Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
_showDeleteChannelDialog(context, channel);
});
},
),
],
),
),
); );
} }
@@ -1980,6 +2020,302 @@ class _ExpandableParticipantStackState
} }
} }
class _ChannelSheetAction {
final IconData icon;
final String label;
final Future<void> Function() onTap;
final bool destructive;
const _ChannelSheetAction({
required this.icon,
required this.label,
required this.onTap,
this.destructive = false,
});
}
class _ChannelActionSheet extends StatelessWidget {
final Contact channel;
final List<_ChannelSheetAction> actions;
final VoidCallback onClose;
const _ChannelActionSheet({
required this.channel,
required this.actions,
required this.onClose,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = AppLocalizations.of(context)!;
final title = channel.getLocalizedDisplayName(context);
final subtitle = channel.isPublicChannel
? l10n.broadcastToAllNearby
: '${l10n.channel} ${channel.publicKey[1]}';
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.82,
),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.12),
blurRadius: 24,
offset: const Offset(0, -4),
),
],
),
child: Material(
color: colorScheme.surface,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ContactAvatar(
contact: channel,
radius: 28,
displayName: title,
),
),
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.45,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
_ChannelSheetChip(
icon: channel.isPublicChannel
? Icons.broadcast_on_personal_rounded
: Icons.tag_rounded,
label: channel.isPublicChannel
? l10n.broadcastToAllNearby
: channel.publicKeyShort,
monospace: !channel.isPublicChannel,
),
],
),
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: onClose,
tooltip: l10n.close,
icon: const Icon(Icons.close_rounded),
),
],
),
if (actions.isNotEmpty) ...[
const SizedBox(height: 20),
LayoutBuilder(
builder: (context, constraints) {
const spacing = 12.0;
const minTileWidth = 132.0;
final actionCount = actions.length;
final maxColumnsByWidth =
((constraints.maxWidth + spacing) /
(minTileWidth + spacing))
.floor()
.clamp(1, 3);
var columnCount = actionCount.clamp(1, maxColumnsByWidth);
if (actionCount > 3 &&
columnCount > 2 &&
actionCount % columnCount == 1) {
columnCount -= 1;
}
final itemWidth =
(constraints.maxWidth - (spacing * (columnCount - 1))) /
columnCount;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final action in actions)
SizedBox(
width: itemWidth,
child: _ChannelPrimaryActionButton(action: action),
),
],
);
},
),
],
],
),
),
),
);
}
}
class _ChannelPrimaryActionButton extends StatelessWidget {
final _ChannelSheetAction action;
const _ChannelPrimaryActionButton({required this.action});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final accent = action.destructive ? colorScheme.error : colorScheme.primary;
final backgroundColor = action.destructive
? colorScheme.errorContainer.withValues(alpha: 0.82)
: Color.alphaBlend(
accent.withValues(alpha: 0.12),
colorScheme.surfaceContainerLow,
);
final foregroundColor = action.destructive
? colorScheme.onErrorContainer
: accent;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: action.onTap,
child: Ink(
height: 100,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: action.destructive
? colorScheme.error.withValues(alpha: 0.18)
: accent.withValues(alpha: 0.14),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: foregroundColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(11),
),
child: Icon(action.icon, color: foregroundColor, size: 16),
),
const SizedBox(height: 6),
Text(
action.label,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: action.destructive
? colorScheme.onErrorContainer
: colorScheme.onSurface,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
),
),
],
),
),
),
),
);
}
}
class _ChannelSheetChip extends StatelessWidget {
final IconData icon;
final String label;
final bool monospace;
const _ChannelSheetChip({
required this.icon,
required this.label,
this.monospace = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
constraints: const BoxConstraints(maxWidth: 260),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
fontFamily: monospace ? 'monospace' : null,
),
),
),
],
),
);
}
}
class _OverflowAvatar extends StatelessWidget { class _OverflowAvatar extends StatelessWidget {
final int count; final int count;
@@ -2101,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

@@ -178,12 +178,20 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
late TextEditingController _lonController; late TextEditingController _lonController;
late TextEditingController _freqController; late TextEditingController _freqController;
late TextEditingController _txPowerController; late TextEditingController _txPowerController;
late TextEditingController _gpsIntervalController;
late TextEditingController _autoAddMaxHopsController;
late final ConnectionProvider _connectionProvider; late final ConnectionProvider _connectionProvider;
bool _telemetryEnabled = false; int _baseTelemetryMode = 0;
int _locationTelemetryMode = 0;
int _environmentTelemetryMode = 0;
int _advertLocationPolicy = 0;
bool _multiAcksEnabled = false;
bool _repeatEnabled = false; bool _repeatEnabled = false;
bool? _gpsEnabled; // null = not supported by hardware bool? _gpsEnabled; // null = not supported by hardware
bool _gpsLoading = false; bool _gpsLoading = false;
bool _isSyncingDeviceTime = false;
int? _selectedPathHashMode;
bool _autoAddDiscoveredContactsEnabled = true; bool _autoAddDiscoveredContactsEnabled = true;
bool _autoAddUsersEnabled = true; bool _autoAddUsersEnabled = true;
bool _autoAddRepeatersEnabled = true; bool _autoAddRepeatersEnabled = true;
@@ -250,6 +258,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_txPowerController = TextEditingController( _txPowerController = TextEditingController(
text: deviceInfo.txPower?.toString() ?? '20', text: deviceInfo.txPower?.toString() ?? '20',
); );
_gpsIntervalController = TextEditingController();
_autoAddMaxHopsController = TextEditingController(
text: (deviceInfo.autoAddMaxHops ?? 0).toString(),
);
if (deviceInfo.radioBw != null && if (deviceInfo.radioBw != null &&
deviceInfo.radioBw! >= 0 && deviceInfo.radioBw! >= 0 &&
@@ -275,10 +287,17 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
); );
_showCustomRadioSettings = _selectedRadioPreset == null; _showCustomRadioSettings = _selectedRadioPreset == null;
// Check if telemetry is enabled (check if lat/lon are set and not zero) final telemetryModes = deviceInfo.telemetryModes;
_telemetryEnabled = _baseTelemetryMode = telemetryModes != null ? telemetryModes & 0x03 : 0;
(deviceInfo.advLat != null && deviceInfo.advLat! != 0) || _locationTelemetryMode = telemetryModes != null
(deviceInfo.advLon != null && deviceInfo.advLon! != 0); ? (telemetryModes >> 2) & 0x03
: 0;
_environmentTelemetryMode = telemetryModes != null
? (telemetryModes >> 4) & 0x03
: 0;
_advertLocationPolicy = deviceInfo.advertLocPolicy ?? 0;
_multiAcksEnabled = (deviceInfo.multiAcks ?? 0) != 0;
_selectedPathHashMode = deviceInfo.pathHashMode;
// Initialize repeat mode from device info (firmware v9+) // Initialize repeat mode from device info (firmware v9+)
_repeatEnabled = deviceInfo.clientRepeat ?? false; _repeatEnabled = deviceInfo.clientRepeat ?? false;
@@ -308,6 +327,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_lonController.dispose(); _lonController.dispose();
_freqController.dispose(); _freqController.dispose();
_txPowerController.dispose(); _txPowerController.dispose();
_gpsIntervalController.dispose();
_autoAddMaxHopsController.dispose();
super.dispose(); super.dispose();
} }
@@ -428,6 +449,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
deviceInfo.autoAddRoomServers, deviceInfo.autoAddRoomServers,
deviceInfo.autoAddSensors, deviceInfo.autoAddSensors,
deviceInfo.autoAddOverwriteOldest, deviceInfo.autoAddOverwriteOldest,
deviceInfo.autoAddMaxHops,
].join('|'); ].join('|');
} }
@@ -467,22 +489,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true; _autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true;
_autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true; _autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true;
_overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false; _overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false;
_autoAddMaxHopsController.text = (deviceInfo.autoAddMaxHops ?? 0)
.toString();
} }
int _telemetryModesForSave(ConnectionProvider connectionProvider) { int _telemetryModesForSave() {
final deviceInfo = connectionProvider.deviceInfo; return (_environmentTelemetryMode << 4) |
final telemetryEnabled = (_locationTelemetryMode << 2) |
(deviceInfo.advLat != null && deviceInfo.advLat != 0) || _baseTelemetryMode;
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return deviceInfo.telemetryModes ?? (telemetryEnabled ? 0x0A : 0x00);
} }
int _advertLocationPolicyForSave(ConnectionProvider connectionProvider) { int _advertLocationPolicyForSave() {
final deviceInfo = connectionProvider.deviceInfo; return _advertLocationPolicy;
final telemetryEnabled = }
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0); int _multiAcksForSave() {
return deviceInfo.advertLocPolicy ?? (telemetryEnabled ? 1 : 0); return _multiAcksEnabled ? 1 : 0;
} }
Future<void> _savePublicInfo() async { Future<void> _savePublicInfo() async {
@@ -501,8 +523,24 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
await connectionProvider.setAdvertName(_nameController.text); await connectionProvider.setAdvertName(_nameController.text);
} }
// Save position and telemetry settings final gpsIntervalText = _gpsIntervalController.text.trim();
if (_telemetryEnabled) { if (gpsIntervalText.isNotEmpty) {
final gpsInterval = int.tryParse(gpsIntervalText);
if (gpsInterval == null || gpsInterval < 0 || gpsInterval > 86400) {
if (mounted) {
setState(() {
_publicInfoError =
'GPS interval must be a whole number between 0 and 86400 seconds.';
_isSavingPublicInfo = false;
});
}
return;
}
await connectionProvider.setCustomVar('gps_interval', gpsIntervalText);
}
// Save stored coordinates only when the firmware advert policy uses prefs.
if (_advertLocationPolicy == 2) {
// Parse and validate coordinates // Parse and validate coordinates
final latResult = validator.parseLatitude(_latController.text); final latResult = validator.parseLatitude(_latController.text);
if (!latResult.isSuccess) { if (!latResult.isSuccess) {
@@ -530,27 +568,15 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
latitude: latResult.value!, latitude: latResult.value!,
longitude: lonResult.value!, longitude: lonResult.value!,
); );
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
await connectionProvider.setOtherParams(
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 1,
);
} else {
// Clear position
await connectionProvider.setAdvertLatLon(latitude: 0.0, longitude: 0.0);
// Set telemetry modes to "Deny" (mode 0)
final telemetryModes = 0x00;
await connectionProvider.setOtherParams(
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 0,
);
} }
await connectionProvider.setOtherParams(
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: _telemetryModesForSave(),
advertLocationPolicy: _advertLocationPolicyForSave(),
multiAcks: _multiAcksForSave(),
);
// Refetch device info to update UI with new settings // Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
@@ -625,6 +651,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Save TX power // Save TX power
await connectionProvider.setTxPower(txPowerResult.value!); await connectionProvider.setTxPower(txPowerResult.value!);
if (_selectedPathHashMode != null) {
await connectionProvider.setPathHashMode(_selectedPathHashMode!);
}
// Refetch device info to update UI with new settings // Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
@@ -658,6 +688,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled; _autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled;
final overwriteOldest = final overwriteOldest =
_autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled; _autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled;
final maxHopsText = _autoAddMaxHopsController.text.trim();
final maxHops = int.tryParse(maxHopsText);
setState(() { setState(() {
_isSavingAutoDiscoverySettings = true; _isSavingAutoDiscoverySettings = true;
@@ -666,18 +698,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}); });
try { try {
if (maxHops == null || maxHops < 0 || maxHops > 64) {
throw Exception('Auto-add max hops must be between 0 and 64.');
}
await connectionProvider.setAutoaddConfig( await connectionProvider.setAutoaddConfig(
autoAddUsers: autoAddUsers, autoAddUsers: autoAddUsers,
autoAddRepeaters: autoAddRepeaters, autoAddRepeaters: autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers, autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors, autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest, overwriteOldest: overwriteOldest,
maxHops: maxHops,
); );
await connectionProvider.setOtherParams( await connectionProvider.setOtherParams(
manualAddContacts: _autoAddFilterModeFlag, manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: _telemetryModesForSave(connectionProvider), telemetryModes: _telemetryModesForSave(),
advertLocationPolicy: _advertLocationPolicyForSave(connectionProvider), advertLocationPolicy: _advertLocationPolicyForSave(),
multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0, multiAcks: _multiAcksForSave(),
); );
await connectionProvider.getAutoaddConfig(); await connectionProvider.getAutoaddConfig();
@@ -709,8 +745,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final vars = await _connectionProvider.getCustomVars(); final vars = await _connectionProvider.getCustomVars();
if (!mounted) return; if (!mounted) return;
final gpsValue = vars['gps']; final gpsValue = vars['gps'];
final gpsIntervalValue = vars['gps_interval'];
setState(() { setState(() {
_gpsEnabled = gpsValue != null ? gpsValue == '1' : null; _gpsEnabled = gpsValue != null ? gpsValue == '1' : null;
if (gpsIntervalValue != null) {
_gpsIntervalController.text = gpsIntervalValue;
}
}); });
} catch (_) { } catch (_) {
// Device may not support custom vars (old firmware / no GPS hardware) // Device may not support custom vars (old firmware / no GPS hardware)
@@ -794,7 +834,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
setState(() { setState(() {
_latController.text = position.latitude.toStringAsFixed(6); _latController.text = position.latitude.toStringAsFixed(6);
_lonController.text = position.longitude.toStringAsFixed(6); _lonController.text = position.longitude.toStringAsFixed(6);
_telemetryEnabled = true; _advertLocationPolicy = 2;
if (_locationTelemetryMode == 0) {
_locationTelemetryMode = 2;
}
}); });
if (mounted) { if (mounted) {
@@ -823,6 +866,37 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
} }
Future<void> _syncDeviceTime() async {
setState(() => _isSyncingDeviceTime = true);
try {
_connectionProvider.clearError();
await _connectionProvider.syncDeviceTime();
final syncError = _connectionProvider.error;
if (syncError != null) {
throw Exception(syncError);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Device time synced to this phone.'),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to sync device time: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() => _isSyncingDeviceTime = false);
}
}
}
Future<void> _confirmFactoryReset() async { Future<void> _confirmFactoryReset() async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -1093,9 +1167,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo; final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final locationSet = final locationSet = _advertLocationPolicy != 0;
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
@@ -1173,6 +1245,130 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
), ),
SizedBox(height: 20), SizedBox(height: 20),
_ConfigSectionCard(
title: 'Device info',
subtitle:
'Capabilities reported by the connected radio and maintenance tools.',
icon: Icons.info_outline_rounded,
child: LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth > 420
? (constraints.maxWidth - 24) / 3
: (constraints.maxWidth - 12) / 2;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: cardWidth,
child: _StorageStat(
label: 'BLE PIN',
value: _formatBlePin(deviceInfo.blePin),
compact: true,
),
),
SizedBox(
width: cardWidth,
child: _StorageStat(
label: AppLocalizations.of(
context,
)!.maxContacts,
value:
deviceInfo.maxContacts?.toString() ??
AppLocalizations.of(context)!.unknown,
compact: true,
),
),
SizedBox(
width: cardWidth,
child: _StorageStat(
label: AppLocalizations.of(
context,
)!.maxChannels,
value:
deviceInfo.maxChannels?.toString() ??
AppLocalizations.of(context)!.unknown,
compact: true,
),
),
if (deviceInfo.pathHashMode != null)
SizedBox(
width: cardWidth,
child: _StorageStat(
label: 'Path hash',
value: _pathHashModeLabel(
deviceInfo.pathHashMode!,
),
compact: true,
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Clock maintenance',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Refresh the radio clock if room logins or message timestamps look off.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isSyncingDeviceTime
? null
: _syncDeviceTime,
icon: _isSyncingDeviceTime
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.schedule_rounded),
label: Text(
_isSyncingDeviceTime
? 'Syncing time...'
: 'Sync device time',
),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(46),
),
),
),
],
),
),
],
);
},
),
),
SizedBox(height: 20),
_ConfigSectionCard( _ConfigSectionCard(
title: AppLocalizations.of(context)!.autoDiscovery, title: AppLocalizations.of(context)!.autoDiscovery,
subtitle: subtitle:
@@ -1310,6 +1506,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
: null, : null,
), ),
), ),
const SizedBox(height: 16),
TextField(
controller: _autoAddMaxHopsController,
onChanged: (_) => _markAutoDiscoverySettingsDirty(),
decoration: InputDecoration(
labelText: 'Auto-add max hops',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
filled: true,
fillColor: colorScheme.surfaceContainerLowest,
helperText:
'0 means no limit. 1 keeps auto-add to direct neighbors only.',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 18), const SizedBox(height: 18),
if (_autoDiscoverySettingsError != null) ...[ if (_autoDiscoverySettingsError != null) ...[
Text( Text(
@@ -1346,27 +1558,87 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SettingHighlightCard( _ConfigDropdownField(
icon: _telemetryEnabled label: 'Base telemetry',
? Icons.travel_explore value: _baseTelemetryMode,
: Icons.location_disabled, items: const [
title: AppLocalizations.of( DropdownMenuItem(value: 0, child: Text('Deny')),
context, DropdownMenuItem(
)!.telemetryAndLocationSharing, value: 1,
description: 'Share your location with nearby devices.', child: Text('Use contact flags'),
accentColor: _telemetryEnabled ),
? colorScheme.primary DropdownMenuItem(value: 2, child: Text('Allow all')),
: colorScheme.onSurfaceVariant, ],
trailing: Switch( onChanged: (value) {
value: _telemetryEnabled, if (value == null) return;
onChanged: (value) { setState(() {
setState(() { _baseTelemetryMode = value;
_telemetryEnabled = value; _markPublicInfoDirty();
_publicInfoSaved = false; });
_publicInfoError = null; },
}); ),
}, const SizedBox(height: 16),
), _ConfigDropdownField(
label: 'Location telemetry',
value: _locationTelemetryMode,
items: const [
DropdownMenuItem(value: 0, child: Text('Deny')),
DropdownMenuItem(
value: 1,
child: Text('Use contact flags'),
),
DropdownMenuItem(value: 2, child: Text('Allow all')),
],
onChanged: (value) {
if (value == null) return;
setState(() {
_locationTelemetryMode = value;
_markPublicInfoDirty();
});
},
),
const SizedBox(height: 16),
_ConfigDropdownField(
label: 'Environmental telemetry',
value: _environmentTelemetryMode,
items: const [
DropdownMenuItem(value: 0, child: Text('Deny')),
DropdownMenuItem(
value: 1,
child: Text('Use contact flags'),
),
DropdownMenuItem(value: 2, child: Text('Allow all')),
],
onChanged: (value) {
if (value == null) return;
setState(() {
_environmentTelemetryMode = value;
_markPublicInfoDirty();
});
},
),
const SizedBox(height: 16),
_ConfigDropdownField(
label: 'GPS advert policy',
value: _advertLocationPolicy,
items: const [
DropdownMenuItem(value: 0, child: Text('Hidden')),
DropdownMenuItem(
value: 1,
child: Text('Share live GPS'),
),
DropdownMenuItem(
value: 2,
child: Text('Use saved coordinates'),
),
],
onChanged: (value) {
if (value == null) return;
setState(() {
_advertLocationPolicy = value;
_markPublicInfoDirty();
});
},
), ),
if (_gpsEnabled != null) ...[ if (_gpsEnabled != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1392,6 +1664,43 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
), ),
], ],
const SizedBox(height: 16),
TextField(
controller: _gpsIntervalController,
onChanged: (_) => _markPublicInfoDirty(),
decoration: InputDecoration(
labelText: 'GPS interval (seconds)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
filled: true,
fillColor: colorScheme.surfaceContainerLowest,
helperText:
'Firmware supports 0-86400 seconds. Older builds may not report the current value back.',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
_SettingHighlightCard(
icon: _multiAcksEnabled
? Icons.mark_email_read_outlined
: Icons.mark_email_unread_outlined,
title: 'Multi-ACK mode',
description:
'Ask the radio to request extra acknowledgements when the firmware supports it.',
accentColor: _multiAcksEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _multiAcksEnabled,
onChanged: (value) {
setState(() {
_multiAcksEnabled = value;
_markPublicInfoDirty();
});
},
),
),
const SizedBox(height: 18), const SizedBox(height: 18),
TextField( TextField(
controller: _nameController, controller: _nameController,
@@ -1407,7 +1716,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
'This is the name other devices will see on the mesh.', 'This is the name other devices will see on the mesh.',
), ),
), ),
if (_telemetryEnabled) ...[ if (_advertLocationPolicy == 2) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Container( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
@@ -1420,14 +1729,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Shared location', 'Saved coordinates',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Set coordinates manually or use your current location.', 'These coordinates are used when advert policy is set to saved coordinates.',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -1463,6 +1772,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
], ],
), ),
), ),
] else if (_advertLocationPolicy == 1) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Text(
'The firmware will advertise the live GPS fix from the onboard sensor manager when available.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
], ],
const SizedBox(height: 18), const SizedBox(height: 18),
if (_publicInfoError != null) ...[ if (_publicInfoError != null) ...[
@@ -1708,6 +2033,45 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
if (_selectedPathHashMode != null) ...[
const SizedBox(height: 16),
DropdownButtonFormField<int>(
key: ValueKey('path-hash-$_selectedPathHashMode'),
initialValue: _selectedPathHashMode,
decoration: InputDecoration(
labelText: 'Advert path hash size',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
filled: true,
fillColor: colorScheme.surfaceContainerLowest,
helperText:
'Controls the low-level hash size used in adverts and flood paths.',
),
items: const [
DropdownMenuItem(
value: 0,
child: Text('1 byte (mode 0)'),
),
DropdownMenuItem(
value: 1,
child: Text('2 bytes (mode 1)'),
),
DropdownMenuItem(
value: 2,
child: Text('3 bytes (mode 2)'),
),
],
onChanged: (int? newValue) {
if (newValue != null) {
setState(() {
_selectedPathHashMode = newValue;
});
_markRadioSettingsDirty();
}
},
),
],
], ],
), ),
), ),
@@ -1914,6 +2278,26 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
return '$storageKb KB'; return '$storageKb KB';
} }
String _formatBlePin(int? blePin) {
if (blePin == null) {
return AppLocalizations.of(context)!.unknown;
}
return blePin.toString().padLeft(6, '0');
}
String _pathHashModeLabel(int mode) {
switch (mode) {
case 0:
return '1 byte';
case 1:
return '2 bytes';
case 2:
return '3 bytes';
default:
return 'Mode $mode';
}
}
} }
class _ConfigHeroCard extends StatelessWidget { class _ConfigHeroCard extends StatelessWidget {
@@ -2153,17 +2537,52 @@ class _ConfigSectionCard extends StatelessWidget {
} }
} }
class _ConfigDropdownField<T> extends StatelessWidget {
final String label;
final T value;
final List<DropdownMenuItem<T>> items;
final ValueChanged<T?> onChanged;
const _ConfigDropdownField({
required this.label,
required this.value,
required this.items,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return DropdownButtonFormField<T>(
initialValue: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20)),
filled: true,
fillColor: colorScheme.surfaceContainerLowest,
),
items: items,
onChanged: onChanged,
);
}
}
class _StorageStat extends StatelessWidget { class _StorageStat extends StatelessWidget {
final String label; final String label;
final String value; final String value;
final bool compact;
const _StorageStat({required this.label, required this.value}); const _StorageStat({
required this.label,
required this.value,
this.compact = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return Container(
padding: const EdgeInsets.all(14), padding: EdgeInsets.all(compact ? 12 : 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest, color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -2177,14 +2596,16 @@ class _StorageStat extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontSize: compact ? 13 : null,
), ),
), ),
const SizedBox(height: 6), SizedBox(height: compact ? 4 : 6),
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontSize: compact ? 17 : null,
), ),
), ),
], ],

View File

@@ -9,10 +9,12 @@ import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../widgets/compact_signal_indicator.dart' show SignalMetric; import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryMenuAction { repeaters, sensors } enum _DiscoveryListFilter { all, repeaters, sensors, others }
class DiscoveryScreen extends StatefulWidget { class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key}); final bool autoDiscoverRepeatersOnOpen;
const DiscoveryScreen({super.key, this.autoDiscoverRepeatersOnOpen = false});
@override @override
State<DiscoveryScreen> createState() => _DiscoveryScreenState(); State<DiscoveryScreen> createState() => _DiscoveryScreenState();
@@ -23,7 +25,11 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
static const int _sensorAdvertType = 4; static const int _sensorAdvertType = 4;
final Set<String> _resolvingAdvertKeys = <String>{}; final Set<String> _resolvingAdvertKeys = <String>{};
final Set<int> _runningDiscoveryTypes = <int>{}; final Set<int> _runningDiscoveryTypes = <int>{};
final TextEditingController _searchController = TextEditingController();
bool _isResolvingAll = false; bool _isResolvingAll = false;
bool _hasQueuedAutoRepeaterDiscovery = false;
String _searchQuery = '';
_DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all;
late final Future<List<MeshMapNode>> _cachedNodesFuture; late final Future<List<MeshMapNode>> _cachedNodesFuture;
@override @override
@@ -32,17 +38,19 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes( _cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl, cacheTtl: MeshMapNodesService.traceCacheTtl,
); );
if (widget.autoDiscoverRepeatersOnOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_autoDiscoverRepeaters();
});
}
} }
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async { @override
switch (action) { void dispose() {
case _DiscoveryMenuAction.repeaters: _searchController.dispose();
await _discoverNodeType(_repeaterAdvertType); super.dispose();
break;
case _DiscoveryMenuAction.sensors:
await _discoverNodeType(_sensorAdvertType);
break;
}
} }
Future<void> _clearAllDiscoveries() async { Future<void> _clearAllDiscoveries() async {
@@ -80,7 +88,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries)), SnackBar(
content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries),
),
); );
} }
@@ -96,9 +106,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
advertType: advertType, advertType: advertType,
); );
if (!mounted) return; if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
final label = switch (advertType) { final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent', _repeaterAdvertType => l10n.repeaterDiscoverySent,
_sensorAdvertType => 'Sensor discovery sent', _sensorAdvertType => l10n.sensorDiscoverySent,
_ => 'Discovery sent', _ => 'Discovery sent',
}; };
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -113,6 +124,14 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
} }
Future<void> _autoDiscoverRepeaters() async {
if (_hasQueuedAutoRepeaterDiscovery) {
return;
}
_hasQueuedAutoRepeaterDiscovery = true;
await _discoverNodeType(_repeaterAdvertType);
}
Future<void> _resolveAdvert(PendingAdvert advert) async { Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex; final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return; if (_resolvingAdvertKeys.contains(keyHex)) return;
@@ -293,8 +312,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}; };
} }
String? _typeLabelForAdvert(PendingAdvert advert) { String? _typeLabelForValue(int? typeValue) {
return switch (advert.typeValue) { return switch (typeValue) {
_repeaterAdvertType => 'Repeater', _repeaterAdvertType => 'Repeater',
_sensorAdvertType => 'Sensor', _sensorAdvertType => 'Sensor',
3 => 'Room', 3 => 'Room',
@@ -303,22 +322,22 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}; };
} }
String? _resolvedTypeLabelForAdvert( int? _resolvedTypeValueForAdvert(
PendingAdvert advert, PendingAdvert advert,
List<MeshMapNode> cachedNodes, List<MeshMapNode> cachedNodes,
) { ) {
final directType = _typeLabelForAdvert(advert); final directType = advert.typeValue;
if (directType != null) { if (directType != null && directType != 0) {
return directType; return directType;
} }
for (final node in cachedNodes) { for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) { if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
return switch (node.type) { return switch (node.type) {
1 => 'Repeater', 1 => _repeaterAdvertType,
4 => 'Sensor', 4 => _sensorAdvertType,
3 => 'Room', 3 => 3,
2 => 'Chat', 2 => 1,
_ => null, _ => null,
}; };
} }
@@ -327,6 +346,81 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return null; return null;
} }
String? _resolvedTypeLabelForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
return _typeLabelForValue(_resolvedTypeValueForAdvert(advert, cachedNodes));
}
bool get _hasActiveInlineFilter =>
_searchQuery.trim().isNotEmpty ||
_selectedFilter != _DiscoveryListFilter.all;
bool _matchesSelectedFilter(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
if (_selectedFilter == _DiscoveryListFilter.all) {
return true;
}
final resolvedType = _resolvedTypeValueForAdvert(advert, cachedNodes);
return switch (_selectedFilter) {
_DiscoveryListFilter.all => true,
_DiscoveryListFilter.repeaters => resolvedType == _repeaterAdvertType,
_DiscoveryListFilter.sensors => resolvedType == _sensorAdvertType,
_DiscoveryListFilter.others =>
resolvedType != _repeaterAdvertType &&
resolvedType != _sensorAdvertType,
};
}
bool _matchesSearchQuery(
PendingAdvert advert,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
final query = _searchQuery.trim().toLowerCase();
if (query.isEmpty) {
return true;
}
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
).toLowerCase();
final typeLabel = (_resolvedTypeLabelForAdvert(advert, cachedNodes) ?? '')
.toLowerCase();
return displayName.contains(query) ||
advert.publicKeyHex.toLowerCase().contains(query) ||
advert.shortDisplayKey.toLowerCase().contains(query) ||
typeLabel.contains(query);
}
List<PendingAdvert> _filteredPendingAdverts(
List<PendingAdvert> adverts,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
return adverts
.where((advert) => _matchesSelectedFilter(advert, cachedNodes))
.where(
(advert) =>
_matchesSearchQuery(advert, contactsProvider, cachedNodes),
)
.toList();
}
String _summaryTitle({required int totalCount, required int filteredCount}) {
if (filteredCount == totalCount) {
return 'Discovered nodes ($totalCount)';
}
return 'Discovered nodes ($filteredCount/$totalCount)';
}
Widget _buildAdvertTitle( Widget _buildAdvertTitle(
BuildContext context, { BuildContext context, {
required String displayName, required String displayName,
@@ -362,193 +456,236 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(l10n.discovery)),
title: Text(l10n.discovery),
actions: [
Consumer<ConnectionProvider>(
builder: (context, connectionProvider, child) {
final isConnected = connectionProvider.deviceInfo.isConnected;
final repeatersBusy = _runningDiscoveryTypes.contains(
_repeaterAdvertType,
);
final sensorsBusy = _runningDiscoveryTypes.contains(
_sensorAdvertType,
);
return PopupMenuButton<_DiscoveryMenuAction>(
tooltip: 'Discovery tools',
onSelected: _handleMenuAction,
itemBuilder: (context) => [
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.repeaters,
enabled: isConnected && !repeatersBusy,
child: Row(
children: [
repeatersBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.router_outlined),
SizedBox(width: 12),
Text(l10n.discoverRepeaters),
],
),
),
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.sensors,
enabled: isConnected && !sensorsBusy,
child: Row(
children: [
sensorsBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sensors_outlined),
SizedBox(width: 12),
Text(l10n.discoverSensors),
],
),
),
],
);
},
),
],
),
body: FutureBuilder<List<MeshMapNode>>( body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture, future: _cachedNodesFuture,
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>( builder: (context, nodesSnapshot) =>
builder: (context, contactsProvider, connectionProvider, child) { Consumer2<ContactsProvider, ConnectionProvider>(
final pendingAdverts = contactsProvider.pendingAdverts; builder: (context, contactsProvider, connectionProvider, child) {
final isConnected = connectionProvider.deviceInfo.isConnected; final pendingAdverts = contactsProvider.pendingAdverts;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[]; final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
final repeatersBusy = _runningDiscoveryTypes.contains(
_repeaterAdvertType,
);
final sensorsBusy = _runningDiscoveryTypes.contains(
_sensorAdvertType,
);
// Track which pending adverts are already in the contacts list // Track which pending adverts are already in the contacts list
final resolvedKeySet = <String>{}; final resolvedKeySet = <String>{};
for (final advert in pendingAdverts) { for (final advert in pendingAdverts) {
if (contactsProvider.findContactByKey(advert.publicKey) != null) { if (contactsProvider.findContactByKey(advert.publicKey) !=
resolvedKeySet.add(advert.publicKeyHex); null) {
} resolvedKeySet.add(advert.publicKeyHex);
} }
}
final totalCount = pendingAdverts.length; final totalCount = pendingAdverts.length;
final filteredAdverts = _filteredPendingAdverts(
pendingAdverts,
contactsProvider,
cachedNodes,
);
final filteredCount = filteredAdverts.length;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Icons.person_search), Row(
const SizedBox(width: 12), crossAxisAlignment: CrossAxisAlignment.center,
Expanded( children: [
child: Text( const Icon(Icons.person_search),
'Discovered nodes ($totalCount)', const SizedBox(width: 12),
style: Theme.of(context).textTheme.titleMedium, Expanded(
child: Text(
_summaryTitle(
totalCount: totalCount,
filteredCount: filteredCount,
),
style: Theme.of(
context,
).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'Resolve entries manually so they do not auto-populate contacts.',
style: Theme.of(context).textTheme.bodyMedium,
),
if (totalCount > 0) ...[
const SizedBox(height: 12),
_buildInlineSearchField(context),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildTypeFilterChip(
context,
label: l10n.all,
filter: _DiscoveryListFilter.all,
),
_buildTypeFilterChip(
context,
label: l10n.repeatersFilter,
filter: _DiscoveryListFilter.repeaters,
),
_buildTypeFilterChip(
context,
label: l10n.sensors,
filter: _DiscoveryListFilter.sensors,
),
_buildTypeFilterChip(
context,
label: l10n.others,
filter: _DiscoveryListFilter.others,
),
],
), ),
],
const SizedBox(height: 14),
LayoutBuilder(
builder: (context, constraints) {
const spacing = 10.0;
final useTwoColumns =
constraints.maxWidth >= 360;
final buttonWidth = useTwoColumns
? (constraints.maxWidth - spacing) / 2
: constraints.maxWidth;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
_buildHeaderActionButton(
width: buttonWidth,
onPressed: isConnected && !repeatersBusy
? () => _discoverNodeType(
_repeaterAdvertType,
)
: null,
icon: repeatersBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.router_outlined),
label: Text(l10n.discoverRepeaters),
),
_buildHeaderActionButton(
width: buttonWidth,
onPressed: isConnected && !sensorsBusy
? () => _discoverNodeType(
_sensorAdvertType,
)
: null,
icon: sensorsBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sensors_outlined),
label: Text(l10n.discoverSensors),
),
_buildHeaderActionButton(
width: buttonWidth,
onPressed:
isConnected &&
pendingAdverts.isNotEmpty &&
!_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons
.download_for_offline_outlined,
),
label: Text(l10n.resolveAll),
),
_buildHeaderActionButton(
width: buttonWidth,
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: const Icon(Icons.clear_all_rounded),
label: Text(l10n.clearAllLabel),
),
],
);
},
), ),
], ],
), ),
const SizedBox(height: 12), ),
Text( ),
'Resolve entries manually so they do not auto-populate contacts.', const SizedBox(height: 16),
style: Theme.of(context).textTheme.bodyMedium, if (totalCount == 0)
), Padding(
const SizedBox(height: 14), padding: const EdgeInsets.symmetric(vertical: 48),
Row( child: Column(
children: [ children: [
Expanded( Icon(
child: OutlinedButton.icon( Icons.person_search_outlined,
onPressed: size: 64,
isConnected && color: Theme.of(context).disabledColor,
pendingAdverts.isNotEmpty &&
!_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons.download_for_offline_outlined,
),
label: Text(l10n.resolveAll),
),
), ),
const SizedBox(width: 10), const SizedBox(height: 16),
Expanded( Text(
child: OutlinedButton.icon( 'No discovered nodes',
onPressed: pendingAdverts.isNotEmpty style: Theme.of(context).textTheme.titleLarge,
? _clearAllDiscoveries ),
: null, const SizedBox(height: 8),
icon: Icon(Icons.clear_all_rounded), Text(
label: Text(l10n.clearAllLabel), 'Use the discovery actions above to find repeaters and sensors on the mesh.',
), textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
), ),
], ],
), ),
], ),
if (_hasActiveInlineFilter &&
totalCount > 0 &&
filteredCount == 0)
_buildNoFilterResults(context),
...filteredAdverts.map(
(advert) => _buildPendingAdvertCard(
context,
advert: advert,
contactsProvider: contactsProvider,
isConnected: isConnected,
isResolved: resolvedKeySet.contains(
advert.publicKeyHex,
),
cachedNodes: cachedNodes,
l10n: l10n,
),
), ),
), ],
), );
const SizedBox(height: 16), },
if (totalCount == 0) ),
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No discovered nodes',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Use the menu to discover repeaters and sensors on the mesh.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
...pendingAdverts.map((advert) =>
_buildPendingAdvertCard(
context,
advert: advert,
contactsProvider: contactsProvider,
isConnected: isConnected,
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
cachedNodes: cachedNodes,
l10n: l10n,
),
),
],
);
},
),
), ),
); );
} }
@@ -563,7 +700,11 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
required AppLocalizations l10n, required AppLocalizations l10n,
}) { }) {
final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex); final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex);
final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes); final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes); final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes);
final downMetric = SignalMetric.fromValues( final downMetric = SignalMetric.fromValues(
rssiDbm: advert.rxRssiDbm, rssiDbm: advert.rxRssiDbm,
@@ -652,6 +793,180 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
); );
} }
Widget _buildInlineSearchField(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final hasFilter = _searchQuery.trim().isNotEmpty;
return Material(
color: Colors.transparent,
child: Ink(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: hasFilter
? colorScheme.primary.withValues(alpha: 0.38)
: colorScheme.outline.withValues(alpha: 0.32),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.025),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 42,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 12, right: 8),
child: Icon(
Icons.search_rounded,
size: 17,
color: hasFilter
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
cursorColor: colorScheme.primary,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.1,
),
decoration: InputDecoration(
hintText: 'Search discovered nodes',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.85,
),
),
filled: true,
fillColor: Colors.transparent,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
),
),
if (hasFilter)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Material(
color: colorScheme.primary.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: 14,
color: colorScheme.primary,
),
),
),
),
),
],
),
),
),
),
);
}
Widget _buildTypeFilterChip(
BuildContext context, {
required String label,
required _DiscoveryListFilter filter,
}) {
final scheme = Theme.of(context).colorScheme;
final selected = _selectedFilter == filter;
final color = selected ? scheme.primary : scheme.outline;
return InkWell(
onTap: () {
if (_selectedFilter == filter) {
return;
}
setState(() {
_selectedFilter = filter;
});
},
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: color.withValues(alpha: selected ? 0.45 : 0.22),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
),
),
),
);
}
Widget _buildNoFilterResults(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
child: Text(
'No matches',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildHeaderActionButton({
required double width,
required VoidCallback? onPressed,
required Widget icon,
required Widget label,
}) {
return SizedBox(
width: width,
child: OutlinedButton.icon(
onPressed: onPressed,
icon: icon,
label: label,
),
);
}
Widget _buildSignalSummary( Widget _buildSignalSummary(
BuildContext context, { BuildContext context, {
SignalMetric? downMetric, SignalMetric? downMetric,

View File

@@ -8,7 +8,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart'; import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/contact.dart';
import '../models/device_info.dart' show ConnectionMode, DeviceInfo; import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
@@ -24,13 +23,11 @@ import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
import 'live_traffic_screen.dart'; import 'live_traffic_screen.dart';
import 'profiles_screen.dart'; import 'profiles_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart'; import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart'; import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/profile_device_key_resolver.dart'; import '../services/profile_device_key_resolver.dart';
import '../services/profile_manager.dart'; import '../services/profile_manager.dart';
@@ -69,7 +66,6 @@ class _HomeScreenState extends State<HomeScreen>
int _currentIndex = 0; int _currentIndex = 0;
bool _isMapFullscreen = false; bool _isMapFullscreen = false;
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool _isContactsEnabled = true; bool _isContactsEnabled = true;
bool _isSensorsEnabled = false; bool _isSensorsEnabled = false;
@@ -108,7 +104,6 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller. // Initialize synchronously so first build always has a valid controller.
_initTabController(); _initTabController();
_loadRxTxPreference(); _loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale(); MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
@@ -292,14 +287,6 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
Future<void> _loadDeveloperModePreference() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
void _showPermissionDialog() { void _showPermissionDialog() {
if (!mounted) return; if (!mounted) return;
@@ -439,133 +426,129 @@ class _HomeScreenState extends State<HomeScreen>
// Request self telemetry so it's fresh // Request self telemetry so it's fresh
context.read<ConnectionProvider>().requestSelfTelemetry(); context.read<ConnectionProvider>().requestSelfTelemetry();
// Find the device's own contact to show self telemetry
final selfKey = deviceInfo.publicKey;
Contact? selfContact;
if (selfKey != null) {
selfContact = context.read<ContactsProvider>().findContactByKey(
Uint8List.fromList(selfKey),
);
}
final telemetry = selfContact?.telemetry;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (context) => SafeArea( builder: (context) => Consumer<ContactsProvider>(
child: Padding( builder: (context, contactsProvider, child) {
padding: const EdgeInsets.all(20), final telemetry = contactsProvider.selfTelemetry;
child: Column(
mainAxisSize: MainAxisSize.min, return SafeArea(
children: [ child: Padding(
Container( padding: const EdgeInsets.all(20),
width: 40, child: Column(
height: 4, mainAxisSize: MainAxisSize.min,
decoration: BoxDecoration( children: [
color: Theme.of(context).dividerColor, Container(
borderRadius: BorderRadius.circular(2), width: 40,
), height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text(
deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_deviceInfoRow(
context,
Icons.bluetooth,
'BLE Signal',
deviceInfo.signalRssi != null
? '${deviceInfo.signalRssi} dBm'
: 'N/A',
),
if (deviceInfo.batteryPercent != null)
_deviceInfoRow(
context,
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
'Battery',
'${deviceInfo.batteryPercent!.round()}%',
),
if (deviceInfo.batteryMilliVolts != null)
_deviceInfoRow(
context,
Icons.bolt,
'Voltage',
'${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V',
),
if (deviceInfo.storageUsedKb != null &&
deviceInfo.storageTotalKb != null)
_deviceInfoRow(
context,
Icons.storage,
'Storage',
'${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB',
),
if (deviceInfo.firmwareVersion != null)
_deviceInfoRow(
context,
Icons.system_update,
'Firmware',
'v${deviceInfo.firmwareVersion}',
),
if (deviceInfo.radioFreq != null)
_deviceInfoRow(
context,
Icons.radio,
'Frequency',
'${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz',
),
if (deviceInfo.txPower != null)
_deviceInfoRow(
context,
Icons.power,
'TX Power',
'${deviceInfo.txPower} dBm',
),
if (telemetry != null) ...[
const Divider(height: 24),
Text(
'Self Telemetry',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (telemetry.temperature != null)
_deviceInfoRow(
context,
Icons.thermostat,
'Temperature',
'${telemetry.temperature!.toStringAsFixed(1)}°C',
),
if (telemetry.humidity != null)
_deviceInfoRow(
context,
Icons.water_drop,
'Humidity',
'${telemetry.humidity!.toStringAsFixed(1)}%',
),
if (telemetry.pressure != null)
_deviceInfoRow(
context,
Icons.compress,
'Pressure',
'${telemetry.pressure!.toStringAsFixed(1)} hPa',
),
if (telemetry.gpsLocation != null)
_deviceInfoRow(
context,
Icons.gps_fixed,
'GPS',
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
),
],
],
), ),
const SizedBox(height: 16), ),
Text( );
deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device', },
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_deviceInfoRow(
context,
Icons.bluetooth,
'BLE Signal',
deviceInfo.signalRssi != null
? '${deviceInfo.signalRssi} dBm'
: 'N/A',
),
if (deviceInfo.batteryPercent != null)
_deviceInfoRow(
context,
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
'Battery',
'${deviceInfo.batteryPercent!.round()}%',
),
if (deviceInfo.batteryMilliVolts != null)
_deviceInfoRow(
context,
Icons.bolt,
'Voltage',
'${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V',
),
if (deviceInfo.storageUsedKb != null &&
deviceInfo.storageTotalKb != null)
_deviceInfoRow(
context,
Icons.storage,
'Storage',
'${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB',
),
if (deviceInfo.firmwareVersion != null)
_deviceInfoRow(
context,
Icons.system_update,
'Firmware',
'v${deviceInfo.firmwareVersion}',
),
if (deviceInfo.radioFreq != null)
_deviceInfoRow(
context,
Icons.radio,
'Frequency',
'${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz',
),
if (deviceInfo.txPower != null)
_deviceInfoRow(
context,
Icons.power,
'TX Power',
'${deviceInfo.txPower} dBm',
),
if (telemetry != null) ...[
const Divider(height: 24),
Text(
'Self Telemetry',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (telemetry.temperature != null)
_deviceInfoRow(
context,
Icons.thermostat,
'Temperature',
'${telemetry.temperature!.toStringAsFixed(1)}°C',
),
if (telemetry.humidity != null)
_deviceInfoRow(
context,
Icons.water_drop,
'Humidity',
'${telemetry.humidity!.toStringAsFixed(1)}%',
),
if (telemetry.pressure != null)
_deviceInfoRow(
context,
Icons.compress,
'Pressure',
'${telemetry.pressure!.toStringAsFixed(1)} hPa',
),
if (telemetry.gpsLocation != null)
_deviceInfoRow(
context,
Icons.gps_fixed,
'GPS',
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
),
],
],
),
),
), ),
); );
} }
@@ -587,9 +570,9 @@ class _HomeScreenState extends State<HomeScreen>
), ),
Text( Text(
value, value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.w600, context,
), ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
), ),
], ],
), ),
@@ -754,12 +737,10 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
void _showConnectionDialog(BuildContext context) { Future<void> _showConnectionDialog(BuildContext context) async {
showModalBottomSheet( await showConnectionDialogFlow(
context: context, context,
isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => const ConnectionDialog(),
); );
} }
@@ -816,32 +797,6 @@ class _HomeScreenState extends State<HomeScreen>
.read<ProfileManager>() .read<ProfileManager>()
.profilesEnabled; .profilesEnabled;
if (_isDeveloperModeEnabled) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
SizedBox(width: 8),
Text(AppLocalizations.of(context)!.spectrumScan),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const SpectrumScanScreen(),
),
);
});
},
),
);
}
items.add( items.add(
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@@ -959,7 +914,6 @@ class _HomeScreenState extends State<HomeScreen>
), ),
); );
_loadRxTxPreference(); _loadRxTxPreference();
_loadDeveloperModePreference();
}); });
}, },
), ),
@@ -1224,58 +1178,56 @@ class _HomeScreenState extends State<HomeScreen>
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
GestureDetector( GestureDetector(
onTap: () => _showDeviceInfoSheet( onTap: () =>
context, _showDeviceInfoSheet(context, deviceInfo),
deviceInfo,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isTcpConnected isTcpConnected
? Icons.wifi_rounded ? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded, : Icons.bluetooth_connected_rounded,
size: 13, size: 13,
color: signalColor,
),
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor, color: signalColor,
), ),
], if (!isTcpConnected &&
if (deviceInfo.batteryPercent != null) ...[ deviceInfo.signalRssi != null) ...[
const SizedBox(width: 8), const SizedBox(width: 4),
Icon( _buildMiniSignalBars(
BatteryDisplayHelper.getBatteryIcon( activeBars:
deviceInfo.batteryPercent!, BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor,
), ),
size: 13, ],
color: if (deviceInfo.batteryPercent != null) ...[
BatteryDisplayHelper.getBatteryColor( const SizedBox(width: 8),
deviceInfo.batteryPercent!, Icon(
), BatteryDisplayHelper.getBatteryIcon(
), deviceInfo.batteryPercent!,
const SizedBox(width: 2), ),
Text( size: 13,
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color:
BatteryDisplayHelper.getBatteryColor( BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!, deviceInfo.batteryPercent!,
), ),
), ),
), const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
], ],
], ),
),
), ),
], ],
), ),

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

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -10,6 +11,7 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart'; import 'package:flutter_compass/flutter_compass.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../services/compass_support.dart';
import '../utils/slovenian_crs.dart'; import '../utils/slovenian_crs.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
@@ -27,7 +29,6 @@ import '../services/background_location_service.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart'; import '../services/map_marker_service.dart';
import '../services/message_destination_preferences.dart'; import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart';
import '../services/profiles_feature_service.dart'; import '../services/profiles_feature_service.dart';
import '../widgets/map_debug_info.dart'; import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart'; import '../widgets/map/compass_widget.dart';
@@ -201,46 +202,64 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
} }
void _startCompassTracking() { void _startCompassTracking() {
if (!CompassSupport.isAvailable) {
return;
}
final compassStream = FlutterCompass.events; final compassStream = FlutterCompass.events;
if (compassStream == null) { if (compassStream == null) {
return; return;
} }
// Start listening to compass events // Start listening to compass events
_compassStreamSubscription = compassStream.listen((CompassEvent event) { _compassStreamSubscription = compassStream.listen(
// Check if widget is disposing, mounted, and event has valid heading (CompassEvent event) {
if (_isDisposing || !mounted || event.heading == null) return; // Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
try { try {
setState(() { setState(() {
_compassHeading = event.heading; _compassHeading = event.heading;
}); });
// Rotate map if rotation mode is enabled and we have compass heading // Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready // Only rotate if map is ready
if (_rotateMarkerWithHeading && if (_rotateMarkerWithHeading &&
event.heading != null && event.heading != null &&
_isMapReady && _isMapReady &&
!_isDisposing) { !_isDisposing) {
try { try {
// Use moveAndRotate to set absolute rotation // Use moveAndRotate to set absolute rotation
final camera = _mapController.camera; final camera = _mapController.camera;
_mapController.moveAndRotate( _mapController.moveAndRotate(
camera.center, camera.center,
camera.zoom, camera.zoom,
-event.heading!, -event.heading!,
); );
} catch (e) { } catch (e) {
// Map not ready yet, ignore // Map not ready yet, ignore
}
}
} catch (e) {
// Widget disposed during setState, ignore
if (!_isDisposing) {
debugPrint('Compass tracking error: $e');
} }
} }
} catch (e) { },
// Widget disposed during setState, ignore onError: (Object error, StackTrace stackTrace) {
if (!_isDisposing) { if (_isDisposing) {
debugPrint('Compass tracking error: $e'); return;
} }
}
}); if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass tracking stream error: $error');
},
);
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
@@ -873,7 +892,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!hasCustomMap) if (!hasCustomMap)
ListTile( ListTile(
leading: Icon(Icons.add_photo_alternate), leading: Icon(Icons.add_photo_alternate),
title: Text(AppLocalizations.of(context)!.loadFromGallery), title: Text(
AppLocalizations.of(context)!.loadFromGallery,
),
subtitle: const Text( subtitle: const Text(
'Use a cave map image instead of GPS tiles', 'Use a cave map image instead of GPS tiles',
), ),
@@ -905,7 +926,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
), ),
ListTile( ListTile(
leading: Icon(Icons.swap_horizontal_circle), leading: Icon(Icons.swap_horizontal_circle),
title: Text(AppLocalizations.of(context)!.replaceImage), title: Text(
AppLocalizations.of(context)!.replaceImage,
),
subtitle: const Text( subtitle: const Text(
'Pick a different map from the gallery', 'Pick a different map from the gallery',
), ),
@@ -941,7 +964,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (customMapConfig.isCalibrated) if (customMapConfig.isCalibrated)
ListTile( ListTile(
leading: Icon(Icons.clear), leading: Icon(Icons.clear),
title: Text(AppLocalizations.of(context)!.clearScale), title: Text(
AppLocalizations.of(context)!.clearScale,
),
onTap: () async { onTap: () async {
await mapProvider.clearCustomMapCalibration(); await mapProvider.clearCustomMapCalibration();
if (!context.mounted) return; if (!context.mounted) return;
@@ -999,7 +1024,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
), ),
child: DetailedCompassDialog( child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition, initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading, initialHeading: _compassHeading,
contacts: contacts, contacts: contacts,
sarMarkers: sarMarkers, sarMarkers: sarMarkers,
), ),
@@ -1025,7 +1050,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
), ),
child: DetailedCompassDialog( child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition, initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading, initialHeading: _compassHeading,
contacts: contacts, contacts: contacts,
sarMarkers: sarMarkers, sarMarkers: sarMarkers,
preSelectedContact: selectedContact, preSelectedContact: selectedContact,
@@ -1277,9 +1302,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
_stopCustomMapCalibration(); _stopCustomMapCalibration();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.customMapScaleSaved))); content: Text(AppLocalizations.of(context)!.customMapScaleSaved),
),
);
} }
} }
@@ -1419,7 +1446,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: Icon(Icons.chat_bubble_outline), leading: Icon(Icons.chat_bubble_outline),
title: Text(AppLocalizations.of(context)!.openMessage), title: Text(AppLocalizations.of(context)!.openMessage),
subtitle: Text(AppLocalizations.of(context)!.jumpToTheRelatedSarMessage), subtitle: Text(
AppLocalizations.of(context)!.jumpToTheRelatedSarMessage,
),
onTap: () async { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await _openSarMarkerMessage( await _openSarMarkerMessage(
@@ -1655,7 +1684,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.pleaseSelectADestinationToSendSarMarker), content: Text(
AppLocalizations.of(
context,
)!.pleaseSelectADestinationToSendSarMarker,
),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -1785,7 +1818,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.sarMarkerBroadcastToPublicChannel), content: Text(
AppLocalizations.of(context)!.sarMarkerBroadcastToPublicChannel,
),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
@@ -2514,52 +2549,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
); );
}, },
), ),
// Contact trail polylines (rendered before user trail and markers)
Consumer<MapProvider>(
builder: (context, mapProvider, _) {
// Determine which contacts to show trails for
final contactsToShow = mapProvider.showAllContactTrails
? contactsWithLocation // Show all when master toggle is ON
: contactsWithLocation.where(
(contact) => mapProvider.isContactPathVisible(
contact.publicKeyHex,
),
); // Individual toggles
return PolylineLayer(
polylines: contactsToShow
.where(
(contact) => contact.advertHistory.length >= 2,
)
.map((contact) {
// Use TrailColorService for consistent, emoji-based colors
final color = TrailColorService.getTrailColor(
contact,
);
return Polyline(
points: contact.advertHistory
.map((advert) => advert.location)
.toList(),
color: color.withValues(
alpha: 0.95,
), // More opaque for better visibility
strokeWidth:
4.5, // Thicker for better visibility on all map backgrounds
borderColor: Colors.white.withValues(
alpha: 0.6,
), // Stronger border contrast
borderStrokeWidth: 2.0, // Wider border
// DASHED pattern to distinguish from solid user trail
pattern: StrokePattern.dashed(
segments: [8, 4],
),
);
})
.toList(),
);
},
),
// Location trail layer (rendered after paths, before drawings) // Location trail layer (rendered after paths, before drawings)
const LocationTrailLayer(), const LocationTrailLayer(),
], ],
@@ -2604,7 +2593,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
context: context, context: context,
mapRotation: _getMapRotation(), mapRotation: _getMapRotation(),
userPosition: _locationService.currentPosition, userPosition: _locationService.currentPosition,
estimatedLocations: contactsProvider.estimatedLocations, estimatedLocations:
contactsProvider.estimatedLocations,
onTap: (contact) { onTap: (contact) {
_showDetailedCompassWithContact( _showDetailedCompassWithContact(
context, context,

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@ 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/sensor_telemetry_card.dart'; import '../widgets/sensors/sensor_telemetry_card.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -20,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() {
@@ -60,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());
});
}); });
} }
@@ -100,70 +91,26 @@ class _SensorsTabState extends State<SensorsTab> {
Future<void> _showAddSensorSheet(BuildContext context) async { Future<void> _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final candidates = sensorsProvider.availableCandidates(contactsProvider); final candidates = sensorsProvider.availableCandidates(
contactsProvider,
connectionProvider: context.read<ConnectionProvider>(),
);
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
builder: (sheetContext) { isScrollControlled: true,
if (candidates.isEmpty) { builder: (sheetContext) => AddSensorSheet(
return const SafeArea( candidates: candidates,
child: Padding( onClose: () => Navigator.of(sheetContext).pop(),
padding: EdgeInsets.all(24), onSelect: (contact) async {
child: Text( await sensorsProvider.addSensor(contact);
'No eligible nodes available. Discover a relay or node first.', if (!sheetContext.mounted) return;
), Navigator.of(sheetContext).pop();
), ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${contact.displayName} added to Sensors')),
); );
} },
),
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 20),
children: [
ListTile(
title: const Text(
'Add sensor node',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(AppLocalizations.of(context)!.pickARelayOrNodeToWatchInSensors),
),
...candidates.map(
(contact) => ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFDDEAF8),
child: Icon(
_typeIcon(contact),
color: const Color(0xFF1E4F7A),
),
),
title: Text(contact.displayName),
subtitle: _SensorCandidatePreview(contact: contact),
isThreeLine: true,
onTap: () async {
await sensorsProvider.addSensor(contact);
if (!sheetContext.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${contact.displayName} added to Sensors',
),
),
);
},
),
),
],
),
);
},
); );
} }
@@ -174,7 +121,7 @@ class _SensorsTabState extends State<SensorsTab> {
) async { ) async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (pageContext) => _SensorCustomizeView( builder: (pageContext) => SensorCustomizeView(
publicKeyHex: publicKeyHex, publicKeyHex: publicKeyHex,
initialContact: contact, initialContact: contact,
onRenameMetric: onRenameMetric:
@@ -268,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(
@@ -275,30 +281,60 @@ class _SensorsTabState extends State<SensorsTab> {
onPressed: () => _showAddSensorSheet(context), onPressed: () => _showAddSensorSheet(context),
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
body: Consumer2<SensorsProvider, ContactsProvider>( body: Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
builder: (context, sensorsProvider, contactsProvider, child) { builder:
final watchedKeys = sensorsProvider.watchedSensorKeys; (
context,
sensorsProvider,
contactsProvider,
connectionProvider,
child,
) {
final watchedKeys = sensorsProvider.watchedSensorKeys;
final hasPersistedSensors = watchedKeys.isNotEmpty;
final selfDisplayKey = sensorsProvider.displaySelfKey(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final displayKeys = <String>[
...?selfDisplayKey == null ? null : <String>[selfDisplayKey],
...watchedKeys,
];
_maybeCenterMapOnTelemetryUpdate(
displayKeys,
sensorsProvider: sensorsProvider,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final hasAnyCards =
selfDisplayKey != null || watchedKeys.isNotEmpty;
return RefreshIndicator( Widget buildSensorCard(
onRefresh: () => _refreshAll(context), String key, {
child: ListView( required int index,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), required int totalCount,
children: [ required bool isWatchedCard,
if (watchedKeys.isEmpty) }) {
const _EmptySensorsState() final contact = sensorsProvider.contactForDisplay(
else key,
...watchedKeys.map((key) { contactsProvider: contactsProvider,
Contact? contact; connectionProvider: connectionProvider,
for (final entry in contactsProvider.contacts) { );
if (entry.publicKeyHex == key) { final availableFieldKeys = sensorMetricKeysFor(contact);
contact = entry; final visibleFields = sensorsProvider.effectiveVisibleFieldsFor(
break; key,
} availableFieldKeys,
} );
final availableFieldKeys = sensorMetricKeysFor(contact);
final visibleFields = sensorsProvider return Padding(
.effectiveVisibleFieldsFor(key, availableFieldKeys); key: ValueKey<String>('sensor_card_$key'),
return SensorTelemetryCard( padding: EdgeInsets.only(
bottom: index == totalCount - 1 ? 0 : 12,
),
child: ReorderableDelayedDragStartListener(
enabled: isWatchedCard,
index: index,
child: SensorTelemetryCard(
contact: contact, contact: contact,
state: sensorsProvider.stateFor(key), state: sensorsProvider.stateFor(key),
visibleFields: visibleFields, visibleFields: visibleFields,
@@ -311,30 +347,242 @@ class _SensorsTabState extends State<SensorsTab> {
for (final field in visibleFields) for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(key, field), field: sensorsProvider.fieldSpanFor(key, field),
}, },
onRemove: () async { onRemove: isWatchedCard
await sensorsProvider.removeSensor(key); ? () async {
}, await sensorsProvider.removeSensor(key);
}
: null,
onCustomize: () => onCustomize: () =>
_showMetricSelector(context, key, contact), _showMetricSelector(context, key, contact),
onShowMetHistory: (contact) =>
showBTHomeMetHistorySheet(context, contact: contact),
onMoveUp: isWatchedCard && index > 0
? () =>
sensorsProvider.reorderSensors(index, index - 1)
: null,
onMoveDown: isWatchedCard && index < totalCount - 1
? () =>
sensorsProvider.reorderSensors(index, index + 2)
: null,
onRefresh: () => sensorsProvider.refreshSensor( onRefresh: () => sensorsProvider.refreshSensor(
publicKeyHex: key, publicKeyHex: key,
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
connectionProvider: context.read<ConnectionProvider>(), connectionProvider: connectionProvider,
), ),
); ),
}), ),
], );
), }
);
}, return RefreshIndicator(
onRefresh: () => _refreshAll(context),
child: !hasAnyCards
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: const [_EmptySensorsState()],
)
: hasPersistedSensors && selfDisplayKey == null
? ReorderableListView.builder(
buildDefaultDragHandles: false,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
itemCount: watchedKeys.length,
onReorder: (oldIndex, newIndex) =>
sensorsProvider.reorderSensors(oldIndex, newIndex),
itemBuilder: (context, index) => buildSensorCard(
watchedKeys[index],
index: index,
totalCount: watchedKeys.length,
isWatchedCard: true,
),
)
: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
if (selfDisplayKey != null)
buildSensorCard(
selfDisplayKey,
index: 0,
totalCount: watchedKeys.isEmpty ? 1 : 2,
isWatchedCard: false,
),
if (selfDisplayKey != null && watchedKeys.isEmpty)
_SelfOnlySensorsCta(
onAddSensor: () => _showAddSensorSheet(context),
),
if (watchedKeys.isNotEmpty)
ReorderableListView.builder(
shrinkWrap: true,
buildDefaultDragHandles: false,
physics: const NeverScrollableScrollPhysics(),
itemCount: watchedKeys.length,
onReorder: (oldIndex, newIndex) => sensorsProvider
.reorderSensors(oldIndex, newIndex),
itemBuilder: (context, index) => buildSensorCard(
watchedKeys[index],
index: index,
totalCount: watchedKeys.length,
isWatchedCard: true,
),
),
],
),
);
},
), ),
); );
} }
} }
class _SensorCustomizeView extends StatelessWidget { class AddSensorSheet extends StatefulWidget {
final List<Contact> candidates;
final Future<void> Function(Contact contact) onSelect;
final VoidCallback onClose;
const AddSensorSheet({
super.key,
required this.candidates,
required this.onSelect,
required this.onClose,
});
@override
State<AddSensorSheet> createState() => _AddSensorSheetState();
}
class _AddSensorSheetState extends State<AddSensorSheet> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.candidates.isEmpty) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No eligible nodes available. Discover a relay or node first.',
),
),
);
}
final l10n = AppLocalizations.of(context)!;
final query = _searchController.text.trim().toLowerCase();
final filteredCandidates = widget.candidates.where((contact) {
if (query.isEmpty) {
return true;
}
return contact.displayName.toLowerCase().contains(query) ||
contact.publicKeyHex.toLowerCase().contains(query);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Add sensor node',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(l10n.pickARelayOrNodeToWatchInSensors),
],
),
),
IconButton(
onPressed: widget.onClose,
tooltip: l10n.close,
icon: const Icon(Icons.close),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Search sensors',
border: OutlineInputBorder(),
),
),
),
Expanded(
child: filteredCandidates.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
child: Align(
alignment: Alignment.topLeft,
child: Text(
'No sensor candidates match your search.',
),
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 20),
itemCount: filteredCandidates.length,
itemBuilder: (context, index) {
final contact = filteredCandidates[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFDDEAF8),
child: Icon(
_typeIcon(contact),
color: const Color(0xFF1E4F7A),
),
),
title: Text(contact.displayName),
subtitle: _SensorCandidatePreview(contact: contact),
isThreeLine: true,
onTap: () => widget.onSelect(contact),
);
},
),
),
],
),
),
),
);
}
}
class SensorCustomizeView extends StatelessWidget {
final String publicKeyHex; final String publicKeyHex;
final Contact? initialContact; final Contact? initialContact;
final bool showLivePreview;
final Future<void> Function({ final Future<void> Function({
required BuildContext context, required BuildContext context,
required String publicKeyHex, required String publicKeyHex,
@@ -343,10 +591,12 @@ class _SensorCustomizeView extends StatelessWidget {
}) })
onRenameMetric; onRenameMetric;
const _SensorCustomizeView({ const SensorCustomizeView({
super.key,
required this.publicKeyHex, required this.publicKeyHex,
required this.initialContact, required this.initialContact,
required this.onRenameMetric, required this.onRenameMetric,
this.showLivePreview = true,
}); });
@override @override
@@ -391,29 +641,39 @@ class _SensorCustomizeView extends StatelessWidget {
body: ListView( body: ListView(
padding: EdgeInsets.fromLTRB(16, 16, 16, 24), padding: EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [ children: [
_SensorCustomizeSectionCard( if (showLivePreview)
title: AppLocalizations.of(context)!.livePreview, _SensorCustomizeSectionCard(
subtitle: title: AppLocalizations.of(context)!.livePreview,
'Changes apply immediately. This card matches the current dashboard layout for this sensor.', subtitle:
child: SensorTelemetryCard( 'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
contact: contact, child: SensorTelemetryCard(
state: sensorsProvider.stateFor(publicKeyHex), contact: contact,
visibleFields: visibleFields, state: sensorsProvider.stateFor(publicKeyHex),
fieldOrder: sensorsProvider.metricOrderFor( visibleFields: visibleFields,
publicKeyHex, fieldOrder: sensorsProvider.metricOrderFor(
visibleFields, publicKeyHex,
visibleFields,
),
labelOverrides: sensorsProvider.labelOverridesFor(
publicKeyHex,
),
onShowMetHistory: contact == null
? null
: (contact) => showBTHomeMetHistorySheet(
context,
contact: contact,
),
fieldSpans: {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(
publicKeyHex,
field,
),
},
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
), ),
labelOverrides: sensorsProvider.labelOverridesFor(
publicKeyHex,
),
fieldSpans: {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(publicKeyHex, field),
},
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
), ),
),
_SensorCustomizeSectionCard( _SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.refreshSchedule, title: AppLocalizations.of(context)!.refreshSchedule,
subtitle: subtitle:
@@ -876,55 +1136,8 @@ class _EmptySensorsState extends StatefulWidget {
} }
class _EmptySensorsStateState extends State<_EmptySensorsState> { class _EmptySensorsStateState extends State<_EmptySensorsState> {
bool _discoveryTriggered = false;
bool _discoveryInProgress = false;
@override
void initState() {
super.initState();
// Auto-trigger sensor discovery when the empty state is shown
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoDiscover();
});
}
Future<void> _autoDiscover() async {
if (_discoveryTriggered || !mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) return;
_discoveryTriggered = true;
setState(() => _discoveryInProgress = true);
try {
await connectionProvider.discoverNodeType(advertType: 4);
} finally {
if (mounted) setState(() => _discoveryInProgress = false);
}
}
Future<void> _discoverSensors() async {
if (_discoveryInProgress || !mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) return;
setState(() => _discoveryInProgress = true);
try {
await connectionProvider.discoverNodeType(advertType: 4);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.sensorDiscoverySent)),
);
}
} finally {
if (mounted) setState(() => _discoveryInProgress = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isConnected =
context.watch<ConnectionProvider>().deviceInfo.isConnected;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 64), padding: const EdgeInsets.symmetric(vertical: 64),
child: Column( child: Column(
@@ -951,28 +1164,84 @@ class _EmptySensorsStateState extends State<_EmptySensorsState> {
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 24), padding: EdgeInsets.symmetric(horizontal: 24),
child: Text( child: Text(
'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.', 'Use + to add discovered relays or nodes. Your device will appear here automatically when available.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 20), ],
FilledButton.icon( ),
onPressed: isConnected && !_discoveryInProgress );
? _discoverSensors }
: null, }
icon: _discoveryInProgress
? const SizedBox( class _SelfOnlySensorsCta extends StatelessWidget {
width: 16, final VoidCallback onAddSensor;
height: 16,
child: CircularProgressIndicator( const _SelfOnlySensorsCta({required this.onAddSensor});
strokeWidth: 2,
color: Colors.white, @override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.add_chart_rounded,
color: theme.colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Add another device',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
), ),
) const SizedBox(height: 8),
: const Icon(Icons.sensors_outlined), Text(
label: Text(_discoveryInProgress 'Bring in weather stations, repeaters, or other devices to watch them here.',
? 'Discovering...' style: theme.textTheme.bodyMedium?.copyWith(
: 'Discover Sensors'), color: theme.colorScheme.onSurfaceVariant,
height: 1.35,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: onAddSensor,
icon: const Icon(Icons.add, size: 18),
label: const Text('Choose'),
),
), ),
], ],
), ),

View File

@@ -16,6 +16,7 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart'; import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart'; import '../providers/map_provider.dart';
import '../models/contact.dart';
import '../models/config_profile.dart'; import '../models/config_profile.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
@@ -24,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';
@@ -59,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;
@@ -79,6 +83,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false; bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0; double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10; int _fastLocationActiveCadenceSeconds = 10;
int? _fastLocationChannelIdx;
bool _rotateMapWithHeading = false; bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false; bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false; bool _openMapInFullscreen = false;
@@ -89,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;
@@ -112,6 +121,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadOnlineTraceCacheStatus(); _loadOnlineTraceCacheStatus();
_loadMapPreferences(); _loadMapPreferences();
_loadNotificationPreferences(); _loadNotificationPreferences();
_loadMessageDestinationLock();
} }
@override @override
@@ -182,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);
@@ -314,6 +412,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_locationService.fastLocationMovementThresholdMeters; _locationService.fastLocationMovementThresholdMeters;
_fastLocationActiveCadenceSeconds = _fastLocationActiveCadenceSeconds =
_locationService.fastLocationActiveCadenceSeconds; _locationService.fastLocationActiveCadenceSeconds;
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
}); });
} }
@@ -338,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: [
@@ -350,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'),
), ),
@@ -379,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: [
@@ -391,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'),
), ),
@@ -407,6 +506,97 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
String _describeFastLocationChannel(List<Contact> channels) {
final channelIdx = _fastLocationChannelIdx;
if (channelIdx == null) {
return 'Not set';
}
for (final channel in channels) {
final idx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
if (idx == channelIdx) {
return '${channel.getLocalizedDisplayName(context)} (slot $channelIdx)';
}
}
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 {
final channels =
List<Contact>.from(context.read<ContactsProvider>().channels)
..removeWhere((c) => c.isPublicChannel)
..sort((a, b) {
final aIdx = a.publicKey.length > 1 ? a.publicKey[1] : 0;
final bIdx = b.publicKey.length > 1 ? b.publicKey[1] : 0;
return aIdx.compareTo(bIdx);
});
final selected = await showModalBottomSheet<int?>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.block),
title: const Text('Disable fast GPS publishing'),
trailing: _fastLocationChannelIdx == null
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(sheetContext, -1),
),
for (final channel in channels)
ListTile(
leading: const Icon(Icons.tag),
title: Text(channel.getLocalizedDisplayName(sheetContext)),
subtitle: Text(
'Channel ${channel.publicKey.length > 1 ? channel.publicKey[1] : 0}',
),
trailing:
_fastLocationChannelIdx ==
(channel.publicKey.length > 1
? channel.publicKey[1]
: 0)
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(
sheetContext,
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
),
),
],
),
),
);
if (selected == null) return;
await _locationService.updateFastLocationChannelIdx(
selected < 0 ? null : selected,
);
if (!mounted) return;
setState(() {
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
});
}
Future<void> _saveImageMaxSize(int size) async { Future<void> _saveImageMaxSize(int size) async {
await ImagePreferences.setMaxSize(size); await ImagePreferences.setMaxSize(size);
if (!mounted) return; if (!mounted) return;
@@ -1336,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(
@@ -1463,21 +1737,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: Icon(Icons.timeline),
title: Text(
AppLocalizations.of(context)!.showAllContactTrailsLabel,
),
subtitle: const Text(
'Display location trails for all contacts that have history',
),
value: mapProvider.showAllContactTrails,
onChanged: (value) async {
await mapProvider.toggleAllContactTrails();
},
),
),
Consumer<MapProvider>( Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile( builder: (context, mapProvider, child) => SwitchListTile(
secondary: Icon(Icons.router_outlined), secondary: Icon(Icons.router_outlined),
@@ -1781,6 +2040,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence, onTap: _editFastLocationActiveCadence,
), ),
ListTile(
leading: const Icon(Icons.forum),
title: const Text('Fast GPS target channel'),
subtitle: Text(
_describeFastLocationChannel(
context.watch<ContactsProvider>().channels,
),
),
trailing: const Icon(Icons.chevron_right),
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

@@ -1,602 +0,0 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../providers/connection_provider.dart';
import '../widgets/device/spectrum_scan_panel.dart';
import '../l10n/app_localizations.dart';
class SpectrumScanScreen extends StatefulWidget {
const SpectrumScanScreen({super.key});
@override
State<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
}
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
static const List<String> _bandwidthOptions = [
'7.8 kHz',
'10.4 kHz',
'15.6 kHz',
'20.8 kHz',
'31.25 kHz',
'41.7 kHz',
'62.5 kHz',
'125 kHz',
'250 kHz',
'500 kHz',
];
String _selectedBandwidth = '62.5 kHz';
bool _isSpectrumScanRunning = false;
bool _rangeInitialized = false;
String? _lastRangeSourceKey;
late double _scanRangeMinMhz;
late double _scanRangeMaxMhz;
late RangeValues _scanRangeValues;
List<SpectrumScanCandidate> _scanCandidates = const [];
int? _selectedScanFrequencyKhz;
int _completedScanSectors = 0;
int _totalScanSectors = 0;
List<SpectrumScanCandidate> get _recommendedScanCandidates =>
_scanCandidates.take(8).toList();
@override
void initState() {
super.initState();
final deviceInfo = _deviceInfo;
if (deviceInfo.radioBw != null &&
deviceInfo.radioBw! >= 0 &&
deviceInfo.radioBw! < _bandwidthOptions.length) {
_selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!];
}
_syncRangeFromDevice(deviceInfo);
_selectedScanFrequencyKhz = deviceInfo.radioFreq;
}
DeviceInfo get _deviceInfo => context.read<ConnectionProvider>().deviceInfo;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncRangeFromDevice(context.watch<ConnectionProvider>().deviceInfo);
}
void _syncRangeFromDevice(DeviceInfo deviceInfo) {
final minKhz = deviceInfo.spectrumScanMinKhz;
final maxKhz = deviceInfo.spectrumScanMaxKhz;
late double normalizedMinMhz;
late double normalizedMaxMhz;
late String sourceKey;
if (minKhz != null && maxKhz != null && maxKhz > minKhz) {
final normalized = _normalizeScanRange(
minKhz / 1000.0,
maxKhz / 1000.0,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey = 'fw:$minKhz:$maxKhz';
} else {
final normalized = _normalizeScanRange(
null,
null,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey =
'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}';
}
if (_rangeInitialized && _lastRangeSourceKey == sourceKey) {
return;
}
_scanRangeMinMhz = normalizedMinMhz;
_scanRangeMaxMhz = normalizedMaxMhz;
if (_scanRangeMaxMhz <= _scanRangeMinMhz) {
_scanRangeMaxMhz = _scanRangeMinMhz + 0.5;
}
_scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz);
_rangeInitialized = true;
_lastRangeSourceKey = sourceKey;
}
(double, double) _normalizeScanRange(
double? minMhz,
double? maxMhz,
double? centerMhz,
) {
const hardMinMhz = 800.0;
const hardMaxMhz = 950.0;
final center = centerMhz ?? 869.525;
if (minMhz != null && maxMhz != null) {
final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz);
final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz);
if (clampedMax > clampedMin) {
return (clampedMin, clampedMax);
}
}
if (center >= 900.0 && center <= 930.0) {
return (902.0, 928.0);
}
return (863.0, 870.0);
}
double _bandwidthToKhz(String bw) {
switch (bw) {
case '7.8 kHz':
return 7.8;
case '10.4 kHz':
return 10.4;
case '15.6 kHz':
return 15.6;
case '20.8 kHz':
return 20.8;
case '31.25 kHz':
return 31.25;
case '41.7 kHz':
return 41.7;
case '62.5 kHz':
return 62.5;
case '125 kHz':
return 125.0;
case '250 kHz':
return 250.0;
case '500 kHz':
return 500.0;
default:
return 62.5;
}
}
String _currentParamProfile(DeviceInfo deviceInfo) {
final sf = deviceInfo.radioSf ?? 8;
final cr = deviceInfo.radioCr ?? 8;
return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr';
}
String _recommendationTitle(int index) {
switch (index) {
case 0:
return 'Best candidate';
case 1:
return 'Alternate';
case 2:
return 'Fallback';
default:
return 'Candidate ${index + 1}';
}
}
List<int> _possibleBracketFrequenciesKhz() {
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final halfBandwidthKhz = bandwidthKhz / 2.0;
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final firstCenterKhz = (startKhz + halfBandwidthKhz).round();
final lastCenterKhz = (stopKhz - halfBandwidthKhz).round();
if (lastCenterKhz < firstCenterKhz) {
return const [];
}
final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25;
final centers = <int>[];
for (
var centerKhz = firstCenterKhz;
centerKhz <= lastCenterKhz && centers.length < 8;
centerKhz += stepKhz
) {
centers.add(centerKhz);
}
if (centers.isEmpty || centers.last != lastCenterKhz) {
centers.add(lastCenterKhz);
}
return centers.toSet().toList()..sort();
}
void _resetDerivedScanResults() {
_scanCandidates = const [];
_selectedScanFrequencyKhz = null;
}
List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) {
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200);
final overlapKhz = bandwidthKhz.round().clamp(8, 500);
final sectors = <(int startKhz, int stopKhz)>[];
var sectorStartKhz = startKhz;
while (sectorStartKhz < stopKhz) {
final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp(
sectorStartKhz + overlapKhz,
stopKhz,
);
sectors.add((sectorStartKhz, sectorStopKhz));
if (sectorStopKhz >= stopKhz) {
break;
}
sectorStartKhz = sectorStopKhz - overlapKhz;
}
return sectors;
}
List<SpectrumScanCandidate> _mergeSectorCandidates(
Iterable<SpectrumScanCandidate> candidates,
) {
final byFrequency = <int, SpectrumScanCandidate>{};
for (final candidate in candidates) {
final existing = byFrequency[candidate.centerFrequencyKhz];
if (existing == null ||
candidate.occupancyPercent < existing.occupancyPercent ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm < existing.peakRssiDbm) ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm == existing.peakRssiDbm &&
candidate.avgRssiDbm < existing.avgRssiDbm)) {
byFrequency[candidate.centerFrequencyKhz] = candidate;
}
}
final merged = byFrequency.values.toList()
..sort((a, b) {
final occupancyCompare = a.occupancyPercent.compareTo(
b.occupancyPercent,
);
if (occupancyCompare != 0) return occupancyCompare;
final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm);
if (peakCompare != 0) return peakCompare;
return a.avgRssiDbm.compareTo(b.avgRssiDbm);
});
return merged;
}
Future<void> _runSpectrumScan() async {
final connectionProvider = context.read<ConnectionProvider>();
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final sectors = _buildScanSectors(bandwidthKhz);
final sectorCandidates = <SpectrumScanCandidate>[];
setState(() {
_isSpectrumScanRunning = true;
_resetDerivedScanResults();
_completedScanSectors = 0;
_totalScanSectors = sectors.length;
});
try {
for (var i = 0; i < sectors.length; i++) {
final sector = sectors[i];
final result = await connectionProvider.scanSpectrum(
startFrequencyKhz: sector.$1,
stopFrequencyKhz: sector.$2,
bandwidthKhz: bandwidthKhz.round(),
stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000),
dwellMs: 160,
thresholdDb: 8,
);
if (result != null) {
sectorCandidates.addAll(result.candidates);
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mounted) {
setState(() {
_scanCandidates = mergedCandidates;
if (_selectedScanFrequencyKhz == null &&
mergedCandidates.isNotEmpty) {
_selectedScanFrequencyKhz =
mergedCandidates.first.centerFrequencyKhz;
}
_completedScanSectors = i + 1;
});
}
} else if (mounted) {
setState(() {
_completedScanSectors = i + 1;
});
}
}
} finally {
if (mounted) {
setState(() {
_isSpectrumScanRunning = false;
if (_completedScanSectors == 0) {
_totalScanSectors = sectors.length;
}
});
}
}
if (!mounted) return;
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mergedCandidates.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.spectrumScanReturnedNoCandidateFrequencies),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_scanCandidates = mergedCandidates;
_selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz;
});
}
Future<void> _applySelectedScanFrequency() async {
if (_selectedScanFrequencyKhz == null) return;
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
await connectionProvider.setRadioParams(
frequency: _selectedScanFrequencyKhz!,
bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth),
spreadingFactor: deviceInfo.radioSf ?? 8,
codingRate: deviceInfo.radioCr ?? 8,
repeat: deviceInfo.clientRepeat,
);
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz',
),
backgroundColor: Colors.green,
),
);
}
int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) {
if (_selectedScanFrequencyKhz != null) {
return _selectedScanFrequencyKhz;
}
return deviceInfo.radioFreq;
}
@override
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context);
final possibleBracketFrequencies = _possibleBracketFrequenciesKhz();
final recommendedScanCandidates = _recommendedScanCandidates;
final recommendationFrequencies = recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates
.map((candidate) => candidate.centerFrequencyKhz)
.toList()
: possibleBracketFrequencies;
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.spectrumScan)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: _selectedBandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
helperText:
'Scan and apply frequencies for this bandwidth',
),
items: _bandwidthOptions.map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedBandwidth = value;
_resetDerivedScanResults();
});
},
),
const SizedBox(height: 16),
Text(
deviceInfo.spectrumScanMinKhz != null &&
deviceInfo.spectrumScanMaxKhz != null
? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz'
: 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_isSpectrumScanRunning) ...[
const SizedBox(height: 8),
Text(
'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 12),
SpectrumScanPanel(
theme: theme,
scanSupported: deviceInfo.supportsSpectrumScan == true,
isRunning: _isSpectrumScanRunning,
rangeMinMhz: _scanRangeMinMhz,
rangeMaxMhz: _scanRangeMaxMhz,
rangeValues: _scanRangeValues,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
selectedFrequencyKhz: _currentPreviewFrequencyKhz(
deviceInfo,
),
graphCandidates: _scanCandidates,
selectableCandidates: recommendedScanCandidates,
onRangeChanged: (values) {
setState(() {
_scanRangeValues = values;
_resetDerivedScanResults();
});
},
onCandidateChanged: (value) {
setState(() {
_selectedScanFrequencyKhz = value;
});
},
onRunScan: _runSpectrumScan,
onApplySelected: _applySelectedScanFrequency,
),
if (recommendationFrequencies.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
_scanCandidates.isNotEmpty
? 'Recommended profiles'
: 'Possible brackets',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
_scanCandidates.isNotEmpty
? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.'
: 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
for (
var i = 0;
i < recommendationFrequencies.length;
i++
) ...[
_RecommendationTile(
title: _recommendationTitle(i),
frequencyKhz: recommendationFrequencies[i],
candidate: recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates[i]
: null,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
paramsLabel: _currentParamProfile(deviceInfo),
isSelected:
_selectedScanFrequencyKhz ==
recommendationFrequencies[i],
onSelect: () {
setState(() {
_selectedScanFrequencyKhz =
recommendationFrequencies[i];
});
},
),
if (i != recommendationFrequencies.length - 1)
const SizedBox(height: 10),
],
],
],
),
),
),
],
),
);
}
}
class _RecommendationTile extends StatelessWidget {
final String title;
final int frequencyKhz;
final SpectrumScanCandidate? candidate;
final double bandwidthKhz;
final String paramsLabel;
final bool isSelected;
final VoidCallback onSelect;
const _RecommendationTile({
required this.title,
required this.frequencyKhz,
required this.candidate,
required this.bandwidthKhz,
required this.paramsLabel,
required this.isSelected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isSelected
? scheme.primaryContainer.withValues(alpha: 0.55)
: scheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? scheme.primary : scheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (isSelected)
Icon(Icons.check_circle, color: scheme.primary, size: 18),
],
),
const SizedBox(height: 8),
Text(
'${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(paramsLabel, style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
Text(
candidate != null
? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm'
: 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
);
}
}

View File

@@ -221,14 +221,13 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
} }
Future<void> _openConnectionDialog() async { Future<void> _openConnectionDialog() async {
await showModalBottomSheet( final connected = await showConnectionDialogFlow(
context: context, context,
isScrollControlled: true, offerPostConnectRepeaterDiscovery: true,
builder: (context) => const ConnectionDialog(),
); );
if (!mounted) return; if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
if (connectionProvider.deviceInfo.isConnected) { if (connected) {
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
} }
if (!mounted) return; if (!mounted) return;

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

@@ -0,0 +1,178 @@
import '../models/contact.dart';
enum BTHomeMetMeasurement {
temperature(1, 'Temperature', '°C'),
humidity(2, 'Humidity', '%'),
windSpeed(3, 'Wind speed', 'm/s'),
gust(4, 'Wind gust', 'm/s'),
rain(5, 'Rain', 'mm');
const BTHomeMetMeasurement(this.id, this.label, this.unit);
final int id;
final String label;
final String unit;
static BTHomeMetMeasurement? fromId(int id) {
for (final value in BTHomeMetMeasurement.values) {
if (value.id == id) {
return value;
}
}
return null;
}
}
class BTHomeMetHistoryPage {
const BTHomeMetHistoryPage({
required this.measurement,
required this.page,
required this.values,
});
final BTHomeMetMeasurement measurement;
final int page;
final List<double> values;
double? get latest => values.isEmpty ? null : values.last;
double? get minimum => values.isEmpty
? null
: values.reduce((left, right) => left < right ? left : right);
double? get maximum => values.isEmpty
? null
: values.reduce((left, right) => left > right ? left : right);
}
class BTHomeMetHistoryFormatException implements Exception {
const BTHomeMetHistoryFormatException(this.message);
final String message;
@override
String toString() => message;
}
class BTHomeMetHistoryParser {
static BTHomeMetHistoryPage parse(String text) {
final parts = text
.trim()
.split(',')
.map((part) => part.trim())
.toList(growable: false);
if (parts.length < 3) {
throw const BTHomeMetHistoryFormatException(
'MET history response is too short.',
);
}
final measurementId = int.tryParse(parts[0]);
final page = int.tryParse(parts[1]);
final count = int.tryParse(parts[2]);
if (measurementId == null || page == null || count == null) {
throw const BTHomeMetHistoryFormatException(
'MET history header is invalid.',
);
}
final measurement = BTHomeMetMeasurement.fromId(measurementId);
if (measurement == null) {
throw BTHomeMetHistoryFormatException(
'Unsupported MET history measurement id: $measurementId',
);
}
if (count < 0) {
throw const BTHomeMetHistoryFormatException(
'MET history sample count is invalid.',
);
}
if (parts.length != count + 3) {
throw BTHomeMetHistoryFormatException(
'MET history sample count mismatch: expected $count values, got ${parts.length - 3}.',
);
}
final values = <double>[];
for (final part in parts.skip(3)) {
final value = double.tryParse(part);
if (value == null) {
throw BTHomeMetHistoryFormatException(
'Invalid MET history sample value: $part',
);
}
values.add(value);
}
return BTHomeMetHistoryPage(
measurement: measurement,
page: page,
values: List<double>.unmodifiable(values),
);
}
}
bool _isTruthyCapabilityValue(Object? value) {
if (value is num) {
return value > 0;
}
if (value is bool) {
return value;
}
return false;
}
bool _hasBTHomeMetCapability(Contact? contact) {
final extraSensorData = contact?.telemetry?.extraSensorData;
if (extraSensorData == null) {
return false;
}
// MET history is only enabled when the node advertises an explicit
// capability marker on channel 1. Accept a small set of channel-1 marker
// keys so the app remains tolerant while firmware-side encoding settles.
const capabilityKeys = <String>[
'met_capability',
'met_capability_1',
'generic_sensor_1',
'light_level_1',
];
for (final key in capabilityKeys) {
if (_isTruthyCapabilityValue(extraSensorData[key])) {
return true;
}
}
return false;
}
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
final telemetry = contact?.telemetry;
if (telemetry == null || !_hasBTHomeMetCapability(contact)) {
return const <BTHomeMetMeasurement>[];
}
final measurements = <BTHomeMetMeasurement>[
if (telemetry.temperature != null) BTHomeMetMeasurement.temperature,
if (telemetry.humidity != null) BTHomeMetMeasurement.humidity,
];
final extraSensorData = telemetry.extraSensorData;
if (extraSensorData != null) {
if (extraSensorData.keys.any(
(key) => key.startsWith('speed_') || key.startsWith('signed_speed_'),
)) {
measurements.add(BTHomeMetMeasurement.windSpeed);
}
if (extraSensorData.keys.any((key) => key.startsWith('gust_'))) {
measurements.add(BTHomeMetMeasurement.gust);
}
if (extraSensorData.keys.any((key) => key.startsWith('rain_'))) {
measurements.add(BTHomeMetMeasurement.rain);
}
}
return List<BTHomeMetMeasurement>.unmodifiable(measurements);
}
bool supportsBTHomeMetHistory(Contact? contact) =>
bTHomeMetMeasurementsForContact(contact).isNotEmpty;

View File

@@ -88,10 +88,13 @@ class CayenneLppParser {
LatLng? gpsLocation; LatLng? gpsLocation;
double? batteryPercentage; double? batteryPercentage;
double? batteryMilliVolts; double? batteryMilliVolts;
double? deferredBatteryMilliVolts;
double? deferredBatteryPercentage;
double? temperature; double? temperature;
double? humidity; double? humidity;
double? pressure; double? pressure;
final extraSensorData = <String, dynamic>{}; final extraSensorData = <String, dynamic>{};
bool sawNonSelfChannel = false;
int fieldCount = 0; int fieldCount = 0;
while (reader.hasRemaining) { while (reader.hasRemaining) {
@@ -112,6 +115,9 @@ class CayenneLppParser {
final channel = reader.readByte(); final channel = reader.readByte();
debugPrint(' Channel: $channel'); debugPrint(' Channel: $channel');
if (channel != _selfTelemetryChannel) {
sawNonSelfChannel = true;
}
final type = reader.readByte(); final type = reader.readByte();
debugPrint( debugPrint(
@@ -136,7 +142,7 @@ class CayenneLppParser {
final value = rawValue / 100.0; final value = rawValue / 100.0;
debugPrint(' Analog Input (raw): $rawValue'); debugPrint(' Analog Input (raw): $rawValue');
debugPrint(' Analog Input (volts): ${value}V'); debugPrint(' Analog Input (volts): ${value}V');
if (_isBatteryChannel(channel)) { if (_isDedicatedBatteryChannel(channel)) {
batteryMilliVolts = value * 1000; batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value); batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel; extraSensorData[_sourceChannelKey('battery')] = channel;
@@ -144,6 +150,10 @@ class CayenneLppParser {
debugPrint( debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
); );
} else if (_isDeferredBatteryChannel(channel)) {
deferredBatteryMilliVolts = value * 1000;
deferredBatteryPercentage = _calculateBatteryPercentage(value);
extraSensorData['analog_input_$channel'] = value;
} else { } else {
extraSensorData['analog_input_$channel'] = value; extraSensorData['analog_input_$channel'] = value;
} }
@@ -237,7 +247,7 @@ class CayenneLppParser {
final value = rawValue / 100.0; final value = rawValue / 100.0;
debugPrint(' Voltage (raw): $rawValue'); debugPrint(' Voltage (raw): $rawValue');
debugPrint(' Voltage: ${value}V'); debugPrint(' Voltage: ${value}V');
if (_isBatteryChannel(channel)) { if (_isDedicatedBatteryChannel(channel)) {
batteryMilliVolts = value * 1000; batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value); batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel; extraSensorData[_sourceChannelKey('battery')] = channel;
@@ -245,6 +255,10 @@ class CayenneLppParser {
debugPrint( debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
); );
} else if (_isDeferredBatteryChannel(channel)) {
deferredBatteryMilliVolts = value * 1000;
deferredBatteryPercentage = _calculateBatteryPercentage(value);
extraSensorData['voltage_$channel'] = value;
} else { } else {
extraSensorData['voltage_$channel'] = value; extraSensorData['voltage_$channel'] = value;
} }
@@ -327,7 +341,7 @@ class CayenneLppParser {
case _lppPercentage: case _lppPercentage:
final value = reader.readByte().toDouble(); final value = reader.readByte().toDouble();
debugPrint(' Percentage: $value%'); debugPrint(' Percentage: $value%');
if (_isBatteryChannel(channel)) { if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) {
batteryPercentage = value; batteryPercentage = value;
extraSensorData[_sourceChannelKey('battery')] = channel; extraSensorData[_sourceChannelKey('battery')] = channel;
} else { } else {
@@ -631,6 +645,21 @@ class CayenneLppParser {
} }
} }
if (batteryMilliVolts == null &&
batteryPercentage == null &&
deferredBatteryMilliVolts != null &&
deferredBatteryPercentage != null &&
!sawNonSelfChannel) {
batteryMilliVolts = deferredBatteryMilliVolts;
batteryPercentage = deferredBatteryPercentage;
extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel;
extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel;
debugPrint(
' Promoted self-channel voltage to battery: '
'${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
}
debugPrint(' Parsed $fieldCount fields'); debugPrint(' Parsed $fieldCount fields');
debugPrint(' ✅ [CayenneLPP] Parsing complete'); debugPrint(' ✅ [CayenneLPP] Parsing complete');
debugPrint( debugPrint(
@@ -677,8 +706,10 @@ class CayenneLppParser {
return ((voltage - 3.0) / 1.2) * 100.0; return ((voltage - 3.0) / 1.2) * 100.0;
} }
static bool _isBatteryChannel(int channel) => static bool _isDedicatedBatteryChannel(int channel) => channel == 0;
channel == 0 || channel == _selfTelemetryChannel;
static bool _isDeferredBatteryChannel(int channel) =>
channel == _selfTelemetryChannel;
static int _readUInt32BE(BufferReader reader) { static int _readUInt32BE(BufferReader reader) {
final bytes = reader.readBytes(4); final bytes = reader.readBytes(4);

View File

@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
class CompassSupport {
const CompassSupport._();
static bool get isAvailable =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
}

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
// ============================================================================ // ============================================================================
@@ -49,6 +57,7 @@ class LocationTrackingService {
'fast_location_movement_threshold_meters'; 'fast_location_movement_threshold_meters';
static const String _prefKeyFastActiveCadence = static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds'; 'fast_location_active_cadence_seconds';
static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx';
String _scopedKey(String baseKey) { String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey); return ProfileStorageScope.scopedKey(baseKey);
@@ -74,10 +83,15 @@ 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.
int? fastLocationChannelIdx;
// ============================================================================ // ============================================================================
// State Properties // State Properties
@@ -518,18 +532,30 @@ 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();
_refreshFastLocationTimer();
}
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
fastLocationChannelIdx = channelIdx;
await saveSettings(); await saveSettings();
_refreshFastLocationTimer(); _refreshFastLocationTimer();
} }
void _evaluateFastLocationMovement(Position position) { void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled) return; if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final previous = _lastFastLocationSentPosition; final previous = _lastFastLocationSentPosition;
if (previous == null) { if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial'); _emitFastLocationUpdate(position, reason: 'initial');
@@ -552,6 +578,7 @@ class LocationTrackingService {
_fastLocationTimer = null; _fastLocationTimer = null;
if (!isTracking || if (!isTracking ||
!fastLocationUpdatesEnabled || !fastLocationUpdatesEnabled ||
fastLocationChannelIdx == null ||
!_isFastLocationActiveUse) { !_isFastLocationActiveUse) {
return; return;
} }
@@ -567,11 +594,16 @@ class LocationTrackingService {
} }
void _emitFastLocationUpdate(Position position, {required String reason}) { void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled) return; if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
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,
@@ -662,13 +694,16 @@ 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));
debugPrint('✅ [LocationTracking] Settings loaded'); debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m'); debugPrint(' Min distance: ${minDistanceMeters}m');
@@ -680,6 +715,7 @@ class LocationTrackingService {
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m', ' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
); );
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s'); debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}');
} }
/// Save settings to SharedPreferences /// Save settings to SharedPreferences
@@ -709,6 +745,12 @@ class LocationTrackingService {
_scopedKey(_prefKeyFastActiveCadence), _scopedKey(_prefKeyFastActiveCadence),
fastLocationActiveCadenceSeconds, fastLocationActiveCadenceSeconds,
); );
final channelKey = _scopedKey(_prefKeyFastChannelIdx);
if (fastLocationChannelIdx == null) {
await prefs.remove(channelKey);
} else {
await prefs.setInt(channelKey, fastLocationChannelIdx!);
}
debugPrint('✅ [LocationTracking] Settings saved'); debugPrint('✅ [LocationTracking] Settings saved');
} }

View File

@@ -64,7 +64,6 @@ class MapWorkspaceSnapshotService {
showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay, showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay,
showMunicipalityBordersOverlay: showMunicipalityBordersOverlay:
mapProvider.showMunicipalityBordersOverlay, mapProvider.showMunicipalityBordersOverlay,
showAllContactTrails: mapProvider.showAllContactTrails,
hideRepeatersOnMap: mapProvider.hideRepeatersOnMap, hideRepeatersOnMap: mapProvider.hideRepeatersOnMap,
); );
} }

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

@@ -14,6 +14,8 @@ import '../providers/drawing_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 '../providers/voice_provider.dart';
import '../providers/image_provider.dart' as ip;
import 'app_config_snapshot_service.dart'; import 'app_config_snapshot_service.dart';
import 'contact_storage_service.dart'; import 'contact_storage_service.dart';
import 'device_config_applicator.dart'; import 'device_config_applicator.dart';
@@ -33,6 +35,8 @@ class ProfileWorkspaceCoordinator {
required this.mapProvider, required this.mapProvider,
required this.drawingProvider, required this.drawingProvider,
required this.channelsProvider, required this.channelsProvider,
required this.voiceProvider,
required this.imageProvider,
required this.appProvider, required this.appProvider,
AppConfigSnapshotService? appConfigSnapshotService, AppConfigSnapshotService? appConfigSnapshotService,
MapWorkspaceSnapshotService? mapWorkspaceSnapshotService, MapWorkspaceSnapshotService? mapWorkspaceSnapshotService,
@@ -58,13 +62,15 @@ class ProfileWorkspaceCoordinator {
final MapProvider mapProvider; final MapProvider mapProvider;
final DrawingProvider drawingProvider; final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider; final ChannelsProvider channelsProvider;
final VoiceProvider voiceProvider;
final ip.ImageProvider imageProvider;
final AppProvider appProvider; final AppProvider appProvider;
final AppConfigSnapshotService _appConfigSnapshotService; final AppConfigSnapshotService _appConfigSnapshotService;
final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService; final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService;
final DeviceConfigApplicator _deviceConfigApplicator; final DeviceConfigApplicator _deviceConfigApplicator;
final MessageStorageService _messageStorageService; final MessageStorageService _messageStorageService;
final ContactStorageService _contactStorageService; final ContactStorageService _contactStorageService;
bool _isSyncingDeviceProfile = false; Future<void>? _deviceProfileSyncFuture;
Future<void> setProfilesEnabled(bool enabled) async { Future<void> setProfilesEnabled(bool enabled) async {
final wasEnabled = profileManager.profilesEnabled; final wasEnabled = profileManager.profilesEnabled;
@@ -279,41 +285,54 @@ class ProfileWorkspaceCoordinator {
} }
Future<void> syncActiveProfileForCurrentDevice() async { Future<void> syncActiveProfileForCurrentDevice() async {
if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) { if (!profileManager.profilesEnabled) {
return; return;
} }
final inFlightSync = _deviceProfileSyncFuture;
if (inFlightSync != null) {
await inFlightSync;
return;
}
final syncFuture = _syncActiveProfileForCurrentDeviceInternal();
_deviceProfileSyncFuture = syncFuture;
try {
await syncFuture;
} finally {
if (identical(_deviceProfileSyncFuture, syncFuture)) {
_deviceProfileSyncFuture = null;
}
}
}
Future<void> _syncActiveProfileForCurrentDeviceInternal() async {
final deviceKey = _currentDeviceProfileKey; final deviceKey = _currentDeviceProfileKey;
if (deviceKey == null) { if (deviceKey == null) {
return; return;
} }
_isSyncingDeviceProfile = true; final profile = await _ensureProfileForCurrentDevice();
try { final targetProfileId = profile.id;
final profile = await _ensureProfileForCurrentDevice(); if (targetProfileId == profileManager.activeProfileId) {
final targetProfileId = profile.id; return;
if (targetProfileId == profileManager.activeProfileId) {
return;
}
await _persistCurrentState();
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(targetProfileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
} finally {
_isSyncingDeviceProfile = false;
} }
await _persistCurrentState();
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(targetProfileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
} }
Future<ConfigProfile> _ensureProfileForCurrentDevice() async { Future<ConfigProfile> _ensureProfileForCurrentDevice() async {
@@ -387,6 +406,8 @@ class ProfileWorkspaceCoordinator {
await sensorsProvider.reloadProfileScopedState(); await sensorsProvider.reloadProfileScopedState();
await drawingProvider.reloadProfileScopedState(); await drawingProvider.reloadProfileScopedState();
await mapProvider.reloadProfileScopedState(); await mapProvider.reloadProfileScopedState();
await voiceProvider.reloadProfileScopedState();
await imageProvider.reloadProfileScopedState();
await appProvider.reloadProfileScopedSettings(); await appProvider.reloadProfileScopedSettings();
} }

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
/// Stores per-channel region scope settings.
///
/// Each channel can optionally have a region scope assigned. When set, the app
/// will set the flood scope on the device before sending channel messages so
/// that repeaters outside the region won't forward them.
class RegionScopePreferences {
static const String _namePrefix = 'region_scope_name_';
static const String _keyPrefix = 'region_scope_key_';
/// Get the saved scope for a channel.
/// Returns null if no scope is set.
static Future<({String name, Uint8List key})?> getScope(
int channelIdx,
) async {
final prefs = await SharedPreferences.getInstance();
final name = prefs.getString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
final keyBase64 = prefs.getString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
if (name == null || keyBase64 == null) return null;
try {
return (name: name, key: Uint8List.fromList(base64.decode(keyBase64)));
} catch (_) {
return null;
}
}
/// Save a region scope for a channel.
static Future<void> setScope(
int channelIdx,
String name,
Uint8List key,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
name,
);
await prefs.setString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
base64.encode(key),
);
}
/// Clear the region scope for a channel.
static Future<void> clearScope(int channelIdx) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
await prefs.remove(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
}
/// Clear all region scopes (all channels).
static Future<void> clearAllScopes() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
for (final key in keys) {
if (key.contains(_namePrefix) || key.contains(_keyPrefix)) {
await prefs.remove(key);
}
}
}
/// Derive the 16-byte transport key for a region name.
///
/// Matches firmware `TransportKeyStore::getAutoKeyFor`:
/// `SHA256("#regionname")` → first 16 bytes.
/// The name must include the leading `#` (auto-prepended if missing).
static Uint8List deriveRegionKey(String regionName) {
final normalized =
regionName.startsWith('#') ? regionName : '#$regionName';
final digest = sha256.convert(utf8.encode(normalized));
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
}

View File

@@ -4,9 +4,119 @@ import 'package:provider/provider.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 '../providers/contacts_provider.dart';
import '../screens/discovery_screen.dart';
import '../services/network_scanner_service.dart'; import '../services/network_scanner_service.dart';
import '../services/profile_workspace_coordinator.dart';
import '../services/serial/serial_transport.dart'; import '../services/serial/serial_transport.dart';
enum _ConnectionDialogResult { connected }
Future<void> _initializeConnectedWorkspace({
required ProfileWorkspaceCoordinator profileWorkspaceCoordinator,
required AppProvider appProvider,
}) async {
await profileWorkspaceCoordinator.syncActiveProfileForCurrentDevice();
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(
BuildContext context, {
Color? backgroundColor,
bool offerPostConnectRepeaterDiscovery = false,
}) async {
final result = await showModalBottomSheet<_ConnectionDialogResult>(
context: context,
isScrollControlled: true,
backgroundColor: backgroundColor,
builder: (context) => const ConnectionDialog(),
);
if (result != _ConnectionDialogResult.connected || !context.mounted) {
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) {
return true;
}
final contactsProvider = context.read<ContactsProvider>();
if (contactsProvider.repeaters.isNotEmpty) {
return true;
}
final l10n = AppLocalizations.of(context)!;
final openDiscovery = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.postConnectDiscoveryTitle),
content: Text(l10n.postConnectDiscoveryDescription),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.continue_),
),
FilledButton.icon(
onPressed: () => Navigator.of(dialogContext).pop(true),
icon: const Icon(Icons.router_outlined),
label: Text(l10n.discoverRepeaters),
),
],
),
);
if (openDiscovery != true || !context.mounted) {
return true;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true),
),
);
return true;
}
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key}); const ConnectionDialog({super.key});
@@ -24,6 +134,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
int _lastTabIndex = 0; int _lastTabIndex = 0;
bool _hasRequestedBleScan = false;
String? _connectingToServerKey; String? _connectingToServerKey;
String? _connectingBleDeviceId; String? _connectingBleDeviceId;
@@ -31,10 +142,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
if (_tabController.index == _lastTabIndex) return; if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index; _lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) { if (_tabController.index == 1) {
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
setState(() { setState(() {
@@ -56,11 +163,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
listen: false, listen: false,
); );
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_refreshBleDevices();
});
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -101,6 +203,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
Future<void> _refreshBleDevices() async { Future<void> _refreshBleDevices() async {
if (!_hasRequestedBleScan && mounted) {
setState(() {
_hasRequestedBleScan = true;
});
} else {
_hasRequestedBleScan = true;
}
await _connectionProvider.stopScan(); await _connectionProvider.stopScan();
if (!mounted) return; if (!mounted) return;
await _connectionProvider.startScan(); await _connectionProvider.startScan();
@@ -112,6 +221,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
return Colors.red; return Colors.red;
} }
void _closeOnSuccessfulConnection() {
if (!mounted) return;
Navigator.of(context).pop(_ConnectionDialogResult.connected);
}
void _showConnectionError(Object error) {
if (!mounted) return;
_showConnectionErrorSnackBar(context, error);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
@@ -392,11 +511,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
final l10n = AppLocalizations.of(context)!;
return Column( return Column(
children: [ children: [
_buildSectionBanner( _buildSectionBanner(
icon: Icons.bluetooth_searching_rounded, icon: Icons.bluetooth_searching_rounded,
message: AppLocalizations.of(context)!.defaultPinInfo, message: l10n.defaultPinInfo,
onRefresh: _refreshBleDevices, onRefresh: _refreshBleDevices,
), ),
if (connectionProvider.error != null) if (connectionProvider.error != null)
@@ -409,8 +530,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
: connectionProvider.scannedDevices.isEmpty : connectionProvider.scannedDevices.isEmpty
? _buildEmptyState( ? _buildEmptyState(
icon: Icons.bluetooth_searching_rounded, icon: Icons.bluetooth_searching_rounded,
title: AppLocalizations.of(context)!.noDevicesFound, title: _hasRequestedBleScan
actionLabel: AppLocalizations.of(context)!.scanAgain, ? l10n.noDevicesFound
: 'Press scan to search for nearby devices',
actionLabel: _hasRequestedBleScan ? l10n.scanAgain : 'Scan',
onAction: _refreshBleDevices, onAction: _refreshBleDevices,
) )
: ListView.builder( : ListView.builder(
@@ -425,19 +548,25 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final isConnecting = _connectingBleDeviceId == deviceId; final isConnecting = _connectingBleDeviceId == deviceId;
Future<void> connectBle() async { Future<void> connectBle() async {
final appProvider = context.read<AppProvider>();
setState(() { setState(() {
_connectingBleDeviceId = deviceId; _connectingBleDeviceId = deviceId;
}); });
try { try {
Navigator.pop(context);
final success = await connectionProvider.connect( final success = await connectionProvider.connect(
device, device,
); );
if (success && if (!success) {
connectionProvider.deviceInfo.isConnected) { final name = device.platformName.isNotEmpty
await appProvider.initialize(); ? device.platformName
: 'device';
throw Exception(
connectionProvider.error ??
'Failed to connect to $name',
);
} }
_closeOnSuccessfulConnection();
} catch (error) {
_showConnectionError(error);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -532,9 +661,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Future<void> connectServer() async { Future<void> connectServer() async {
final connectionProvider = context final connectionProvider = context
.read<ConnectionProvider>(); .read<ConnectionProvider>();
final appProvider = context.read<AppProvider>();
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
setState(() { setState(() {
_connectingToServerKey = serverKey; _connectingToServerKey = serverKey;
@@ -550,48 +676,23 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
await connectionProvider.connectTcp( final success = await connectionProvider.connectTcp(
server.ipAddress, server.ipAddress,
server.port, server.port,
); );
await appProvider.initialize(); if (!success) {
throw Exception(
if (mounted) { connectionProvider.error ??
navigator.pop(); 'Failed to connect to ${server.ipAddress}:${server.port}',
);
} }
_closeOnSuccessfulConnection();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_connectingToServerKey = null; _connectingToServerKey = null;
}); });
_showConnectionError(e);
var errorMessage = e.toString();
if (errorMessage.startsWith('Exception: ')) {
errorMessage = errorMessage.substring(
'Exception: '.length,
);
}
if (errorMessage.startsWith(
'Connection failed: Exception: ',
)) {
errorMessage = errorMessage.substring(
'Connection failed: Exception: '.length,
);
} else if (errorMessage.startsWith(
'Connection failed: ',
)) {
errorMessage = errorMessage.substring(
'Connection failed: '.length,
);
}
messenger.showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
} }
} }
@@ -658,8 +759,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
actionLabel: actionLabel, actionLabel: actionLabel,
onAction: onAction, onAction: onAction,
), ),
onConnected: () { onConnected: (result) {
if (mounted) Navigator.of(context).pop(); if (mounted) {
Navigator.of(context).pop(result);
}
}, },
); );
} }
@@ -685,7 +788,7 @@ typedef _EmptyStateBuilder =
}); });
class _SerialDeviceList extends StatefulWidget { class _SerialDeviceList extends StatefulWidget {
final VoidCallback onConnected; final ValueChanged<_ConnectionDialogResult> onConnected;
final _TransportCardBuilder buildTransportCard; final _TransportCardBuilder buildTransportCard;
final _EmptyStateBuilder buildEmptyState; final _EmptyStateBuilder buildEmptyState;
@@ -780,7 +883,6 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
setState(() => _isConnecting = true); setState(() => _isConnecting = true);
try { try {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final appProvider = context.read<AppProvider>();
final connection = await _transport.connect(device); final connection = await _transport.connect(device);
final success = await connectionProvider.connectSerial( final success = await connectionProvider.connectSerial(
service: connection.service, service: connection.service,
@@ -791,8 +893,7 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
await appProvider.initialize(); widget.onConnected(_ConnectionDialogResult.connected);
widget.onConnected();
} else { } else {
await connection.disconnect(); await connection.disconnect();
connection.service.dispose(); connection.service.dispose();

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),
],
),
],
),
), ),
], ],
), ),

File diff suppressed because it is too large Load Diff

View File

@@ -1,387 +0,0 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
import '../../l10n/app_localizations.dart';
class SpectrumScanPanel extends StatelessWidget {
final ThemeData theme;
final bool scanSupported;
final bool isRunning;
final double rangeMinMhz;
final double rangeMaxMhz;
final RangeValues rangeValues;
final double bandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> graphCandidates;
final List<SpectrumScanCandidate> selectableCandidates;
final ValueChanged<RangeValues> onRangeChanged;
final ValueChanged<int?> onCandidateChanged;
final VoidCallback onRunScan;
final VoidCallback onApplySelected;
const SpectrumScanPanel({
super.key,
required this.theme,
required this.scanSupported,
required this.isRunning,
required this.rangeMinMhz,
required this.rangeMaxMhz,
required this.rangeValues,
required this.bandwidthKhz,
required this.selectedFrequencyKhz,
required this.graphCandidates,
required this.selectableCandidates,
required this.onRangeChanged,
required this.onCandidateChanged,
required this.onRunScan,
required this.onApplySelected,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.tune, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Power Scan',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
FilledButton.icon(
onPressed: scanSupported && !isRunning ? onRunScan : null,
icon: isRunning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.radar),
label: Text(
scanSupported
? (isRunning ? 'Scanning' : 'Scan')
: 'Unavailable',
),
),
],
),
const SizedBox(height: 8),
Text(
scanSupported
? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.'
: 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
_FrequencyRangePreview(
minMhz: rangeMinMhz,
maxMhz: rangeMaxMhz,
selectedRange: rangeValues,
selectedBandwidthKhz: bandwidthKhz,
selectedFrequencyKhz: selectedFrequencyKhz,
candidates: graphCandidates,
),
const SizedBox(height: 10),
Wrap(
spacing: 14,
runSpacing: 6,
children: [
_LegendChip(
color: theme.colorScheme.primary,
label: AppLocalizations.of(context)!.quiet,
),
_LegendChip(
color: Colors.orange,
label: AppLocalizations.of(context)!.moderate,
),
_LegendChip(
color: theme.colorScheme.error,
label: AppLocalizations.of(context)!.busy,
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${rangeValues.start.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
Text(
'${rangeValues.end.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
],
),
RangeSlider(
values: rangeValues,
min: rangeMinMhz,
max: rangeMaxMhz,
divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp(
1,
400,
),
labels: RangeLabels(
rangeValues.start.toStringAsFixed(3),
rangeValues.end.toStringAsFixed(3),
),
onChanged: onRangeChanged,
),
if (selectableCandidates.isEmpty) ...[
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
scanSupported
? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.'
: 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
] else ...[
const SizedBox(height: 10),
DropdownButtonFormField<int>(
initialValue: selectedFrequencyKhz,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Candidate frequency',
border: OutlineInputBorder(),
helperText: 'Best frequencies for the current bandwidth',
),
items: selectableCandidates.map((candidate) {
return DropdownMenuItem<int>(
value: candidate.centerFrequencyKhz,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
Text(
'${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
);
}).toList(),
selectedItemBuilder: (context) {
return selectableCandidates.map((candidate) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
);
}).toList();
},
onChanged: onCandidateChanged,
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: selectedFrequencyKhz == null
? null
: onApplySelected,
icon: Icon(Icons.north_east),
label: Text(AppLocalizations.of(context)!.useSelectedFrequency),
),
),
],
],
),
);
}
}
class _LegendChip extends StatelessWidget {
final Color color;
final String label;
const _LegendChip({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
);
}
}
class _FrequencyRangePreview extends StatelessWidget {
final double minMhz;
final double maxMhz;
final RangeValues selectedRange;
final double selectedBandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> candidates;
const _FrequencyRangePreview({
required this.minMhz,
required this.maxMhz,
required this.selectedRange,
required this.selectedBandwidthKhz,
required this.selectedFrequencyKhz,
required this.candidates,
});
double _positionFor(double mhz) {
final span = maxMhz - minMhz;
if (span <= 0) return 0;
return ((mhz - minMhz) / span).clamp(0.0, 1.0);
}
Color _candidateColor(BuildContext context, int occupancyPercent) {
final scheme = Theme.of(context).colorScheme;
if (occupancyPercent <= 10) return scheme.primary;
if (occupancyPercent <= 35) return Colors.orange;
return scheme.error;
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final spanMhz = maxMhz - minMhz;
final selectedFreqMhz = selectedFrequencyKhz != null
? selectedFrequencyKhz! / 1000.0
: null;
final bwMhz = selectedBandwidthKhz / 1000.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final rangeLeft = _positionFor(selectedRange.start) * width;
final rangeRight = _positionFor(selectedRange.end) * width;
double? bwLeft;
double? bwWidth;
if (selectedFreqMhz != null && spanMhz > 0) {
bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width;
final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width;
bwWidth = (bwRight - bwLeft).clamp(4.0, width);
}
return Container(
height: 108,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: scheme.surfaceContainerLow,
border: Border.all(color: scheme.outlineVariant),
),
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 12,
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: scheme.surfaceContainerHigh,
),
),
),
),
Positioned(
left: rangeLeft,
top: 12,
width: (rangeRight - rangeLeft).clamp(8.0, width),
height: 56,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: scheme.primaryContainer,
border: Border.all(color: scheme.primary),
),
),
),
if (bwLeft != null && bwWidth != null)
Positioned(
left: bwLeft,
top: 28,
width: bwWidth,
height: 24,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: scheme.tertiaryContainer,
border: Border.all(color: scheme.tertiary),
),
),
),
for (final candidate in candidates)
Positioned(
left: (_positionFor(candidate.centerFrequencyMhz) * width)
.clamp(10.0, width - 18.0),
top: 72,
child: Column(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _candidateColor(
context,
candidate.occupancyPercent,
),
),
),
const SizedBox(height: 4),
Text(
candidate.centerFrequencyMhz.toStringAsFixed(3),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart'; import 'package:flutter_compass/flutter_compass.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../services/compass_support.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/sar_marker.dart'; import '../../models/sar_marker.dart';
import '../common/location_display.dart'; import '../common/location_display.dart';
@@ -90,17 +92,33 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
}); });
} }
// Subscribe to compass updates // Subscribe to compass updates on supported targets only.
final compassStream = FlutterCompass.events; final compassStream = CompassSupport.isAvailable
? FlutterCompass.events
: null;
if (compassStream != null) { if (compassStream != null) {
_compassSubscription = compassStream.listen((event) { _compassSubscription = compassStream.listen(
if (mounted && event.heading != null) { (event) {
setState(() { if (mounted && event.heading != null) {
_currentHeading = event.heading; setState(() {
_compassAccuracy = event.accuracy; _currentHeading = event.heading;
}); _compassAccuracy = event.accuracy;
} });
}); }
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) {
return;
}
if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass dialog stream error: $error');
},
);
} }
// Subscribe to position updates // Subscribe to position updates

View File

@@ -1,11 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/location_trail.dart'; import '../../models/location_trail.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/gpx_service.dart'; import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
/// Trail management controls widget /// Trail management controls widget
@@ -14,17 +11,8 @@ class TrailControls extends StatelessWidget {
void _showTrailMenu(BuildContext context) { void _showTrailMenu(BuildContext context) {
final mapProvider = Provider.of<MapProvider>(context, listen: false); final mapProvider = Provider.of<MapProvider>(context, listen: false);
final contactsProvider = Provider.of<ContactsProvider>(
context,
listen: false,
);
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
// Get contacts with trails (advertHistory >= 2 points)
final contactsWithTrails = contactsProvider.contactsWithLocation
.where((c) => c.advertHistory.length >= 2)
.toList();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -183,110 +171,6 @@ class TrailControls extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
// Contact Trails Section
Row(
children: [
const Icon(Icons.people, size: 20),
SizedBox(width: 8),
Text(
l10n.contactTrails,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
// Show All Contact Trails toggle
SwitchListTile(
secondary: Icon(Icons.route),
title: Text(l10n.showAllContactTrails),
subtitle: Text(
contactsWithTrails.isEmpty
? l10n.noContactsWithLocationHistory
: mapProvider.showAllContactTrails
? l10n.showingTrailsForContacts(
contactsWithTrails.length,
)
: l10n.individualContactTrails,
),
value: mapProvider.showAllContactTrails,
onChanged: contactsWithTrails.isNotEmpty
? (value) {
mapProvider.toggleAllContactTrails();
setModalState(() {}); // Update modal UI
}
: null, // Disable if no contacts with trails
),
// Individual contact trails (when "show all" is OFF)
if (!mapProvider.showAllContactTrails &&
contactsWithTrails.isNotEmpty)
ExpansionTile(
title: Text(l10n.individualContactTrails),
initiallyExpanded: false,
children: contactsWithTrails.map((contact) {
final trailColor = TrailColorService.getTrailColor(
contact,
);
final isVisible = mapProvider.isContactPathVisible(
contact.publicKeyHex,
);
return SwitchListTile(
// Color indicator with emoji
secondary: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.roleEmoji != null)
Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: 4),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: trailColor,
border: Border.all(
color: Colors.white,
width: 2,
),
borderRadius: BorderRadius.circular(3),
),
),
],
),
title: Row(
children: [
Expanded(child: Text(contact.displayName)),
IconButton(
tooltip: l10n.exportToClipboard,
icon: const Icon(Icons.file_download_outlined),
onPressed: () =>
_exportContactTrail(context, contact),
),
],
),
subtitle: Text(
'${contact.advertHistory.length} points',
),
value: isVisible,
onChanged: (value) {
mapProvider.toggleContactPath(contact.publicKeyHex);
setModalState(() {}); // Update modal UI
},
);
}).toList(),
),
const SizedBox(height: 8), const SizedBox(height: 8),
// Close button // Close button
@@ -360,34 +244,6 @@ class TrailControls extends StatelessWidget {
} }
} }
Future<void> _exportContactTrail(BuildContext context, Contact contact) async {
await _exportTrail(
context,
_buildContactTrail(contact),
customName: '${contact.displayName} Trail',
);
}
LocationTrail _buildContactTrail(Contact contact) {
final sortedHistory = [...contact.advertHistory]
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
return LocationTrail(
id: 'contact_${contact.publicKeyHex}',
isActive: false,
startTime: sortedHistory.first.timestamp,
endTime: sortedHistory.last.timestamp,
points: sortedHistory
.map(
(advert) => TrailPoint(
position: advert.location,
timestamp: advert.timestamp,
),
)
.toList(),
);
}
void _showClearConfirmation( void _showClearConfirmation(
BuildContext context, BuildContext context,
MapProvider mapProvider, MapProvider mapProvider,

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/sar_marker.dart'; import '../../models/sar_marker.dart';
@@ -10,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';
@@ -31,6 +34,7 @@ import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart'; import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart'; import '../../models/message_transfer_details.dart';
import '../../screens/add_contact_screen.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart'; import 'tictactoe_message_bubble.dart';
@@ -72,9 +76,28 @@ class MessageBubble extends StatefulWidget {
} }
class _MessageBubbleState extends State<MessageBubble> { class _MessageBubbleState extends State<MessageBubble> {
static const int _meshcoreAdvertMinBytes = 98;
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]'); static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
static final RegExp _linkPattern = RegExp(
r'(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+',
caseSensitive: false,
);
static final RegExp _rawMeshcoreAdvertPattern = RegExp(
r'(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
);
static final RegExp _messageTokenPattern = RegExp(
r'@\[(.+?)\]|(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+|(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
caseSensitive: false,
);
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false; bool _showReceivedStats = false;
final List<TapGestureRecognizer> _linkRecognizers = [];
@override
void dispose() {
_disposeLinkRecognizers();
super.dispose();
}
@override @override
void didUpdateWidget(MessageBubble oldWidget) { void didUpdateWidget(MessageBubble oldWidget) {
@@ -100,14 +123,108 @@ class _MessageBubbleState extends State<MessageBubble> {
}); });
} }
void _disposeLinkRecognizers() {
for (final recognizer in _linkRecognizers) {
recognizer.dispose();
}
_linkRecognizers.clear();
}
String _trimTrailingUrlPunctuation(String value) {
var trimmed = value;
while (trimmed.isNotEmpty) {
final lastChar = trimmed[trimmed.length - 1];
final shouldTrim = switch (lastChar) {
'.' || ',' || '!' || '?' || ':' || ';' => true,
')' => '('.allMatches(trimmed).length < ')'.allMatches(trimmed).length,
']' => '['.allMatches(trimmed).length < ']'.allMatches(trimmed).length,
'}' => '{'.allMatches(trimmed).length < '}'.allMatches(trimmed).length,
_ => false,
};
if (!shouldTrim) {
break;
}
trimmed = trimmed.substring(0, trimmed.length - 1);
}
return trimmed;
}
Uri? _parseMessageLink(String value) {
final trimmed = _trimTrailingUrlPunctuation(value);
if (trimmed.isEmpty) {
return null;
}
if (_isRawMeshcoreAdvert(trimmed)) {
return Uri.parse('meshcore://$trimmed');
}
final normalized = trimmed.startsWith('www.')
? 'https://$trimmed'
: trimmed;
final uri = Uri.tryParse(normalized);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
return uri;
}
bool _isRawMeshcoreAdvert(String value) {
if (!_rawMeshcoreAdvertPattern.hasMatch(value)) {
return false;
}
return value.length.isEven && value.length >= _meshcoreAdvertMinBytes * 2;
}
Future<void> _openMessageLink(String rawUrl) async {
final uri = _parseMessageLink(rawUrl);
if (uri == null) {
ToastLogger.error(context, 'Invalid link');
return;
}
if (uri.scheme.toLowerCase() == 'meshcore') {
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => AddContactScreen(initialAdvert: rawUrl),
),
);
return;
}
try {
if (!await canLaunchUrl(uri)) {
if (!mounted) {
return;
}
ToastLogger.error(context, 'Cannot open link');
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (error) {
if (!mounted) {
return;
}
ToastLogger.error(context, 'Failed to open link');
}
}
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) { Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
final matches = _mentionPattern.allMatches(text).toList(); _disposeLinkRecognizers();
final matches = _messageTokenPattern.allMatches(text).toList();
if (matches.isEmpty) { if (matches.isEmpty) {
return Text(text, style: baseBodyStyle); return Text(text, style: baseBodyStyle);
} }
final textColor = final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface; baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final linkColor = Theme.of(context).colorScheme.primary;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1; final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of( final backgroundColor = Theme.of(
context, context,
@@ -129,8 +246,9 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
final rawMatch = match.group(0) ?? '';
final mentionName = match.group(1)?.trim() ?? ''; final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) { if (_mentionPattern.hasMatch(rawMatch) && mentionName.isNotEmpty) {
spans.add( spans.add(
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
@@ -154,8 +272,34 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
), ),
); );
} else if (_linkPattern.hasMatch(rawMatch) ||
_isRawMeshcoreAdvert(rawMatch)) {
final trimmedUrl = _trimTrailingUrlPunctuation(rawMatch);
final trailingText = rawMatch.substring(trimmedUrl.length);
final uri = _parseMessageLink(rawMatch);
if (uri != null) {
final recognizer = TapGestureRecognizer()
..onTap = () => _openMessageLink(rawMatch);
_linkRecognizers.add(recognizer);
spans.add(
TextSpan(
text: trimmedUrl,
style: baseBodyStyle?.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
recognizer: recognizer,
),
);
if (trailingText.isNotEmpty) {
spans.add(TextSpan(text: trailingText, style: baseBodyStyle));
}
} else {
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
}
} else { } else {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle)); spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
} }
cursor = match.end; cursor = match.end;
@@ -1812,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);
@@ -1861,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
@@ -1896,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) {
@@ -1914,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,9 @@ 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 VoidCallback? onRegionScopeTap;
const MessagesComposer({ const MessagesComposer({
super.key, super.key,
@@ -41,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,
@@ -49,6 +54,9 @@ 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.onRegionScopeTap,
}); });
@override @override
@@ -102,12 +110,23 @@ class MessagesComposer extends StatelessWidget {
? onStopAndSendVoice ? onStopAndSendVoice
: onShowComposerActions, : onShowComposerActions,
), ),
if (regionScopeName != null &&
onRegionScopeTap != null) ...[
const SizedBox(width: 6),
_RegionScopeChip(
name: regionScopeName!,
onTap: onRegionScopeTap!,
),
],
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,
), ),
), ),
], ],
@@ -151,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,
), ),
@@ -282,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,
),
],
),
),
),
), ),
); );
} }
@@ -421,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;
@@ -433,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,
}); });
@@ -444,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(
@@ -534,3 +574,52 @@ class _SendButton extends StatelessWidget {
); );
} }
} }
class _RegionScopeChip extends StatelessWidget {
final String name;
final VoidCallback onTap;
const _RegionScopeChip({required this.name, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.tertiary.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.language_rounded,
size: 16,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 80),
child: Text(
name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

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,10 @@ 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"}).
final Map<int, String> channelRegionScopes;
const RecipientSelectorSheet({ const RecipientSelectorSheet({
super.key, super.key,
@@ -29,6 +36,8 @@ 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 {},
}); });
@override @override
@@ -87,6 +96,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
return lastSeenCompare; return lastSeenCompare;
} }
} else if (_sortMode == _RecipientSortMode.activity) { } else if (_sortMode == _RecipientSortMode.activity) {
if (a.isFavourite != b.isFavourite) {
return a.isFavourite ? -1 : 1;
}
final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a)); final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a));
if (unreadCompare != 0) { if (unreadCompare != 0) {
return unreadCompare; return unreadCompare;
@@ -147,10 +159,184 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
} }
} }
String _sectionDescription(AppLocalizations l10n, String type) {
switch (type) {
case 'channel':
return 'Broadcast lanes for nearby mesh traffic';
case 'room':
return 'Shared spaces for ongoing team coordination';
case 'contact':
return 'Direct people and devices you can reach';
default:
return '';
}
}
MessagesProvider? _resolveMessagesProvider(BuildContext context) {
if (widget.messagesProvider != null) {
return widget.messagesProvider;
}
try {
return Provider.of<MessagesProvider>(context);
} 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';
@override @override
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(
@@ -194,21 +380,45 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Text( child: Column(
l10n.selectRecipient, crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context).textTheme.titleLarge?.copyWith( children: [
fontWeight: FontWeight.w900, Text(
letterSpacing: -0.5, l10n.selectRecipient,
), style: Theme.of(context).textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
'Pick a channel, room, or direct contact.',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
IconButton.filledTonal( Container(
onPressed: () => Navigator.pop(context), decoration: BoxDecoration(
tooltip: l10n.close, color: colorScheme.surfaceContainerHigh,
icon: const Icon(Icons.close_rounded), borderRadius: BorderRadius.circular(18),
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: 0.18,
),
),
),
child: IconButton(
onPressed: () => Navigator.pop(context),
tooltip: l10n.close,
icon: const Icon(Icons.close_rounded),
),
), ),
], ],
), ),
@@ -273,20 +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: channel.isPublicChannel
? l10n.broadcastToAllNearby
: '${l10n.channel} ${channel.publicKey[1]}',
unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel),
onTap: () {
widget.onSelect('channel', channel);
Navigator.pop(context);
},
), ),
], ],
), ),
@@ -305,9 +505,14 @@ 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,
onTap: () { onTap: () {
widget.onSelect('contact', contact); widget.onSelect('contact', contact);
Navigator.pop(context); Navigator.pop(context);
@@ -330,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: () {
@@ -455,10 +664,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final accentColor = _sectionColor(context, type); final accentColor = _sectionColor(context, type);
final compact = _isDenseSection(type);
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14), padding: EdgeInsets.all(compact ? 12 : 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@@ -470,16 +680,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
Row( Row(
children: [ children: [
Container( Container(
width: 38, width: compact ? 34 : 38,
height: 38, height: compact ? 34 : 38,
decoration: BoxDecoration( decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.12), color: accentColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Icon(icon, size: 18, color: accentColor), child: Icon(icon, size: compact ? 16 : 18, color: accentColor),
), ),
const SizedBox(width: 10), SizedBox(width: compact ? 8 : 10),
Expanded( Expanded(
child: Text( child: Text(
title, title,
@@ -490,9 +700,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
), ),
), ),
Container( Container(
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 10, horizontal: compact ? 9 : 10,
vertical: 5, vertical: compact ? 4 : 5,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: colorScheme.surface,
@@ -511,7 +721,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
Text(
_sectionDescription(AppLocalizations.of(context)!, type),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
SizedBox(height: compact ? 10 : 12),
if (children.isEmpty) if (children.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
@@ -526,7 +743,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
else ...[ else ...[
for (var i = 0; i < children.length; i++) ...[ for (var i = 0; i < children.length; i++) ...[
children[i], children[i],
if (i != children.length - 1) const SizedBox(height: 8), if (i != children.length - 1) SizedBox(height: compact ? 6 : 8),
], ],
], ],
], ],
@@ -620,9 +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,
String? activityLabel,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -631,14 +850,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(compact ? 18 : 20),
onTap: onTap, onTap: onTap,
child: Ink( child: Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? accentColor.withValues(alpha: 0.12) ? accentColor.withValues(alpha: 0.12)
: colorScheme.surface.withValues(alpha: 0.82), : colorScheme.surface.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(compact ? 18 : 20),
border: Border.all( border: Border.all(
color: isSelected color: isSelected
? accentColor.withValues(alpha: 0.24) ? accentColor.withValues(alpha: 0.24)
@@ -646,7 +865,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
), ),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: EdgeInsets.all(compact ? 10 : 12),
child: Row( child: Row(
children: [ children: [
Stack( Stack(
@@ -654,15 +873,15 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
children: [ children: [
ContactAvatar( ContactAvatar(
contact: contact, contact: contact,
radius: 22, radius: compact ? 20 : 22,
displayName: title, displayName: title,
), ),
Positioned( Positioned(
right: -3, right: -3,
bottom: -3, bottom: -3,
child: Container( child: Container(
width: 18, width: compact ? 16 : 18,
height: 18, height: compact ? 16 : 18,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -673,41 +892,87 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
alignment: Alignment.center, alignment: Alignment.center,
child: Icon( child: Icon(
_typeIcon(contact), _typeIcon(contact),
size: 10, size: compact ? 9 : 10,
color: accentColor, color: accentColor,
), ),
), ),
), ),
], ],
), ),
const SizedBox(width: 12), SizedBox(width: compact ? 10 : 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
title, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, Expanded(
style: Theme.of(context).textTheme.titleSmall?.copyWith( child: Row(
fontWeight: FontWeight.w800, children: [
letterSpacing: -0.2, Flexible(
), child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
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 (activityLabel != null) ...[
SizedBox(width: compact ? 8 : 10),
Text(
activityLabel,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
],
],
), ),
const SizedBox(height: 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',
),
),
], ],
), ),
), ),
const SizedBox(width: 10), SizedBox(width: compact ? 8 : 10),
_buildTrailing( _buildTrailing(
context, context,
unreadCount: unreadCount, unreadCount: unreadCount,
@@ -759,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

@@ -0,0 +1,819 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' show Message;
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../services/bthome_met_history.dart';
Future<void> showBTHomeMetHistorySheet(
BuildContext context, {
required Contact contact,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _BTHomeMetHistorySheet(contact: contact),
);
}
class _BTHomeMetHistorySheet extends StatefulWidget {
const _BTHomeMetHistorySheet({required this.contact});
final Contact contact;
@override
State<_BTHomeMetHistorySheet> createState() => _BTHomeMetHistorySheetState();
}
class _BTHomeMetHistorySheetState extends State<_BTHomeMetHistorySheet> {
late final List<BTHomeMetMeasurement> _availableMeasurements;
late BTHomeMetMeasurement _selectedMeasurement;
BTHomeMetHistoryPage? _history;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_availableMeasurements = bTHomeMetMeasurementsForContact(widget.contact);
_selectedMeasurement = _availableMeasurements.isEmpty
? BTHomeMetMeasurement.temperature
: _availableMeasurements.first;
if (_availableMeasurements.isEmpty) {
_loading = false;
_error = 'No BTHome MET-compatible telemetry is available for this node.';
return;
}
unawaited(_loadHistory(measurement: _selectedMeasurement, page: 0));
}
Future<void> _loadHistory({
required BTHomeMetMeasurement measurement,
required int page,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
final previousOnMessageReceived = connectionProvider.onMessageReceived;
String? responseText;
void onMessage(Message message) {
previousOnMessageReceived?.call(message);
if (_matchesContact(message)) {
responseText = message.text;
}
}
connectionProvider.onMessageReceived = onMessage;
if (mounted) {
setState(() {
_loading = true;
_error = null;
});
}
try {
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: widget.contact.publicKey,
text: 'bthome met history ${measurement.id} $page',
);
if (!sent) {
throw Exception('Failed to send MET history request.');
}
for (var i = 0; i < 30; i++) {
await Future.delayed(const Duration(milliseconds: 500));
if (responseText != null) {
break;
}
}
final text = responseText?.trim();
if (text == null || text.isEmpty) {
throw TimeoutException('No response from sensor.');
}
if (_looksLikeError(text)) {
throw Exception(text);
}
final history = BTHomeMetHistoryParser.parse(text);
if (!mounted) {
return;
}
setState(() {
_selectedMeasurement = measurement;
_history = history;
_loading = false;
_error = null;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_selectedMeasurement = measurement;
_history = null;
_loading = false;
_error = _formatError(error);
});
} finally {
if (identical(connectionProvider.onMessageReceived, onMessage)) {
connectionProvider.onMessageReceived = previousOnMessageReceived;
}
}
}
bool _matchesContact(Message message) {
final prefix = message.senderPublicKeyPrefix;
if (prefix == null ||
prefix.length < 6 ||
widget.contact.publicKey.length < 6) {
return false;
}
for (var i = 0; i < 6; i++) {
if (prefix[i] != widget.contact.publicKey[i]) {
return false;
}
}
return true;
}
bool _looksLikeError(String text) {
final lower = text.toLowerCase();
return lower.startsWith('err') ||
lower.contains('unknown') ||
lower.contains('unsupported');
}
String _formatError(Object error) {
if (error is TimeoutException) {
return error.message ?? 'Timed out waiting for MET history.';
}
return error.toString().replaceFirst('Exception: ', '');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final height = MediaQuery.of(context).size.height * 0.8;
final history = _history;
return SafeArea(
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MET history',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
widget.contact.displayName,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _availableMeasurements
.map(
(measurement) => ChoiceChip(
label: Text(measurement.label),
selected: measurement == _selectedMeasurement,
onSelected: (selected) {
if (!selected || _loading) {
return;
}
unawaited(
_loadHistory(measurement: measurement, page: 0),
);
},
),
)
.toList(growable: false),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: _loading || (history?.page ?? 0) == 0
? null
: () => unawaited(
_loadHistory(
measurement: _selectedMeasurement,
page: history!.page - 1,
),
),
icon: const Icon(Icons.chevron_left),
label: const Text('Newer'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed:
_loading ||
history == null ||
history.values.length < 12
? null
: () => unawaited(
_loadHistory(
measurement: _selectedMeasurement,
page: history.page + 1,
),
),
icon: const Icon(Icons.chevron_right),
label: const Text('Older'),
),
const Spacer(),
if (_loading)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Text(
'Page ${history?.page ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_error != null)
_HistoryMessageCard(
icon: Icons.error_outline,
title: 'Could not load MET history',
body: _error!,
)
else if (_loading && history == null)
const _HistoryMessageCard(
icon: Icons.hourglass_top,
title: 'Loading',
body: 'Waiting for the sensor to reply.',
)
else if (history != null) ...[
_HistoryChartCard(history: history),
const SizedBox(height: 12),
_HistoryStatsGrid(history: history),
const SizedBox(height: 12),
_HistorySamplesCard(history: history),
],
],
),
),
),
],
),
),
),
);
}
}
class _HistoryChartCard extends StatelessWidget {
const _HistoryChartCard({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
history.measurement.label,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Samples are shown oldest to newest. Firmware replies do not include timestamps.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: LineChart(_historyLineChartData(context, history: history)),
),
],
),
);
}
}
class _HistoryStatsGrid extends StatelessWidget {
const _HistoryStatsGrid({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final measurement = history.measurement;
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 520;
final tiles = <Widget>[
_HistoryStatTile(
label: 'Latest',
value: _formatMeasurementValue(measurement, history.latest),
),
_HistoryStatTile(
label: 'Min',
value: _formatMeasurementValue(measurement, history.minimum),
),
_HistoryStatTile(
label: 'Max',
value: _formatMeasurementValue(measurement, history.maximum),
),
_HistoryStatTile(
label: 'Samples',
value: history.values.length.toString(),
),
];
if (compact) {
return Column(
children: [
Row(
children: [
Expanded(child: tiles[0]),
const SizedBox(width: 8),
Expanded(child: tiles[1]),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: tiles[2]),
const SizedBox(width: 8),
Expanded(child: tiles[3]),
],
),
],
);
}
return Row(
children: [
Expanded(child: tiles[0]),
const SizedBox(width: 8),
Expanded(child: tiles[1]),
const SizedBox(width: 8),
Expanded(child: tiles[2]),
const SizedBox(width: 8),
Expanded(child: tiles[3]),
],
);
},
);
}
}
class _HistoryStatTile extends StatelessWidget {
const _HistoryStatTile({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _HistorySamplesCard extends StatelessWidget {
const _HistorySamplesCard({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Samples',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: history.values
.asMap()
.entries
.map((entry) {
final sampleIndex = entry.key + 1;
final isLatest = entry.key == history.values.length - 1;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: isLatest
? _historyColor(
history.measurement,
).withValues(alpha: 0.12)
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'$sampleIndex. ${_formatMeasurementValue(history.measurement, entry.value)}${isLatest ? ' latest' : ''}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isLatest
? FontWeight.w700
: FontWeight.w500,
),
),
);
})
.toList(growable: false),
),
],
),
);
}
}
class _HistoryMessageCard extends StatelessWidget {
const _HistoryMessageCard({
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(body),
],
),
),
],
),
);
}
}
Color _historyColor(BTHomeMetMeasurement measurement) {
switch (measurement) {
case BTHomeMetMeasurement.temperature:
return const Color(0xFFC76821);
case BTHomeMetMeasurement.humidity:
return const Color(0xFF246BB2);
case BTHomeMetMeasurement.windSpeed:
return const Color(0xFF2B78A0);
case BTHomeMetMeasurement.gust:
return const Color(0xFF1E88A8);
case BTHomeMetMeasurement.rain:
return const Color(0xFF2C6BA0);
}
}
String _formatMeasurementValue(BTHomeMetMeasurement measurement, num? value) {
if (value == null) {
return '--';
}
final digits = switch (measurement) {
BTHomeMetMeasurement.humidity => 0,
BTHomeMetMeasurement.temperature => 1,
BTHomeMetMeasurement.windSpeed => 1,
BTHomeMetMeasurement.gust => 1,
BTHomeMetMeasurement.rain => 1,
};
final text = value
.toStringAsFixed(digits)
.replaceFirst(RegExp(r'\.?0+$'), '');
return '$text${measurement.unit}';
}
LineChartData _historyLineChartData(
BuildContext context, {
required BTHomeMetHistoryPage history,
}) {
final theme = Theme.of(context);
final color = _historyColor(history.measurement);
final values = history.values;
final spots = values
.asMap()
.entries
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
.toList(growable: false);
final chartMinY = _historyChartMinY(history);
final chartMaxY = _historyChartMaxY(history);
final yInterval = _historyYAxisInterval(
measurement: history.measurement,
minY: chartMinY,
maxY: chartMaxY,
);
return LineChartData(
minX: 0,
maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(),
minY: chartMinY,
maxY: chartMaxY,
clipData: const FlClipData.all(),
lineTouchData: const LineTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yInterval,
getDrawingHorizontalLine: (value) => FlLine(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.28),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 42,
interval: yInterval,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
space: 8,
child: Text(
_formatHistoryAxisValue(history.measurement, value),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.round();
if (value != index.toDouble() ||
!_shouldShowBottomSampleLabel(index, values.length)) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
space: 6,
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
color: color,
barWidth: 2.8,
isCurved: false,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
checkToShowDot: (spot, barData) =>
barData.spots.length <= 10 || spot == barData.spots.last,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: spot == barData.spots.last ? 4 : 2.5,
color: color,
strokeColor: theme.colorScheme.surface,
strokeWidth: 1.6,
),
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.24),
color.withValues(alpha: 0.03),
],
),
),
),
],
);
}
double _historyChartMinY(BTHomeMetHistoryPage history) {
final minValue = history.minimum ?? 0;
final maxValue = history.maximum ?? 0;
final spread = maxValue - minValue;
final padding = spread == 0
? _historyMinimumPadding(history.measurement, minValue)
: math.max(
spread * 0.18,
_historyMinimumPadding(history.measurement, minValue),
);
return minValue - padding;
}
double _historyChartMaxY(BTHomeMetHistoryPage history) {
final minValue = history.minimum ?? 0;
final maxValue = history.maximum ?? 0;
final spread = maxValue - minValue;
final padding = spread == 0
? _historyMinimumPadding(history.measurement, maxValue)
: math.max(
spread * 0.18,
_historyMinimumPadding(history.measurement, maxValue),
);
return maxValue + padding;
}
double _historyMinimumPadding(
BTHomeMetMeasurement measurement,
double reference,
) {
final scaled = math.max(reference.abs() * 0.05, 0.1);
return switch (measurement) {
BTHomeMetMeasurement.temperature => math.max(0.4, scaled),
BTHomeMetMeasurement.humidity => math.max(2.0, scaled),
BTHomeMetMeasurement.windSpeed => math.max(0.4, scaled),
BTHomeMetMeasurement.gust => math.max(0.4, scaled),
BTHomeMetMeasurement.rain => math.max(0.4, scaled),
};
}
double _historyYAxisInterval({
required BTHomeMetMeasurement measurement,
required double minY,
required double maxY,
}) {
final span = maxY - minY;
if (span <= 0) {
return 1;
}
final rough = span / 3;
return switch (measurement) {
BTHomeMetMeasurement.humidity => math.max(1, rough.round()).toDouble(),
BTHomeMetMeasurement.temperature => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.windSpeed => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.gust => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.rain => _niceStep(rough, minStep: 0.5),
};
}
double _niceStep(double value, {required double minStep}) {
if (value <= minStep) {
return minStep;
}
final exponent = math
.pow(10.0, (math.log(value) / math.ln10).floor())
.toDouble();
final normalized = value / exponent;
final stepped = switch (normalized) {
< 1.5 => 1.0,
< 3.0 => 2.0,
< 7.0 => 5.0,
_ => 10.0,
};
return math.max(minStep, stepped * exponent);
}
String _formatHistoryAxisValue(BTHomeMetMeasurement measurement, double value) {
final digits = measurement == BTHomeMetMeasurement.humidity ? 0 : 1;
return value.toStringAsFixed(digits).replaceFirst(RegExp(r'\.?0+$'), '');
}
bool _shouldShowBottomSampleLabel(int index, int sampleCount) {
if (index < 0 || index >= sampleCount) {
return false;
}
if (sampleCount <= 3) {
return true;
}
final middle = (sampleCount - 1) ~/ 2;
return index == 0 || index == middle || index == sampleCount - 1;
}

View File

@@ -1,12 +1,14 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import '../../services/bthome_met_history.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
class SensorMetricOption { class SensorMetricOption {
@@ -27,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>{},
@@ -546,12 +573,15 @@ SensorMetricCardData? _buildOptionPreviewCardData(
fieldKey: fieldKey, fieldKey: fieldKey,
icon: Icons.explore_outlined, icon: Icons.explore_outlined,
label: label, label: label,
value: previewValue, value: degrees == null
? previewValue
: '${_formatPreviewNumber(degrees, maxFractionDigits: 0)}°',
secondaryValue: degrees == null secondaryValue: degrees == null
? null ? null
: _previewFormatCardinalDirection(degrees), : _previewFormatCardinalDirection(degrees),
accent: const Color(0xFF8A5A44), accent: const Color(0xFF8A5A44),
channel: metricKey.channel, channel: metricKey.channel,
directionDegrees: degrees,
); );
case 'unixtime': case 'unixtime':
return SensorMetricCardData( return SensorMetricCardData(
@@ -790,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);
@@ -1006,6 +1036,13 @@ String _formatPreviewNumber(num value, {int maxFractionDigits = 2}) {
? math.min(maxFractionDigits, 1) ? math.min(maxFractionDigits, 1)
: maxFractionDigits; : maxFractionDigits;
final text = value.toStringAsFixed(digits); final text = value.toStringAsFixed(digits);
return _trimFractionZeros(text);
}
String _trimFractionZeros(String text) {
if (!text.contains('.')) {
return text;
}
return text.replaceFirst(RegExp(r'\.?0+$'), ''); return text.replaceFirst(RegExp(r'\.?0+$'), '');
} }
@@ -1028,6 +1065,9 @@ class SensorTelemetryCard extends StatelessWidget {
final Future<void> Function()? onRemove; final Future<void> Function()? onRemove;
final Future<void> Function()? onRefresh; final Future<void> Function()? onRefresh;
final VoidCallback? onCustomize; final VoidCallback? onCustomize;
final Future<void> Function(Contact contact)? onShowMetHistory;
final Future<void> Function()? onMoveUp;
final Future<void> Function()? onMoveDown;
final EdgeInsetsGeometry margin; final EdgeInsetsGeometry margin;
final String emptyMetricsMessage; final String emptyMetricsMessage;
final Map<String, String> labelOverrides; final Map<String, String> labelOverrides;
@@ -1042,14 +1082,35 @@ class SensorTelemetryCard extends StatelessWidget {
this.onRemove, this.onRemove,
this.onRefresh, this.onRefresh,
this.onCustomize, this.onCustomize,
this.onShowMetHistory,
this.onMoveUp,
this.onMoveDown,
this.margin = const EdgeInsets.only(bottom: 16), this.margin = const EdgeInsets.only(bottom: 16),
this.emptyMetricsMessage = this.emptyMetricsMessage =
'All fields are hidden. Use Visible fields to choose what to show.', 'All fields are hidden. Use Visible fields to choose what to show.',
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 || onCustomize != null || onRemove != null; onRefresh != null ||
onCustomize != null ||
onRemove != null ||
onMoveUp != null ||
onMoveDown != null ||
_rawTelemetryHex(contact?.telemetry) != null ||
(contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact));
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1128,21 +1189,21 @@ class SensorTelemetryCard extends StatelessWidget {
), ),
if (state == SensorRefreshState.refreshing) if (state == SensorRefreshState.refreshing)
_InlineStateMeta( _InlineStateMeta(
label: l10n.refreshing,
color: Color(0xFF266AC2), color: Color(0xFF266AC2),
spinning: true, spinning: true,
tooltip: l10n.refreshing,
), ),
if (state == SensorRefreshState.success) if (state == SensorRefreshState.success)
const _InlineStateMeta( const _InlineStateMeta(
label: 'Updated',
color: Color(0xFF218B63), color: Color(0xFF218B63),
icon: Icons.check_circle, icon: Icons.check_circle,
tooltip: 'Updated',
), ),
if (state == SensorRefreshState.unavailable) if (state == SensorRefreshState.unavailable)
_InlineStateMeta( _InlineStateMeta(
label: l10n.unavailable,
color: Color(0xFFB13B55), color: Color(0xFFB13B55),
icon: Icons.error_outline, icon: Icons.error_outline,
tooltip: l10n.unavailable,
), ),
], ],
), ),
@@ -1155,10 +1216,34 @@ class SensorTelemetryCard extends StatelessWidget {
onSelected: (value) async { onSelected: (value) async {
if (value == 'refresh' && onRefresh != null) { if (value == 'refresh' && onRefresh != null) {
await onRefresh!(); await onRefresh!();
} else if (value == 'move_up' && onMoveUp != null) {
await onMoveUp!();
} else if (value == 'move_down' && onMoveDown != null) {
await onMoveDown!();
} else if (value == 'copy_raw') {
final rawTelemetry = _rawTelemetryHex(
contact?.telemetry,
);
if (rawTelemetry != null && context.mounted) {
await Clipboard.setData(
ClipboardData(text: rawTelemetry),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Raw response copied'),
),
);
}
}
} else if (value == 'remove' && onRemove != null) { } else if (value == 'remove' && onRemove != null) {
await onRemove!(); await onRemove!();
} else if (value == 'customize' && onCustomize != null) { } else if (value == 'customize' && onCustomize != null) {
onCustomize!(); onCustomize!();
} else if (value == 'met_history' &&
contact != null &&
onShowMetHistory != null) {
await onShowMetHistory!(contact!);
} }
}, },
itemBuilder: (context) { itemBuilder: (context) {
@@ -1171,6 +1256,30 @@ class SensorTelemetryCard extends StatelessWidget {
), ),
); );
} }
if (onMoveUp != null) {
items.add(
const PopupMenuItem<String>(
value: 'move_up',
child: Text('Move up'),
),
);
}
if (onMoveDown != null) {
items.add(
const PopupMenuItem<String>(
value: 'move_down',
child: Text('Move down'),
),
);
}
if (_rawTelemetryHex(contact?.telemetry) != null) {
items.add(
const PopupMenuItem<String>(
value: 'copy_raw',
child: Text('Copy raw response'),
),
);
}
if (onCustomize != null) { if (onCustomize != null) {
items.add( items.add(
PopupMenuItem<String>( PopupMenuItem<String>(
@@ -1179,6 +1288,16 @@ class SensorTelemetryCard extends StatelessWidget {
), ),
); );
} }
if (contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact)) {
items.add(
const PopupMenuItem<String>(
value: 'met_history',
child: Text('MET history'),
),
);
}
if (onRemove != null) { if (onRemove != null) {
items.add( items.add(
PopupMenuItem<String>( PopupMenuItem<String>(
@@ -1221,6 +1340,11 @@ class SensorTelemetryCard extends StatelessWidget {
metric.wide) metric.wide)
? constraints.maxWidth ? constraints.maxWidth
: compactWidth, : compactWidth,
onLongPress: onRefresh == null
? null
: () async {
await onRefresh!();
},
), ),
) )
.toList(), .toList(),
@@ -1670,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,
); );
@@ -1682,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,
); );
@@ -1694,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,
); );
@@ -1784,10 +1908,11 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.explore_outlined, icon: Icons.explore_outlined,
label: label, label: label,
value: '${_formatNumber(degrees, maxFractionDigits: 0)} deg', value: '${_formatNumber(degrees, maxFractionDigits: 0)}°',
secondaryValue: _formatCardinalDirection(degrees), secondaryValue: _formatCardinalDirection(degrees),
accent: const Color(0xFF8A5A44), accent: const Color(0xFF8A5A44),
channel: metricKey.channel, channel: metricKey.channel,
directionDegrees: degrees,
); );
case 'rotation': case 'rotation':
@@ -2027,7 +2152,7 @@ class SensorTelemetryCard extends StatelessWidget {
? math.min(maxFractionDigits, 1) ? math.min(maxFractionDigits, 1)
: maxFractionDigits; : maxFractionDigits;
final text = value.toStringAsFixed(digits); final text = value.toStringAsFixed(digits);
return text.replaceFirst(RegExp(r'\.?0+$'), ''); return _trimFractionZeros(text);
} }
_Vector3? _asVector3(dynamic value) { _Vector3? _asVector3(dynamic value) {
@@ -2091,31 +2216,28 @@ class SensorTelemetryCard extends StatelessWidget {
} }
class _InlineStateMeta extends StatelessWidget { class _InlineStateMeta extends StatelessWidget {
final String label;
final Color color; final Color color;
final IconData? icon; final IconData? icon;
final bool spinning; final bool spinning;
final String? tooltip;
const _InlineStateMeta({ const _InlineStateMeta({
required this.label,
required this.color, required this.color,
this.icon, this.icon,
this.spinning = false, this.spinning = false,
this.tooltip,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final badge = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.10), color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: Row( child: spinning
mainAxisSize: MainAxisSize.min, ? SizedBox(
children: [
if (spinning)
SizedBox(
width: 11, width: 11,
height: 11, height: 11,
child: CircularProgressIndicator( child: CircularProgressIndicator(
@@ -2123,18 +2245,20 @@ class _InlineStateMeta extends StatelessWidget {
valueColor: AlwaysStoppedAnimation<Color>(color), valueColor: AlwaysStoppedAnimation<Color>(color),
), ),
) )
else if (icon != null) : Icon(
Icon(icon, size: 11, color: color), icon ?? Icons.info_outline,
const SizedBox(width: 4), size: 11,
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color, color: color,
fontWeight: FontWeight.w700,
), ),
), );
],
), if (tooltip == null || tooltip!.isEmpty) {
return badge;
}
return Tooltip(
message: tooltip!,
child: badge,
); );
} }
} }
@@ -2163,11 +2287,12 @@ 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;
final bool allowMapPreview; final bool allowMapPreview;
final GestureLongPressCallback? onLongPress;
const SensorMetricTile({ const SensorMetricTile({
super.key, super.key,
@@ -2175,10 +2300,46 @@ class SensorMetricTile extends StatelessWidget {
required this.width, required this.width,
this.keyPrefix = 'sensor_metric', this.keyPrefix = 'sensor_metric',
this.allowMapPreview = true, this.allowMapPreview = true,
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(
@@ -2189,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,
), ),
], ],
@@ -2200,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,
), ),
), ),
@@ -2229,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,
), ),
), ),
@@ -2249,142 +2410,196 @@ class SensorMetricTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final data = widget.data;
key: ValueKey('${keyPrefix}_${data.fieldKey}'), return Material(
width: width, color: Colors.transparent,
padding: const EdgeInsets.all(8), child: InkWell(
decoration: BoxDecoration( key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'),
color: data.accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)), onLongPress: widget.onLongPress,
), child: Container(
child: data.mapLocation == null || !allowMapPreview width: widget.width,
? Stack( padding: const EdgeInsets.all(8),
children: [ decoration: BoxDecoration(
Row( color: data.accent.withValues(alpha: 0.08),
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
),
child: data.directionDegrees != null
? Stack(
children: [ children: [
_MetricIcon(accent: data.accent, icon: data.icon), Row(
const SizedBox(width: 10), crossAxisAlignment: CrossAxisAlignment.center,
Expanded( children: [
child: _MetricText(data: data, keyPrefix: keyPrefix), _DirectionDial(
accent: data.accent,
degrees: data.directionDegrees!,
cardinal: data.secondaryValue ?? '',
compact: true,
),
const SizedBox(width: 10),
Expanded(
child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
),
],
), ),
], if (data.channel != null)
), Positioned(
if (data.channel != null) right: 0,
Positioned( bottom: 0,
right: 0, child: Text(
bottom: 0, 'ch${data.channel}',
child: Text( style: TextStyle(
'ch${data.channel}', fontSize: 9,
style: TextStyle( fontWeight: FontWeight.w700,
fontSize: 9, color: data.accent.withValues(alpha: 0.5),
fontWeight: FontWeight.w700, ),
color: data.accent.withValues(alpha: 0.5), ),
), ),
],
)
: data.mapLocation == null || !widget.allowMapPreview
? Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10),
Expanded(
child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
),
],
), ),
), if (data.channel != null)
], Positioned(
) right: 0,
: Column( bottom: 0,
crossAxisAlignment: CrossAxisAlignment.start, child: Text(
children: [ 'ch${data.channel}',
Row( style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: data.accent.withValues(alpha: 0.5),
),
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_MetricIcon(accent: data.accent, icon: data.icon), Row(
const SizedBox(width: 10), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: _MetricText(data: data, keyPrefix: keyPrefix), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10),
Expanded(
child: _MetricText(
data: data,
keyPrefix: widget.keyPrefix,
),
),
],
), ),
], const SizedBox(height: 10),
), Material(
const SizedBox(height: 10), color: Colors.transparent,
Material( child: InkWell(
color: Colors.transparent, borderRadius: BorderRadius.circular(14),
child: InkWell( onTap: () => _showExpandedMap(context),
borderRadius: BorderRadius.circular(14), child: ClipRRect(
onTap: () => _showExpandedMap(context), borderRadius: BorderRadius.circular(14),
child: ClipRRect( child: SizedBox(
borderRadius: BorderRadius.circular(14), height: 104,
child: SizedBox( width: double.infinity,
height: 104, child: Stack(
width: double.infinity, children: [
child: Stack( flutter_map.FlutterMap(
children: [ mapController: _previewMapController,
flutter_map.FlutterMap( options: flutter_map.MapOptions(
options: flutter_map.MapOptions( initialCenter: data.mapLocation!,
initialCenter: data.mapLocation!, initialZoom: 14,
initialZoom: 14, interactionOptions:
interactionOptions: const flutter_map.InteractionOptions(
const flutter_map.InteractionOptions( flags:
flags: flutter_map.InteractiveFlag.none, flutter_map.InteractiveFlag.none,
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.meshcore.sar.meshcore_sar_app',
), ),
), flutter_map.MarkerLayer(
children: [ markers: [
flutter_map.TileLayer( flutter_map.Marker(
urlTemplate: point: data.mapLocation!,
'https://tile.openstreetmap.org/{z}/{x}/{y}.png', width: 32,
userAgentPackageName: height: 32,
'com.meshcore.sar.meshcore_sar_app', child: Icon(
), Icons.location_on,
flutter_map.MarkerLayer( color: data.accent,
markers: [ size: 28,
flutter_map.Marker( ),
point: data.mapLocation!, ),
width: 32, ],
height: 32,
child: Icon(
Icons.location_on,
color: data.accent,
size: 28,
),
), ),
], ],
), ),
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: 0.55,
),
borderRadius: BorderRadius.circular(999),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.open_in_full,
size: 12,
color: Colors.white,
),
SizedBox(width: 4),
Text(
'Open map',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
], ],
), ),
Positioned( ),
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(999),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.open_in_full,
size: 12,
color: Colors.white,
),
SizedBox(width: 4),
Text(
'Open map',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
), ),
), ),
), ),
), ],
), ),
], ),
), ),
); );
} }
} }
@@ -2417,6 +2632,10 @@ class _MetricText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (data.directionDegrees != null) {
return _DirectionMetricText(data: data);
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -2456,12 +2675,138 @@ class _MetricText extends StatelessWidget {
} }
} }
class _DirectionMetricText extends StatelessWidget {
final SensorMetricCardData data;
const _DirectionMetricText({required this.data});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final directionLabel = data.secondaryValue ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
data.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: data.accent,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
data.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
height: 1.1,
),
),
if (directionLabel.isNotEmpty)
Text(
directionLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
color: data.accent.withValues(alpha: 0.9),
fontWeight: FontWeight.w800,
height: 1.1,
),
),
],
),
],
);
}
}
class _DirectionDial extends StatelessWidget {
final Color accent;
final double degrees;
final String cardinal;
final bool compact;
const _DirectionDial({
required this.accent,
required this.degrees,
required this.cardinal,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final normalized = ((degrees % 360) + 360) % 360;
final size = compact ? 34.0 : 44.0;
final innerSize = compact ? 24.0 : 30.0;
final arrowSize = compact ? 14.0 : 16.0;
final northTop = compact ? 1.0 : 3.0;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.08),
shape: BoxShape.circle,
border: Border.all(color: accent.withValues(alpha: 0.2)),
),
),
Container(
width: innerSize,
height: innerSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent.withValues(alpha: 0.2)),
),
),
Transform.rotate(
angle: normalized * math.pi / 180,
child: Icon(
Icons.navigation_rounded,
size: arrowSize,
color: accent,
),
),
Positioned(
top: northTop,
child: Text(
'N',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: accent.withValues(alpha: 0.65),
fontWeight: FontWeight.w800,
fontSize: compact ? 7.5 : 9,
),
),
),
],
),
);
}
}
class SensorMetricCardData { class SensorMetricCardData {
final String fieldKey; final String fieldKey;
final IconData icon; final IconData icon;
final String label; final String label;
final String value; final String value;
final String? secondaryValue; final String? secondaryValue;
final double? directionDegrees;
final Color accent; final Color accent;
final bool wide; final bool wide;
final LatLng? mapLocation; final LatLng? mapLocation;
@@ -2473,6 +2818,7 @@ class SensorMetricCardData {
required this.label, required this.label,
required this.value, required this.value,
this.secondaryValue, this.secondaryValue,
this.directionDegrees,
required this.accent, required this.accent,
this.wide = false, this.wide = false,
this.mapLocation, this.mapLocation,
@@ -2504,13 +2850,20 @@ class _RgbColor {
} }
const String _telemetrySourceChannelPrefix = '__source_channel:'; const String _telemetrySourceChannelPrefix = '__source_channel:';
const String _rawTelemetryHexKey = '__raw_lpp_hex';
String _extraFieldKey(String label) { String _extraFieldKey(String label) {
return 'extra:$label'; return 'extra:$label';
} }
bool _isTelemetryMetadataKey(String key) { bool _isTelemetryMetadataKey(String key) {
return key.startsWith(_telemetrySourceChannelPrefix); return key.startsWith(_telemetrySourceChannelPrefix) ||
key == _rawTelemetryHexKey;
}
String? _rawTelemetryHex(ContactTelemetry? telemetry) {
final value = telemetry?.extraSensorData?[_rawTelemetryHexKey];
return value is String && value.trim().isNotEmpty ? value : null;
} }
String _telemetrySourceChannelKey(String fieldKey) { String _telemetrySourceChannelKey(String fieldKey) {

View File

@@ -226,6 +226,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.3" version: "0.3.3"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
exif: exif:
dependency: transitive dependency: transitive
description: description:
@@ -306,6 +314,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -811,8 +827,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: main ref: "0bb339f684f709cf5b9d40c07827cc62beb18a4d"
resolved-ref: "3dbc82170583f616b590e5204a307a02f2b8833d" 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.0319.1+35 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: main ref: 0bb339f684f709cf5b9d40c07827cc62beb18a4d
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: codec2_flutter:
@@ -71,6 +71,7 @@ dependencies:
url: https://github.com/fleaflet/flutter_map.git url: https://github.com/fleaflet/flutter_map.git
ref: master ref: master
latlong2: ^0.9.0 latlong2: ^0.9.0
fl_chart: ^1.2.0
http: ^1.2.0 http: ^1.2.0

View File

@@ -0,0 +1,60 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/channel.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart';
void main() {
group('ChannelsProvider device sync preparation', () {
test('clears runtime channel state before sync', () {
final provider = ChannelsProvider();
provider.addOrUpdateChannel(
index: 2,
name: 'Ops',
secret: Uint8List.fromList(List<int>.filled(16, 7)),
);
provider.selectChannel(2);
provider.prepareForDeviceSync();
expect(provider.channels, isEmpty);
expect(provider.selectedChannelIndex, 0);
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

@@ -0,0 +1,69 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/services/contact_storage_service.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Contact createContact({required Uint8List key, required String name}) {
return Contact(
publicKey: key,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (46.0569 * 1e6).round(),
advLon: (14.5058 * 1e6).round(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'default',
);
});
test(
'initializeEarly respects the active profile storage namespace',
() async {
final storage = ContactStorageService();
final defaultContact = createContact(
key: Uint8List.fromList(List<int>.filled(32, 1)),
name: 'Default Contact',
);
final alphaContact = createContact(
key: Uint8List.fromList(List<int>.filled(32, 2)),
name: 'Alpha Contact',
);
await storage.saveContacts([defaultContact]);
await storage.saveContacts([alphaContact], namespace: 'alpha');
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'alpha',
);
final provider = ContactsProvider();
await provider.initializeEarly();
final names = provider.contacts
.where((contact) => !contact.isChannel)
.map((contact) => contact.advName)
.toList();
expect(names, <String>['Alpha Contact']);
expect(provider.storageNamespace, 'alpha');
},
);
}

View File

@@ -173,14 +173,8 @@ void main() {
var updated = scopedProvider.findContactByKey(scopedKey)!; var updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull); expect(updated.telemetry!.gpsLocation, isNull);
expect( expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
updated.displayLocation!.latitude, expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
closeTo(45.1234, 0.0001),
);
expect(
updated.displayLocation!.longitude,
closeTo(13.8765, 0.0001),
);
// Invalid 0,0 GPS frame should behave the same way. // Invalid 0,0 GPS frame should behave the same way.
final invalidGps = CayenneLppParser.createGpsData( final invalidGps = CayenneLppParser.createGpsData(
@@ -192,14 +186,8 @@ void main() {
updated = scopedProvider.findContactByKey(scopedKey)!; updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull); expect(updated.telemetry!.gpsLocation, isNull);
expect( expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
updated.displayLocation!.latitude, expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
closeTo(45.1234, 0.0001),
);
expect(
updated.displayLocation!.longitude,
closeTo(13.8765, 0.0001),
);
} }
}, },
); );
@@ -337,95 +325,107 @@ void main() {
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0)); expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
}); });
test('retains scalar telemetry but wipes stale extra sensor fields on refresh', () { test(
final fullTelemetry = ContactTelemetry( 'retains scalar telemetry but wipes stale extra sensor fields on refresh',
gpsLocation: const LatLng(46.0569, 14.5058), () {
batteryPercentage: 54.0, final fullTelemetry = ContactTelemetry(
batteryMilliVolts: 3780, gpsLocation: const LatLng(46.0569, 14.5058),
temperature: 19.5, batteryPercentage: 54.0,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)), batteryMilliVolts: 3780,
humidity: 58.0, temperature: 19.5,
pressure: 1011.2, timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
extraSensorData: const {'pm25': 8.0}, humidity: 58.0,
); pressure: 1011.2,
extraSensorData: const {'pm25': 8.0},
);
provider.addOrUpdateContact( provider.addOrUpdateContact(
createContact( createContact(
key: publicKey, key: publicKey,
type: ContactType.chat, type: ContactType.chat,
).copyWith(telemetry: fullTelemetry), ).copyWith(telemetry: fullTelemetry),
); );
final batteryOnly = CayenneLppParser.createBatteryData(3.95); final batteryOnly = CayenneLppParser.createBatteryData(3.95);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
final updated = provider.findContactByKey(publicKey)!; final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull); expect(updated.telemetry!.gpsLocation, isNull);
expect(updated.displayLocation, const LatLng(46.0569, 14.5058)); expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
expect(updated.telemetry!.batteryMilliVolts, isNotNull); expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.telemetry!.batteryPercentage, isNotNull); expect(updated.telemetry!.batteryPercentage, isNotNull);
expect(updated.telemetry!.temperature, equals(19.5)); expect(updated.telemetry!.temperature, equals(19.5));
expect(updated.telemetry!.humidity, equals(58.0)); expect(updated.telemetry!.humidity, equals(58.0));
expect(updated.telemetry!.pressure, equals(1011.2)); expect(updated.telemetry!.pressure, equals(1011.2));
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('__source_channel:battery', 0), containsPair('__source_channel:battery', 0),
); );
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('__source_channel:voltage', 0), containsPair('__source_channel:voltage', 0),
); );
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25'))); expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
}); },
);
test('replaces old source-channel mappings when a metric moves channels', () { test(
final initialTelemetry = ContactTelemetry( 'replaces old source-channel mappings when a metric moves channels',
gpsLocation: null, () {
batteryPercentage: null, final initialTelemetry = ContactTelemetry(
batteryMilliVolts: null, gpsLocation: null,
temperature: 21.5, batteryPercentage: null,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)), batteryMilliVolts: null,
humidity: null, temperature: 21.5,
pressure: null, timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
extraSensorData: const { humidity: null,
'__source_channel:temperature': 2, pressure: null,
'temperature_2': 21.5, extraSensorData: const {
'humidity_4': 66.0, '__source_channel:temperature': 2,
}, 'temperature_2': 21.5,
); 'humidity_4': 66.0,
},
);
provider.addOrUpdateContact( provider.addOrUpdateContact(
createContact( createContact(
key: publicKey, key: publicKey,
type: ContactType.chat, type: ContactType.chat,
).copyWith(telemetry: initialTelemetry), ).copyWith(telemetry: initialTelemetry),
); );
final movedChannelTelemetry = CayenneLppParser.createTemperatureData( final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
23.5, 23.5,
channel: 3, channel: 3,
); );
provider.updateTelemetry(publicKey.sublist(0, 6), movedChannelTelemetry); provider.updateTelemetry(
publicKey.sublist(0, 6),
movedChannelTelemetry,
);
final updated = provider.findContactByKey(publicKey)!; final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1)); expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('__source_channel:temperature', 3), containsPair('__source_channel:temperature', 3),
); );
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('temperature_3', closeTo(23.5, 0.1)), containsPair('temperature_3', closeTo(23.5, 0.1)),
); );
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
isNot(contains('temperature_2')), isNot(contains('temperature_2')),
); );
expect(updated.telemetry!.extraSensorData, isNot(contains('humidity_4'))); expect(
}); updated.telemetry!.extraSensorData,
isNot(contains('humidity_4')),
);
},
);
test('builds message snapshot from latest valid telemetry', () { test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData( final telemetryData = CayenneLppParser.createGpsData(
@@ -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', () {
@@ -870,6 +883,142 @@ void main() {
); );
}); });
}); });
group('ContactsProvider device sync preparation', () {
late ContactsProvider provider;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
});
test(
'keeps runtime contacts visible during sync without erasing persisted contacts or saved groups',
() async {
final key = createPublicKey(140);
final pendingKey = createPublicKey(180);
provider.addOrUpdateContact(
createContact(key: key, type: ContactType.chat, name: 'Synced Later'),
);
provider.addPendingAdvert(pendingKey);
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
await provider.prepareForDeviceContactSync();
expect(
provider.chatContacts.map((contact) => contact.advName),
contains('Synced Later'),
);
expect(provider.pendingAdverts, hasLength(1));
expect(provider.savedGroupsForSection('teamMembers'), hasLength(1));
final restored = ContactsProvider();
await restored.initializeEarly();
expect(
restored.chatContacts.map((contact) => contact.advName),
contains('Synced Later'),
);
expect(restored.savedGroupsForSection('teamMembers'), hasLength(1));
},
);
test(
'retains cached telemetry when sync re-adds a contact without telemetry',
() async {
final key = createPublicKey(141);
final contact = createContact(
key: key,
type: ContactType.sensor,
name: 'Sparse Sync Sensor',
).copyWith(
telemetry: ContactTelemetry(
temperature: 18.5,
humidity: 64.0,
timestamp: DateTime(2026, 3, 22, 8, 30),
),
);
provider.addOrUpdateContact(contact);
await provider.prepareForDeviceContactSync();
provider.addOrUpdateContact(
createContact(
key: key,
type: ContactType.sensor,
name: 'Sparse Sync Sensor',
),
);
final restored = provider.findContactByKey(key);
expect(restored, isNotNull);
expect(restored!.telemetry, isNotNull);
expect(restored.telemetry!.temperature, closeTo(18.5, 0.01));
expect(restored.telemetry!.humidity, closeTo(64.0, 0.01));
},
);
test('keeps self telemetry cached across reconnect for the same device', () async {
final selfKey = createPublicKey(200);
await provider.initialize(devicePublicKey: selfKey);
provider.updateTelemetry(
selfKey.sublist(0, 6),
CayenneLppParser.createTemperatureData(23.5, channel: 1),
);
await provider.prepareForDeviceContactSync(devicePublicKey: selfKey);
expect(provider.selfTelemetry, isNotNull);
expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1));
});
});
group('ContactsProvider self telemetry', () {
test(
'stores self telemetry without re-adding the device as a contact',
() async {
SharedPreferences.setMockInitialValues({});
final provider = ContactsProvider();
final selfKey = createPublicKey(200);
await provider.initialize(devicePublicKey: selfKey);
provider.updateTelemetry(
selfKey.sublist(0, 6),
CayenneLppParser.createTemperatureData(23.5, channel: 1),
);
expect(provider.selfTelemetry, isNotNull);
expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1));
expect(provider.findContactByKey(selfKey), isNull);
expect(
provider.contacts.any(
(contact) => contact.publicKeyHex == publicKeyHex(selfKey),
),
isFalse,
);
},
);
});
test('stores raw telemetry hex in metadata for verification', () async {
SharedPreferences.setMockInitialValues({});
final provider = ContactsProvider();
final telemetryKey = createPublicKey(150);
final contact = createContact(
key: telemetryKey,
type: ContactType.chat,
name: 'Telemetry Node',
);
provider.addOrUpdateContact(contact);
final payload = Uint8List.fromList(<int>[0x01, 0x67, 0x00, 0xEB]);
provider.updateTelemetry(telemetryKey.sublist(0, 6), payload);
final updated = provider.findContactByKey(telemetryKey)!;
expect(updated.telemetry?.extraSensorData?['__raw_lpp_hex'], '01 67 00 eb');
});
} }
String publicKeyHex(Uint8List publicKey) { String publicKeyHex(Uint8List publicKey) {

View File

@@ -0,0 +1,134 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/services/profiles_feature_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/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'alpha',
);
});
test('VoiceProvider reloads profile-scoped sessions', () async {
final player = _FakeVoicePlayerService();
final provider = VoiceProvider(codec: VoiceCodecService(), player: player);
addTearDown(provider.dispose);
await provider.reloadProfileScopedState();
provider.registerEnvelope(
const VoiceEnvelope(
sessionId: 'a1b2c3d4',
mode: VoicePacketMode.mode1200,
total: 2,
durationMs: 1600,
),
);
await Future<void>.delayed(const Duration(milliseconds: 50));
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'beta',
);
await provider.reloadProfileScopedState();
expect(provider.session('a1b2c3d4'), isNull);
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'alpha',
);
await provider.reloadProfileScopedState();
expect(provider.session('a1b2c3d4'), isNotNull);
});
test('ImageProvider reloads profile-scoped sessions', () async {
final provider = ImageProvider();
await provider.reloadProfileScopedState();
provider.registerEnvelope(
const ImageEnvelope(
sessionId: 'a1b2c3d4',
format: ImageFormat.avif,
total: 2,
width: 32,
height: 32,
sizeBytes: 8,
),
);
provider.addFragment(
ImagePacket(
sessionId: 'a1b2c3d4',
format: ImageFormat.avif,
index: 0,
total: 2,
data: Uint8List.fromList([1, 2, 3, 4]),
),
width: 32,
height: 32,
);
await Future<void>.delayed(const Duration(milliseconds: 50));
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'beta',
);
await provider.reloadProfileScopedState();
expect(provider.session('a1b2c3d4'), isNull);
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'alpha',
);
await provider.reloadProfileScopedState();
expect(provider.session('a1b2c3d4'), isNotNull);
});
}
class _FakeVoicePlayerService implements VoicePlayerService {
final StreamController<void> _events = StreamController<void>.broadcast();
bool _isPlaying = false;
@override
bool get isPlaying => _isPlaying;
@override
Duration get position => Duration.zero;
@override
Duration get duration => Duration.zero;
@override
Stream<void> get events => _events.stream;
@override
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
_isPlaying = true;
_events.add(null);
}
@override
Future<void> stop() async {
_isPlaying = false;
_events.add(null);
}
@override
void dispose() {
_events.close();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

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(
@@ -390,6 +391,165 @@ void main() {
); );
}); });
test('incoming contact messages dedupe by sender handle and text', () {
final provider = MessagesProvider();
provider.addMessage(
Message(
id: 'handle-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000800,
text: 'same payload',
senderName: 'Radio Alpha',
receivedAt: DateTime.now(),
),
);
provider.addMessage(
Message(
id: 'handle-2',
messageType: MessageType.contact,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000810,
text: 'same payload',
senderName: 'Radio Alpha',
receivedAt: DateTime.now(),
),
);
expect(provider.messages, hasLength(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]);
@@ -430,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

@@ -6,6 +6,7 @@ import 'package:meshcore_sar_app/models/device_info.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -19,10 +20,17 @@ class _FakeContactsProvider extends ContactsProvider {
} }
class _FakeConnectionProvider extends ConnectionProvider { class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected}) _FakeConnectionProvider({
: _isConnected = isConnected; required bool isConnected,
Uint8List? publicKey,
String? selfName,
}) : _isConnected = isConnected,
_publicKey = publicKey,
_selfName = selfName;
final bool _isConnected; final bool _isConnected;
final Uint8List? _publicKey;
final String? _selfName;
int pingCalls = 0; int pingCalls = 0;
@@ -31,6 +39,8 @@ class _FakeConnectionProvider extends ConnectionProvider {
connectionState: _isConnected connectionState: _isConnected
? ConnectionState.connected ? ConnectionState.connected
: ConnectionState.disconnected, : ConnectionState.disconnected,
publicKey: _publicKey,
selfName: _selfName,
); );
@override @override
@@ -65,9 +75,12 @@ void main() {
expect(provider.isLoaded, isTrue); expect(provider.isLoaded, isTrue);
} }
Contact buildSensorContact() { Contact buildSensorContact({
int firstByte = 0x44,
String name = 'WX Station',
}) {
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[0] = 0x44; publicKey[0] = firstByte;
return Contact( return Contact(
publicKey: publicKey, publicKey: publicKey,
@@ -75,7 +88,7 @@ void main() {
flags: 0, flags: 0,
outPathLen: 0, outPathLen: 0,
outPath: Uint8List(64), outPath: Uint8List(64),
advName: 'WX Station', advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0, advLat: 0,
advLon: 0, advLon: 0,
@@ -264,6 +277,36 @@ void main() {
); );
}); });
test('watched sensor order persists across reloads', () async {
SharedPreferences.setMockInitialValues({});
final first = buildSensorContact(firstByte: 0x44, name: 'First');
final second = buildSensorContact(firstByte: 0x45, name: 'Second');
final third = buildSensorContact(firstByte: 0x46, name: 'Third');
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(first);
await provider.addSensor(second);
await provider.addSensor(third);
await provider.reorderSensors(2, 0);
expect(provider.watchedSensorKeys, <String>[
third.publicKeyHex,
first.publicKeyHex,
second.publicKeyHex,
]);
final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider);
expect(reloadedProvider.watchedSensorKeys, <String>[
third.publicKeyHex,
first.publicKeyHex,
second.publicKeyHex,
]);
});
test( test(
'unsupported auto refresh minutes normalize to nearest option', 'unsupported auto refresh minutes normalize to nearest option',
() async { () async {
@@ -313,6 +356,50 @@ void main() {
expect(connectionProvider.pingCalls, 2); expect(connectionProvider.pingCalls, 2);
}); });
test('refreshDueSensors refreshes self every 30 seconds', () async {
SharedPreferences.setMockInitialValues({});
final selfKey = Uint8List(32)..[0] = 0x66;
final contactsProvider = ContactsProvider();
await contactsProvider.initialize(devicePublicKey: selfKey);
final connectionProvider = _FakeConnectionProvider(
isConnected: true,
publicKey: selfKey,
selfName: 'My Device',
);
final start = DateTime(2026, 3, 22, 9, 0);
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.refreshDueSensors(
now: start,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
expect(connectionProvider.pingCalls, 1);
await provider.refreshDueSensors(
now: start.add(const Duration(seconds: 30)),
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
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(
now: start.add(const Duration(minutes: 1)),
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
expect(connectionProvider.pingCalls, 3);
});
test( test(
'addSensor includes available extra telemetry fields by default', 'addSensor includes available extra telemetry fields by default',
() async { () async {
@@ -340,4 +427,60 @@ void main() {
); );
}, },
); );
test('selfContact includes stored self telemetry', () async {
SharedPreferences.setMockInitialValues({});
final selfKey = Uint8List(32)..[0] = 0x66;
final contactsProvider = ContactsProvider();
await contactsProvider.initialize(devicePublicKey: selfKey);
contactsProvider.updateTelemetry(
selfKey.sublist(0, 6),
CayenneLppParser.createTemperatureData(19.5, channel: 1),
);
final connectionProvider = _FakeConnectionProvider(
isConnected: true,
publicKey: selfKey,
selfName: 'My Device',
);
final provider = SensorsProvider();
await waitUntilLoaded(provider);
final selfContact = provider.selfContact(
contactsProvider,
connectionProvider,
);
expect(selfContact, isNotNull);
expect(selfContact!.telemetry, isNotNull);
expect(selfContact.telemetry!.temperature, closeTo(19.5, 0.1));
});
test(
'displaySelfKey still returns self when watched sensors exist',
() async {
SharedPreferences.setMockInitialValues({});
final selfKey = Uint8List(32)..[0] = 0x66;
final sensor = buildSensorContact(firstByte: 0x44, name: 'Weather');
final contactsProvider = ContactsProvider();
await contactsProvider.initialize(devicePublicKey: selfKey);
final connectionProvider = _FakeConnectionProvider(
isConnected: true,
publicKey: selfKey,
selfName: 'My Device',
);
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(sensor);
expect(
provider.displaySelfKey(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
),
selfKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(),
);
},
);
} }

View File

@@ -0,0 +1,206 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/device_info.dart' as device_info;
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/screens/discovery_screen.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected})
: _isConnected = isConnected;
final bool _isConnected;
final List<int> discoveredAdvertTypes = <int>[];
@override
device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo(
connectionState: _isConnected
? device_info.ConnectionState.connected
: device_info.ConnectionState.disconnected,
);
@override
Future<void> discoverNodeType({
required int advertType,
bool prefixOnly = false,
int since = 0,
}) async {
discoveredAdvertTypes.add(advertType);
}
}
Future<void> _pumpDiscoveryScreen(
WidgetTester tester, {
ContactsProvider? contactsProvider,
ConnectionProvider? connectionProvider,
DiscoveryScreen screen = const DiscoveryScreen(),
}) async {
final resolvedContactsProvider = contactsProvider ?? ContactsProvider();
final resolvedConnectionProvider =
connectionProvider ?? _FakeConnectionProvider(isConnected: true);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<ContactsProvider>.value(
value: resolvedContactsProvider,
),
ChangeNotifierProvider<ConnectionProvider>.value(
value: resolvedConnectionProvider,
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: screen,
),
),
);
await tester.pumpAndSettle();
}
Uint8List _publicKey(int seed) {
final bytes = Uint8List(32);
bytes[0] = seed;
bytes[1] = seed + 1;
bytes[2] = seed + 2;
return bytes;
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets(
'discovery actions are shown in the summary card instead of overflow menu',
(tester) async {
tester.view.physicalSize = const Size(375, 812);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final connectionProvider = _FakeConnectionProvider(isConnected: true);
await _pumpDiscoveryScreen(
tester,
connectionProvider: connectionProvider,
);
expect(tester.takeException(), isNull);
expect(find.text('Discover repeaters'), findsOneWidget);
expect(find.text('Discover sensors'), findsOneWidget);
expect(find.text('Resolve all'), findsOneWidget);
expect(find.text('Clear all'), findsOneWidget);
expect(find.text('Search discovered nodes'), findsNothing);
expect(find.byIcon(Icons.more_vert), findsNothing);
expect(
find.text(
'Use the discovery actions above to find repeaters and sensors on the mesh.',
),
findsOneWidget,
);
await tester.tap(find.text('Discover repeaters'));
await tester.pump();
expect(connectionProvider.discoveredAdvertTypes, [2]);
expect(find.text('Repeater discovery sent'), findsOneWidget);
},
);
testWidgets('auto discovery can trigger repeater discovery on open', (
tester,
) async {
final connectionProvider = _FakeConnectionProvider(isConnected: true);
await _pumpDiscoveryScreen(
tester,
connectionProvider: connectionProvider,
screen: const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true),
);
expect(connectionProvider.discoveredAdvertTypes, [2]);
expect(find.text('Repeater discovery sent'), findsOneWidget);
});
testWidgets('search and inline type filters narrow discovered nodes', (
tester,
) async {
tester.view.physicalSize = const Size(390, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final contactsProvider = ContactsProvider()
..addOrUpdatePendingAdvertMetadata(
publicKey: _publicKey(0x10),
typeValue: 2,
advName: 'Relay Alpha',
)
..addOrUpdatePendingAdvertMetadata(
publicKey: _publicKey(0x20),
typeValue: 4,
advName: 'WX Station',
)
..addOrUpdatePendingAdvertMetadata(
publicKey: _publicKey(0x30),
typeValue: 3,
advName: 'Ops Room',
);
await _pumpDiscoveryScreen(
tester,
contactsProvider: contactsProvider,
connectionProvider: _FakeConnectionProvider(isConnected: true),
);
expect(find.text('Search discovered nodes'), findsOneWidget);
expect(find.text('All'), findsOneWidget);
expect(find.text('Repeaters'), findsOneWidget);
expect(find.text('Sensors'), findsOneWidget);
expect(find.text('Others'), findsOneWidget);
expect(find.text('Relay Alpha'), findsOneWidget);
expect(find.text('WX Station'), findsOneWidget);
expect(find.text('Ops Room'), findsOneWidget);
await tester.tap(find.text('Sensors'));
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsNothing);
expect(find.text('WX Station'), findsOneWidget);
expect(find.text('Ops Room'), findsNothing);
expect(find.text('Discovered nodes (1/3)'), findsOneWidget);
await tester.tap(find.text('Others'));
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsNothing);
expect(find.text('WX Station'), findsNothing);
expect(find.text('Ops Room'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'relay');
await tester.pumpAndSettle();
expect(find.text('No matches'), findsOneWidget);
await tester.tap(find.text('All'));
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsOneWidget);
expect(find.text('Ops Room'), findsNothing);
await tester.enterText(find.byType(TextField), 'ops');
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsNothing);
expect(find.text('WX Station'), findsNothing);
expect(find.text('Ops Room'), findsOneWidget);
expect(find.text('Discovered nodes (1/3)'), findsOneWidget);
});
}

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

@@ -4,27 +4,16 @@ 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/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart'; import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:provider/provider.dart'; import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
setUp(() { Contact buildSensorContact({
SharedPreferences.setMockInitialValues({}); int firstByte = 0x44,
}); String name = 'WX Station',
}) {
Future<void> waitUntilLoaded(SensorsProvider provider) async {
for (var i = 0; i < 20 && !provider.isLoaded; i++) {
await Future<void>.delayed(Duration.zero);
}
expect(provider.isLoaded, isTrue);
}
Contact buildSensorContact() {
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[0] = 0x44; publicKey[0] = firstByte;
return Contact( return Contact(
publicKey: publicKey, publicKey: publicKey,
@@ -32,7 +21,7 @@ void main() {
flags: 0, flags: 0,
outPathLen: 0, outPathLen: 0,
outPath: Uint8List(64), outPath: Uint8List(64),
advName: 'WX Station', advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0, advLat: 0,
advLon: 0, advLon: 0,
@@ -50,48 +39,84 @@ void main() {
); );
} }
testWidgets('customize action opens full customization view', (tester) async { testWidgets('metric selector item shows channel-specific preview', (
tester,
) async {
final contact = buildSensorContact(); final contact = buildSensorContact();
final sensorsProvider = SensorsProvider(); final option = sensorMetricOptionsFor(contact, labelOverrides: const {})
final contactsProvider = ContactsProvider(); .firstWhere(
(entry) => entry.key.contains('illuminance') && entry.channel == 2,
await waitUntilLoaded(sensorsProvider); );
contactsProvider.addOrUpdateContact(contact);
await sensorsProvider.addSensor(contact);
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MaterialApp(
providers: [ localizationsDelegates: AppLocalizations.localizationsDelegates,
ChangeNotifierProvider<ContactsProvider>.value( supportedLocales: AppLocalizations.supportedLocales,
value: contactsProvider, home: Scaffold(
body: SensorMetricSelectorItem(
option: option,
visible: true,
span: 1,
canMoveUp: false,
canMoveDown: false,
onToggle: (_) {},
onRename: () {},
onSpanChanged: (_) {},
), ),
ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SensorsTab(),
), ),
), ),
); );
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Customize fields'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Customize WX Station'), findsOneWidget);
expect(find.text('Live preview'), findsOneWidget);
expect(find.text('Refresh schedule'), findsOneWidget);
expect( expect(
find.byKey( find.byKey(ValueKey('sensor_selector_metric_${option.key}')),
const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'),
),
findsOneWidget, findsOneWidget,
); );
expect(find.text('Channel 2'), findsOneWidget); expect(find.text('Channel 2'), findsOneWidget);
}); });
testWidgets('add sensor sheet keeps close action available when scrolled', (
tester,
) async {
tester.view.physicalSize = const Size(320, 480);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var didClose = false;
final candidates = List<Contact>.generate(
20,
(index) =>
buildSensorContact(firstByte: index + 1, name: 'Sensor ${index + 1}'),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AddSensorSheet(
candidates: candidates,
onSelect: (_) async {},
onClose: () {
didClose = true;
},
),
),
),
);
await tester.pump();
final closeButton = find.byTooltip('Close');
expect(closeButton, findsOneWidget);
await tester.drag(find.byType(ListView).last, const Offset(0, -600));
await tester.pump();
expect(closeButton, findsOneWidget);
await tester.tap(closeButton);
await tester.pump();
expect(didClose, isTrue);
});
} }

View File

@@ -0,0 +1,84 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/bthome_met_history.dart';
void main() {
test('parses a valid BTHome MET history response', () {
final parsed = BTHomeMetHistoryParser.parse('1,0,4,11.2,11.8,12.1,12.3');
expect(parsed.measurement, BTHomeMetMeasurement.temperature);
expect(parsed.page, 0);
expect(parsed.values, <double>[11.2, 11.8, 12.1, 12.3]);
expect(parsed.latest, 12.3);
expect(parsed.minimum, 11.2);
expect(parsed.maximum, 12.3);
});
test('rejects malformed BTHome MET history counts', () {
expect(
() => BTHomeMetHistoryParser.parse('2,0,3,41,42'),
throwsA(isA<BTHomeMetHistoryFormatException>()),
);
});
test('detects available BTHome MET measurements from telemetry', () {
final contact = Contact(
publicKey: Uint8List(32),
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX',
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
telemetry: ContactTelemetry(
temperature: 20.1,
humidity: 52,
extraSensorData: const {
'generic_sensor_1': 1,
'speed_2': 3.1,
'gust_2': 4.8,
'rain_2': 12.3,
},
timestamp: DateTime(2026, 3, 21, 12),
),
);
expect(bTHomeMetMeasurementsForContact(contact), <BTHomeMetMeasurement>[
BTHomeMetMeasurement.temperature,
BTHomeMetMeasurement.humidity,
BTHomeMetMeasurement.windSpeed,
BTHomeMetMeasurement.gust,
BTHomeMetMeasurement.rain,
]);
expect(supportsBTHomeMetHistory(contact), isTrue);
});
test('does not enable BTHome MET history without channel 1 capability', () {
final contact = Contact(
publicKey: Uint8List(32),
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX',
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
telemetry: ContactTelemetry(
temperature: 20.1,
humidity: 52,
extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3},
timestamp: DateTime(2026, 3, 21, 12),
),
);
expect(bTHomeMetMeasurementsForContact(contact), isEmpty);
expect(supportsBTHomeMetHistory(contact), isFalse);
});
}

View File

@@ -488,6 +488,39 @@ void main() {
}, },
); );
test('weather station payload keeps generic percentage and does not invent battery from ch1 voltage', () {
final payload = Uint8List.fromList([
0x01, 0x74, 0x00, 0x00,
0x02, 0x78, 0x64,
0x02, 0x73, 0x24, 0x32,
0x02, 0x65, 0x5A, 0x50,
0x02, 0x8A, 0xFF, 0xE4,
0x02, 0x74, 0x01, 0xE0,
0x02, 0x9D, 0x00,
0x02, 0x68, 0x76,
0x02, 0x81, 0x01, 0x4A,
0x02, 0x89, 0x01, 0xB8,
0x02, 0x67, 0x00, 0x2D,
0x02, 0xAD, 0x0A,
0x02, 0x84, 0x00, 0x56,
0x02, 0x8B, 0x00, 0xB3,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.batteryPercentage, isNull);
expect(decoded.batteryMilliVolts, isNull);
expect(decoded.temperature, closeTo(4.5, 0.1));
expect(decoded.humidity, closeTo(59.0, 0.1));
expect(decoded.pressure, closeTo(926.6, 0.1));
expect(decoded.extraSensorData!['voltage_1'], closeTo(0.0, 0.001));
expect(decoded.extraSensorData!['percentage_2'], equals(100.0));
expect(decoded.extraSensorData!['uv_2'], equals(1.0));
expect(decoded.extraSensorData!['rain_2'], closeTo(17.9, 0.1));
expect(decoded.extraSensorData!.containsKey('__source_channel:battery'), isFalse);
});
test('unknown sensor type is skipped gracefully', () { test('unknown sensor type is skipped gracefully', () {
final buffer = ByteData(5); final buffer = ByteData(5);
buffer.setUint8(0, 0); buffer.setUint8(0, 0);

View File

@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/compass_support.dart';
void main() {
tearDown(() {
debugDefaultTargetPlatformOverride = null;
});
test('reports compass support only on mobile targets', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(CompassSupport.isAvailable, isTrue);
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
expect(CompassSupport.isAvailable, isTrue);
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
expect(CompassSupport.isAvailable, isFalse);
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
expect(CompassSupport.isAvailable, isFalse);
});
}

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

@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
void main() {
final service = LocationTrackingService();
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: false,
activeProfileId: 'default',
);
service.fastLocationUpdatesEnabled = false;
service.fastLocationMovementThresholdMeters = 10.0;
service.fastLocationActiveCadenceSeconds = 10;
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 {
await service.updateFastLocationChannelIdx(3);
service.fastLocationChannelIdx = null;
await service.loadSettings();
expect(service.fastLocationChannelIdx, 3);
});
test('clears persisted fast location channel idx when unset', () async {
await service.updateFastLocationChannelIdx(7);
await service.updateFastLocationChannelIdx(null);
service.fastLocationChannelIdx = 99;
await service.loadSettings();
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

@@ -13,6 +13,8 @@ import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/map_provider.dart'; 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/sensors_provider.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
import 'package:meshcore_sar_app/services/app_config_snapshot_service.dart'; import 'package:meshcore_sar_app/services/app_config_snapshot_service.dart';
import 'package:meshcore_sar_app/services/contact_storage_service.dart'; import 'package:meshcore_sar_app/services/contact_storage_service.dart';
import 'package:meshcore_sar_app/services/device_config_applicator.dart'; import 'package:meshcore_sar_app/services/device_config_applicator.dart';
@@ -207,15 +209,21 @@ void main() {
final connectionProvider = _FakeConnectionProvider( final connectionProvider = _FakeConnectionProvider(
deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])), deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])),
); );
final voiceProvider = _FakeVoiceProvider();
final imageProvider = _FakeImageProvider();
final coordinator = _buildCoordinator( final coordinator = _buildCoordinator(
profileManager: manager, profileManager: manager,
connectionProvider: connectionProvider, connectionProvider: connectionProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
); );
await coordinator.syncActiveProfileForCurrentDevice(); await coordinator.syncActiveProfileForCurrentDevice();
expect(manager.activeProfileId, alpha.id); expect(manager.activeProfileId, alpha.id);
expect(connectionProvider.disconnectCallCount, 0); expect(connectionProvider.disconnectCallCount, 0);
expect(voiceProvider.reloadCallCount, 1);
expect(imageProvider.reloadCallCount, 1);
}); });
test( test(
@@ -257,6 +265,8 @@ void main() {
ProfileWorkspaceCoordinator _buildCoordinator({ ProfileWorkspaceCoordinator _buildCoordinator({
required ProfileManager profileManager, required ProfileManager profileManager,
_FakeConnectionProvider? connectionProvider, _FakeConnectionProvider? connectionProvider,
_FakeVoiceProvider? voiceProvider,
_FakeImageProvider? imageProvider,
}) { }) {
return ProfileWorkspaceCoordinator( return ProfileWorkspaceCoordinator(
profileManager: profileManager, profileManager: profileManager,
@@ -267,6 +277,8 @@ ProfileWorkspaceCoordinator _buildCoordinator({
mapProvider: _FakeMapProvider(), mapProvider: _FakeMapProvider(),
drawingProvider: _FakeDrawingProvider(), drawingProvider: _FakeDrawingProvider(),
channelsProvider: _FakeChannelsProvider(), channelsProvider: _FakeChannelsProvider(),
voiceProvider: voiceProvider ?? _FakeVoiceProvider(),
imageProvider: imageProvider ?? _FakeImageProvider(),
appProvider: _FakeAppProvider(), appProvider: _FakeAppProvider(),
appConfigSnapshotService: _FakeAppConfigSnapshotService(), appConfigSnapshotService: _FakeAppConfigSnapshotService(),
mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(), mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(),
@@ -332,9 +344,8 @@ class _FakeDeviceConfigApplicator extends DeviceConfigApplicator {
} }
class _FakeConnectionProvider implements ConnectionProvider { class _FakeConnectionProvider implements ConnectionProvider {
_FakeConnectionProvider({ _FakeConnectionProvider({DeviceInfo? deviceInfo})
DeviceInfo? deviceInfo, : deviceInfo = deviceInfo ?? DeviceInfo();
}) : deviceInfo = deviceInfo ?? DeviceInfo();
int disconnectCallCount = 0; int disconnectCallCount = 0;
@@ -407,6 +418,30 @@ class _FakeChannelsProvider implements ChannelsProvider {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }
class _FakeVoiceProvider implements VoiceProvider {
int reloadCallCount = 0;
@override
Future<void> reloadProfileScopedState() async {
reloadCallCount += 1;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeImageProvider implements ip.ImageProvider {
int reloadCallCount = 0;
@override
Future<void> reloadProfileScopedState() async {
reloadCallCount += 1;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeAppProvider implements AppProvider { class _FakeAppProvider implements AppProvider {
@override @override
Future<void> reloadProfileScopedSettings() async {} Future<void> reloadProfileScopedSettings() async {}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/widgets/connection_dialog.dart';
import 'package:provider/provider.dart';
class _FakeConnectionProvider extends ConnectionProvider {
int startScanCalls = 0;
int stopScanCalls = 0;
bool _isScanning = false;
@override
bool get isScanning => _isScanning;
@override
List<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<void> startScan() async {
startScanCalls += 1;
_isScanning = true;
notifyListeners();
}
@override
Future<void> stopScan() async {
stopScanCalls += 1;
_isScanning = false;
notifyListeners();
}
}
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() {
testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ConnectionDialog()),
),
),
);
await tester.pump();
expect(connectionProvider.startScanCalls, 0);
expect(
find.text('Press scan to search for nearby devices'),
findsOneWidget,
);
expect(find.text('Scan'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Scan'));
await tester.pump();
expect(connectionProvider.stopScanCalls, 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

@@ -1,7 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
@@ -22,8 +21,36 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(call) async {
switch (call.method) {
case 'canLaunch':
return true;
case 'launch':
final arguments = Map<dynamic, dynamic>.from(
call.arguments as Map<dynamic, dynamic>,
);
launchedUrls.add(arguments['url'] as String);
return true;
}
return null;
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
null,
);
}); });
testWidgets('received bubbles show signal chips on double tap', ( testWidgets('received bubbles show signal chips on double tap', (
@@ -153,6 +180,189 @@ void main() {
await _disposeHarness(tester, harness); await _disposeHarness(tester, harness);
} }
}); });
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 {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'message-link',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(31),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Check https://example.com/docs, then ping @[Rescue Team].',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere(
(widget) =>
widget.text.toPlainText().contains('https://example.com/docs'),
);
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == 'https://example.com/docs',
);
expect(richText.text.toPlainText(), contains('https://example.com/docs'));
expect(find.text('@Rescue Team'), findsOneWidget);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pump();
expect(launchedUrls, ['https://example.com/docs']);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble opens meshcore links in add contact screen', (
tester,
) async {
final harness = await _TestHarness.create();
try {
const payloadSegment = '00112233445566778899aabbccddeeff';
final advert =
'meshcore://'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'0011';
final message = Message(
id: 'message-meshcore-link',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(41),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Import $advert',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == advert,
);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pumpAndSettle();
expect(find.text('Import a shared contact advert'), findsOneWidget);
expect(find.text(advert), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble linkifies raw meshcore adverts', (tester) async {
final harness = await _TestHarness.create();
try {
const advert =
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'0011';
final message = Message(
id: 'message-meshcore-raw',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(51),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Import raw advert: $advert',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == advert,
);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pumpAndSettle();
expect(find.text('Import a shared contact advert'), findsOneWidget);
expect(find.text(advert), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
} }
Widget _buildApp(_TestHarness harness, Message message) { Widget _buildApp(_TestHarness harness, Message message) {
@@ -242,3 +452,22 @@ Future<void> _doubleTap(WidgetTester tester, Finder finder) async {
await tester.tap(finder); await tester.tap(finder);
await tester.pump(); await tester.pump();
} }
TextSpan? _findTextSpan(
InlineSpan span,
bool Function(TextSpan span) predicate,
) {
if (span is! TextSpan) {
return null;
}
if (predicate(span)) {
return span;
}
for (final child in span.children ?? const <InlineSpan>[]) {
final match = _findTextSpan(child, predicate);
if (match != null) {
return match;
}
}
return null;
}

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,10 +1,12 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.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';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
void main() { void main() {
@@ -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,8 +189,411 @@ 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 {
tester.view.physicalSize = const Size(1600, 2600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final publicKey = Uint8List(32);
publicKey[0] = 0x49;
final payload = Uint8List.fromList([
0x01, 0x74, 0x00, 0x00,
0x02, 0x78, 0x64,
0x02, 0x73, 0x24, 0x31,
0x02, 0x65, 0x67, 0xDE,
0x02, 0x8A, 0xFF, 0xE6,
0x02, 0x74, 0x01, 0xE0,
0x02, 0x9D, 0x00,
0x02, 0x68, 0x76,
0x02, 0x81, 0x00, 0xDC,
0x02, 0x89, 0x01, 0x36,
0x02, 0x67, 0x00, 0x2F,
0x02, 0xAD, 0x0A,
0x02, 0x84, 0x00, 0x50,
0x02, 0x8B, 0x00, 0xB3,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]);
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: CayenneLppParser.parse(payload),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
child: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {
'temperature',
'humidity',
'pressure',
'extra:percentage_2',
'extra:dew_2',
'extra:speed_2',
'extra:gust_2',
'extra:uv_2',
'extra:direction_2',
'extra:rain_2',
},
fieldSpans: sensorFullWidthFieldSpans(const {
'temperature',
'humidity',
'pressure',
'extra:percentage_2',
'extra:dew_2',
'extra:speed_2',
'extra:gust_2',
'extra:uv_2',
'extra:direction_2',
'extra:rain_2',
}),
),
),
),
),
);
expect(find.byKey(const ValueKey('sensor_metric_extra:percentage_2')), findsOneWidget);
expect(find.byKey(const ValueKey('sensor_metric_extra:uv_2')), findsOneWidget);
expect(find.text('100%'), findsOneWidget);
expect(find.text('1%'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('80°'), findsOneWidget);
expect(find.text(''), findsNothing);
});
testWidgets('renders direction metric with compact compass layout', (
tester,
) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x47;
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 {'direction_2': 111.0},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'extra:direction_2'},
fieldSpans: sensorFullWidthFieldSpans(const {'extra:direction_2'}),
),
),
),
);
expect(find.text('Direction'), findsOneWidget);
expect(find.text('111°'), findsOneWidget);
expect(find.text('E'), findsOneWidget);
expect(find.byIcon(Icons.navigation_rounded), findsOneWidget);
});
testWidgets('long pressing a telemetry bubble triggers refresh', (
tester,
) async {
final contact = buildContact();
var refreshCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onRefresh: () async {
refreshCount += 1;
},
),
),
),
);
await tester.longPress(
find.byKey(const ValueKey('sensor_metric_temperature')),
);
await tester.pumpAndSettle();
expect(refreshCount, 1);
});
testWidgets('overflow menu exposes move actions', (tester) async {
final contact = buildContact();
var moveUpCount = 0;
var moveDownCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onMoveUp: () async {
moveUpCount += 1;
},
onMoveDown: () async {
moveDownCount += 1;
},
),
),
),
);
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
expect(find.text('Move up'), findsOneWidget);
expect(find.text('Move down'), findsOneWidget);
await tester.tap(find.text('Move down'));
await tester.pumpAndSettle();
expect(moveUpCount, 0);
expect(moveDownCount, 1);
});
testWidgets('overflow menu copies raw response', (tester) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x48;
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(
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'__raw_lpp_hex': '01 67 00 d7'},
),
);
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
await tester.pumpWidget(
MaterialApp(
scaffoldMessengerKey: scaffoldKey,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
),
),
),
);
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
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.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(clipboardText, '01 67 00 d7');
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));
});
} }