mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 17:00:28 +00:00
Compare commits
33 Commits
v2026.0318
...
v2026.0323
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85dccea35 | ||
|
|
019224d955 | ||
|
|
4c048a5a50 | ||
|
|
2b42e7dd15 | ||
|
|
7ea6334f02 | ||
|
|
6f0a66dc68 | ||
|
|
a26ae96acd | ||
|
|
a6dc137e51 | ||
|
|
c2501442e3 | ||
|
|
71eac21df9 | ||
|
|
c299349b6a | ||
|
|
ab05f13de9 | ||
|
|
0e61dc5bf1 | ||
|
|
d45924d7b4 | ||
|
|
a24e699e22 | ||
|
|
7486837ad0 | ||
|
|
059125c714 | ||
|
|
9279ff2158 | ||
|
|
dc297b0b9f | ||
|
|
2d24481aba | ||
|
|
20a1df94eb | ||
|
|
5cc81bef65 | ||
|
|
6145462555 | ||
|
|
721fb7e7a2 | ||
|
|
5a58e45e13 | ||
|
|
de4258c0a4 | ||
|
|
d8cb35c595 | ||
|
|
8da69705ee | ||
|
|
e474168ba8 | ||
|
|
03dba8b3ce | ||
|
|
bc9e37bd22 | ||
|
|
a32d5d2c85 | ||
|
|
e683ab933f |
21
.github/workflows/build-artifacts.yml
vendored
21
.github/workflows/build-artifacts.yml
vendored
@@ -40,6 +40,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Cache Android NDK
|
- name: Cache Android NDK
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -95,6 +98,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable Linux desktop
|
- name: Enable Linux desktop
|
||||||
run: flutter config --enable-linux-desktop
|
run: flutter config --enable-linux-desktop
|
||||||
@@ -132,6 +138,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable Windows desktop
|
- name: Enable Windows desktop
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -174,6 +183,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable macOS desktop
|
- name: Enable macOS desktop
|
||||||
run: flutter config --enable-macos-desktop
|
run: flutter config --enable-macos-desktop
|
||||||
@@ -181,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
|
||||||
|
|
||||||
@@ -216,6 +231,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
@@ -272,6 +290,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable web
|
- name: Enable web
|
||||||
run: flutter config --enable-web
|
run: flutter config --enable-web
|
||||||
|
|||||||
3
.github/workflows/build-multiplatform.yml
vendored
3
.github/workflows/build-multiplatform.yml
vendored
@@ -27,6 +27,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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.
|
||||||
|
|||||||
@@ -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 = 119;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
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 = 119;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
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 = 119;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
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 = 119;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
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 = 119;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
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 = 119;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>119</string>
|
<string>125</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00103">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000216">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.372199">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.086406">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="121.394124">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="96.919368">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="365.414242">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="563.579992">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -2285,7 +2285,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 +2297,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 +2410,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 +2422,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 +2462,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 +2778,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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3219,7 +3219,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 +3237,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 +3387,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 +3405,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 +3465,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 +5009,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
|
||||||
|
|||||||
@@ -1898,6 +1898,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 +2705,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1905,6 +1905,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 +2707,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1717,7 +1717,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 +1729,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 +1819,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 +1831,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 +1869,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 +2669,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1901,6 +1901,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 +2709,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1905,6 +1905,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 +2713,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1881,6 +1881,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 +2682,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1896,6 +1896,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 +2699,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1893,6 +1893,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 +2696,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1900,6 +1900,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 +2707,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1890,6 +1890,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 +2692,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1881,6 +1881,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 +2682,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1883,6 +1883,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 +2680,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1896,6 +1896,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 +2703,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1796,6 +1796,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 +2575,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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,209 @@
|
|||||||
{}
|
{
|
||||||
|
"de": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"el": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"es": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fr": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"hr": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"it": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pl": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pt": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"sl": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"tr": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"uk": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"zh": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ class Channel {
|
|||||||
} else {
|
} else {
|
||||||
// Normal channel: require explicit secret
|
// Normal channel: require explicit secret
|
||||||
if (explicitSecret == null || explicitSecret.length != 16) {
|
if (explicitSecret == null || explicitSecret.length != 16) {
|
||||||
throw ArgumentError(
|
throw ArgumentError('Normal channels require a 16-byte secret');
|
||||||
'Normal channels require a 16-byte secret',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index,
|
index: index,
|
||||||
@@ -78,6 +76,18 @@ class Channel {
|
|||||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isHashChannelName(String channelName) {
|
||||||
|
return channelName.trim().startsWith('#');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String pskBase64ForHashChannelName(String channelName) {
|
||||||
|
final normalized = channelName.trim();
|
||||||
|
if (!isHashChannelName(normalized)) {
|
||||||
|
throw ArgumentError('Only #channels can export derived psk_base64');
|
||||||
|
}
|
||||||
|
return base64.encode(_generateHashChannelSecret(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the default public channel (channel 0)
|
/// Create the default public channel (channel 0)
|
||||||
/// Uses the well-known pre-shared key from MeshCore
|
/// Uses the well-known pre-shared key from MeshCore
|
||||||
factory Channel.publicChannel() {
|
factory Channel.publicChannel() {
|
||||||
@@ -85,8 +95,22 @@ class Channel {
|
|||||||
index: 0,
|
index: 0,
|
||||||
name: 'Public Channel',
|
name: 'Public Channel',
|
||||||
secret: Uint8List.fromList([
|
secret: Uint8List.fromList([
|
||||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
0x8b,
|
||||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
0x33,
|
||||||
|
0x87,
|
||||||
|
0xe9,
|
||||||
|
0xc5,
|
||||||
|
0xcd,
|
||||||
|
0xea,
|
||||||
|
0x6a,
|
||||||
|
0xc9,
|
||||||
|
0xe5,
|
||||||
|
0xed,
|
||||||
|
0xba,
|
||||||
|
0xa1,
|
||||||
|
0x15,
|
||||||
|
0xcd,
|
||||||
|
0x72,
|
||||||
]),
|
]),
|
||||||
flags: null,
|
flags: null,
|
||||||
);
|
);
|
||||||
@@ -95,6 +119,9 @@ class Channel {
|
|||||||
/// Check if this is a hash-based channel (name starts with '#')
|
/// Check if this is a hash-based channel (name starts with '#')
|
||||||
bool get isHashChannel => name.startsWith('#');
|
bool get isHashChannel => name.startsWith('#');
|
||||||
|
|
||||||
|
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
|
||||||
|
String get pskBase64 => base64.encode(secret);
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -131,12 +158,7 @@ class Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with modified fields
|
/// Create a copy with modified fields
|
||||||
Channel copyWith({
|
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
|
||||||
int? index,
|
|
||||||
String? name,
|
|
||||||
Uint8List? secret,
|
|
||||||
int? flags,
|
|
||||||
}) {
|
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index ?? this.index,
|
index: index ?? this.index,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
|
|||||||
@@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct (WiFi)';
|
return 'Direct (WiFi)';
|
||||||
case ConnectionMode.usb:
|
case ConnectionMode.usb:
|
||||||
return 'Direct (USB)';
|
return 'Direct (Serial)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||||
case ConnectionMode.usb:
|
case ConnectionMode.usb:
|
||||||
return 'USB serial connection to MeshCore device';
|
return 'Direct serial connection to MeshCore device';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -107,6 +108,15 @@ class _PendingRepeaterOwnerRequest {
|
|||||||
const _PendingRepeaterOwnerRequest({required this.publicKey});
|
const _PendingRepeaterOwnerRequest({required this.publicKey});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ContactsTabSection {
|
||||||
|
favourites,
|
||||||
|
teamMembers,
|
||||||
|
repeaters,
|
||||||
|
sensors,
|
||||||
|
rooms,
|
||||||
|
channels,
|
||||||
|
}
|
||||||
|
|
||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
static const int _maxDirectPayloadHops = 3;
|
static const int _maxDirectPayloadHops = 3;
|
||||||
@@ -162,6 +172,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isContactsEnabled => _isContactsEnabled;
|
bool get isContactsEnabled => _isContactsEnabled;
|
||||||
bool _isSensorsEnabled = true;
|
bool _isSensorsEnabled = true;
|
||||||
bool get isSensorsEnabled => _isSensorsEnabled;
|
bool get isSensorsEnabled => _isSensorsEnabled;
|
||||||
|
final Map<ContactsTabSection, bool> _contactsSectionVisibility = {
|
||||||
|
for (final section in ContactsTabSection.values) section: true,
|
||||||
|
};
|
||||||
|
|
||||||
bool _isVoiceSilenceTrimmingEnabled = true;
|
bool _isVoiceSilenceTrimmingEnabled = true;
|
||||||
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
||||||
@@ -199,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 = {};
|
||||||
@@ -238,6 +253,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
_initializeLocationTracking();
|
_initializeLocationTracking();
|
||||||
_loadMapEnabled();
|
_loadMapEnabled();
|
||||||
_loadContactsEnabled();
|
_loadContactsEnabled();
|
||||||
|
_loadContactsSectionVisibility();
|
||||||
_loadSensorsEnabled();
|
_loadSensorsEnabled();
|
||||||
_loadVoiceSilenceTrimmingEnabled();
|
_loadVoiceSilenceTrimmingEnabled();
|
||||||
_loadVoiceBandPassFilterEnabled();
|
_loadVoiceBandPassFilterEnabled();
|
||||||
@@ -259,6 +275,27 @@ class AppProvider with ChangeNotifier {
|
|||||||
return ProfileStorageScope.scopedKey(baseKey);
|
return ProfileStorageScope.scopedKey(baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isContactsSectionEnabled(ContactsTabSection section) {
|
||||||
|
return _contactsSectionVisibility[section] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _contactsSectionVisibilityKey(ContactsTabSection section) {
|
||||||
|
switch (section) {
|
||||||
|
case ContactsTabSection.favourites:
|
||||||
|
return 'contacts_section_favourites_enabled';
|
||||||
|
case ContactsTabSection.teamMembers:
|
||||||
|
return 'contacts_section_team_members_enabled';
|
||||||
|
case ContactsTabSection.repeaters:
|
||||||
|
return 'contacts_section_repeaters_enabled';
|
||||||
|
case ContactsTabSection.sensors:
|
||||||
|
return 'contacts_section_sensors_enabled';
|
||||||
|
case ContactsTabSection.rooms:
|
||||||
|
return 'contacts_section_rooms_enabled';
|
||||||
|
case ContactsTabSection.channels:
|
||||||
|
return 'contacts_section_channels_enabled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _startPacketCapturePersistence() {
|
void _startPacketCapturePersistence() {
|
||||||
_packetCaptureFlushTimer?.cancel();
|
_packetCaptureFlushTimer?.cancel();
|
||||||
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
@@ -565,6 +602,37 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadContactsSectionVisibility() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
for (final section in ContactsTabSection.values) {
|
||||||
|
_contactsSectionVisibility[section] =
|
||||||
|
prefs.getBool(_scopedKey(_contactsSectionVisibilityKey(section))) ??
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading contacts section visibility settings: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setContactsSectionEnabled(
|
||||||
|
ContactsTabSection section,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
_contactsSectionVisibility[section] = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey(_contactsSectionVisibilityKey(section)),
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving contacts section visibility setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load sensors enabled setting from shared preferences
|
/// Load sensors enabled setting from shared preferences
|
||||||
Future<void> _loadSensorsEnabled() async {
|
Future<void> _loadSensorsEnabled() async {
|
||||||
try {
|
try {
|
||||||
@@ -933,9 +1001,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
// we also import into firmware so subsequent getContact calls work.
|
// we also import into firmware so subsequent getContact calls work.
|
||||||
// Matches the official app which calls cmdGetAdvertPath for all adverts.
|
// Matches the official app which calls cmdGetAdvertPath for all adverts.
|
||||||
if (source == ContactReceiveSource.advert) {
|
if (source == ContactReceiveSource.advert) {
|
||||||
unawaited(
|
unawaited(connectionProvider.importReceivedAdvert(contact.publicKey));
|
||||||
connectionProvider.importReceivedAdvert(contact.publicKey),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final isNewPendingAdvert = contactsProvider
|
final isNewPendingAdvert = contactsProvider
|
||||||
.addOrUpdatePendingAdvertContact(
|
.addOrUpdatePendingAdvertContact(
|
||||||
@@ -1134,16 +1200,31 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich message with sender name from contacts first
|
// Enrich message with sender name from contacts.
|
||||||
|
// For room-forwarded messages, resolve the original poster's name
|
||||||
|
// from roomPostAuthorPrefix instead of the room server's key.
|
||||||
Message enrichedMessage = message;
|
Message enrichedMessage = message;
|
||||||
Contact? senderContact;
|
Contact? senderContact;
|
||||||
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
if (message.senderName == null) {
|
||||||
final contact = contactsProvider.findContactByKey(
|
// Try room post author first (the actual person who posted)
|
||||||
message.senderPublicKeyPrefix!,
|
if (message.roomPostAuthorPrefix != null) {
|
||||||
);
|
final author = contactsProvider.findContactByPrefix(
|
||||||
if (contact != null) {
|
message.roomPostAuthorPrefix!,
|
||||||
senderContact = contact;
|
);
|
||||||
enrichedMessage = message.copyWith(senderName: contact.advName);
|
if (author != null) {
|
||||||
|
senderContact = author;
|
||||||
|
enrichedMessage = message.copyWith(senderName: author.advName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to sender key (direct messages, or room with unknown author)
|
||||||
|
if (senderContact == null && message.senderPublicKeyPrefix != null) {
|
||||||
|
final contact = contactsProvider.findContactByKey(
|
||||||
|
message.senderPublicKeyPrefix!,
|
||||||
|
);
|
||||||
|
if (contact != null) {
|
||||||
|
senderContact = contact;
|
||||||
|
enrichedMessage = message.copyWith(senderName: contact.advName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
senderContact ??= message.senderPublicKeyPrefix != null
|
senderContact ??= message.senderPublicKeyPrefix != null
|
||||||
@@ -1237,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,
|
||||||
@@ -1274,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) {
|
||||||
@@ -1337,9 +1420,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
|
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debugPrint(
|
debugPrint('📊 [AppProvider] Binary response (0x8C tag=$tag) received');
|
||||||
'📊 [AppProvider] Binary response (0x8C tag=$tag) received',
|
|
||||||
);
|
|
||||||
// Binary responses carry Cayenne LPP telemetry data.
|
// Binary responses carry Cayenne LPP telemetry data.
|
||||||
// The data starts with a channel byte — valid LPP always has at least
|
// The data starts with a channel byte — valid LPP always has at least
|
||||||
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
|
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
|
||||||
@@ -1393,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1538,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(
|
||||||
@@ -1710,6 +1761,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return _prepareDirectMessageSend(
|
return _prepareDirectMessageSend(
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1779,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,
|
||||||
@@ -1806,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, {
|
||||||
@@ -1980,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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2144,9 +2261,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
// already added to pending adverts and showed notification.
|
// already added to pending adverts and showed notification.
|
||||||
// Nothing else to do — the callback pipeline handles everything.
|
// Nothing else to do — the callback pipeline handles everything.
|
||||||
if (wasKnown) {
|
if (wasKnown) {
|
||||||
debugPrint(
|
debugPrint(' [pushAdvert] Existing contact refreshed');
|
||||||
' [pushAdvert] Existing contact refreshed',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2479,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
|
||||||
@@ -2560,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,
|
||||||
@@ -2706,6 +2823,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
await Future.wait([
|
await Future.wait([
|
||||||
_loadMapEnabled(),
|
_loadMapEnabled(),
|
||||||
_loadContactsEnabled(),
|
_loadContactsEnabled(),
|
||||||
|
_loadContactsSectionVisibility(),
|
||||||
_loadSensorsEnabled(),
|
_loadSensorsEnabled(),
|
||||||
_loadVoiceSilenceTrimmingEnabled(),
|
_loadVoiceSilenceTrimmingEnabled(),
|
||||||
_loadVoiceBandPassFilterEnabled(),
|
_loadVoiceBandPassFilterEnabled(),
|
||||||
@@ -2728,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;
|
||||||
@@ -2745,10 +2880,14 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📍 [AppProvider] Sending fast GPS update ($reason): '
|
'📍 [AppProvider] Sending fast GPS update ($reason): '
|
||||||
'${position.latitude}, ${position.longitude}',
|
'${position.latitude}, ${position.longitude} via channel $channelIdx',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary());
|
await connectionProvider.sendChannelData(
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
dataType: MeshCoreConstants.dataTypeDev,
|
||||||
|
payload: packet.encodeBinary(),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
|
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
|
||||||
}
|
}
|
||||||
@@ -3286,6 +3425,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
|
||||||
@@ -3567,6 +3873,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,
|
||||||
);
|
);
|
||||||
@@ -3689,6 +3996,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();
|
||||||
}
|
}
|
||||||
@@ -3798,6 +4107,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -96,6 +97,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;
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
final MeshCoreBleService _bleService = MeshCoreBleService();
|
final MeshCoreBleService _bleService = MeshCoreBleService();
|
||||||
MeshCoreTcpService? _tcpService;
|
MeshCoreTcpService? _tcpService;
|
||||||
MeshCoreSerialService? _serialService;
|
MeshCoreSerialService? _serialService;
|
||||||
|
Future<void> Function()? _serialDisconnectTransport;
|
||||||
|
|
||||||
/// Expose BLE service for background location tracking
|
/// Expose BLE service for background location tracking
|
||||||
MeshCoreBleService get bleService => _bleService;
|
MeshCoreBleService get bleService => _bleService;
|
||||||
@@ -104,8 +105,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;
|
||||||
@@ -211,6 +210,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;
|
||||||
@@ -246,7 +254,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeMode != ConnectionMode.usb && _serialService != null) {
|
if (activeMode != ConnectionMode.usb && _serialService != null) {
|
||||||
_disposeSerialService();
|
await _disposeSerialService();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,10 +268,20 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _disposeSerialService() {
|
Future<void> _disposeSerialService() async {
|
||||||
_serialService?.markDisconnected();
|
final service = _serialService;
|
||||||
_serialService?.dispose();
|
final disconnectTransport = _serialDisconnectTransport;
|
||||||
_serialService = null;
|
_serialService = null;
|
||||||
|
_serialDisconnectTransport = null;
|
||||||
|
if (service == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.markDisconnected();
|
||||||
|
service.writeRaw = null;
|
||||||
|
if (disconnectTransport != null) {
|
||||||
|
await disconnectTransport();
|
||||||
|
}
|
||||||
|
service.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _beginConnectionAttempt({
|
void _beginConnectionAttempt({
|
||||||
@@ -452,10 +470,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(
|
||||||
@@ -522,6 +536,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);
|
||||||
|
|
||||||
@@ -536,10 +560,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();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -552,6 +581,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?,
|
||||||
@@ -594,6 +626,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();
|
||||||
};
|
};
|
||||||
@@ -635,6 +668,13 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
|
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final adapterError = await _awaitBleAdapterReady();
|
||||||
|
if (adapterError != null) {
|
||||||
|
debugPrint('⚠️ [Provider] BLE adapter not ready: $adapterError');
|
||||||
|
_error = adapterError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await for (final scanResult in _bleService.scanForDevices(
|
await for (final scanResult in _bleService.scanForDevices(
|
||||||
timeout: const Duration(seconds: 10),
|
timeout: const Duration(seconds: 10),
|
||||||
)) {
|
)) {
|
||||||
@@ -676,6 +716,53 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _awaitBleAdapterReady({
|
||||||
|
Duration timeout = const Duration(seconds: 4),
|
||||||
|
}) async {
|
||||||
|
if (!await FlutterBluePlus.isSupported) {
|
||||||
|
return 'Bluetooth is not supported on this device.';
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = await FlutterBluePlus.adapterState.first;
|
||||||
|
debugPrint('🔵 [Provider] Initial BLE adapter state: $state');
|
||||||
|
|
||||||
|
if (state == BluetoothAdapterState.unknown ||
|
||||||
|
state == BluetoothAdapterState.turningOn) {
|
||||||
|
debugPrint('⏳ [Provider] Waiting for BLE adapter to finish initializing');
|
||||||
|
try {
|
||||||
|
state = await FlutterBluePlus.adapterState
|
||||||
|
.where(
|
||||||
|
(candidate) =>
|
||||||
|
candidate != BluetoothAdapterState.unknown &&
|
||||||
|
candidate != BluetoothAdapterState.turningOn,
|
||||||
|
)
|
||||||
|
.first
|
||||||
|
.timeout(timeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
state = FlutterBluePlus.adapterStateNow;
|
||||||
|
}
|
||||||
|
debugPrint('🔵 [Provider] BLE adapter state after wait: $state');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == BluetoothAdapterState.on) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (state) {
|
||||||
|
BluetoothAdapterState.off =>
|
||||||
|
'Bluetooth is turned off. Turn it on and try again.',
|
||||||
|
BluetoothAdapterState.turningOff =>
|
||||||
|
'Bluetooth is turning off. Turn it back on and try again.',
|
||||||
|
BluetoothAdapterState.unauthorized =>
|
||||||
|
'Bluetooth access is not allowed. Check app permissions in System Settings and try again.',
|
||||||
|
BluetoothAdapterState.unavailable =>
|
||||||
|
'Bluetooth hardware is unavailable. On macOS, verify Bluetooth access is enabled for this app target.',
|
||||||
|
BluetoothAdapterState.unknown || BluetoothAdapterState.turningOn =>
|
||||||
|
'Bluetooth is still initializing. Please try again in a moment.',
|
||||||
|
BluetoothAdapterState.on => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop scanning
|
/// Stop scanning
|
||||||
Future<void> stopScan() async {
|
Future<void> stopScan() async {
|
||||||
await FlutterBluePlus.stopScan();
|
await FlutterBluePlus.stopScan();
|
||||||
@@ -762,16 +849,22 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// The caller is responsible for opening the serial port and wiring
|
/// The caller is responsible for opening the serial port and wiring
|
||||||
/// [service.writeRaw] + [service.feedRawBytes] before calling this.
|
/// [service.writeRaw] + [service.feedRawBytes] before calling this.
|
||||||
/// After this call succeeds, [service.markConnected()] has already run.
|
/// After this call succeeds, [service.markConnected()] has already run.
|
||||||
Future<bool> connectSerial(MeshCoreSerialService service) async {
|
Future<bool> connectSerial({
|
||||||
|
required MeshCoreSerialService service,
|
||||||
|
required Future<void> Function() disconnectTransport,
|
||||||
|
required String deviceId,
|
||||||
|
required String deviceName,
|
||||||
|
}) async {
|
||||||
debugPrint('🔌 [Provider] connectSerial()');
|
debugPrint('🔌 [Provider] connectSerial()');
|
||||||
await _prepareForConnectionSwitch(ConnectionMode.usb);
|
await _prepareForConnectionSwitch(ConnectionMode.usb);
|
||||||
_disposeSerialService();
|
await _disposeSerialService();
|
||||||
_serialService = service;
|
_serialService = service;
|
||||||
|
_serialDisconnectTransport = disconnectTransport;
|
||||||
_wireServiceCallbacks(_serialService!);
|
_wireServiceCallbacks(_serialService!);
|
||||||
_beginConnectionAttempt(
|
_beginConnectionAttempt(
|
||||||
mode: ConnectionMode.usb,
|
mode: ConnectionMode.usb,
|
||||||
deviceId: 'usb',
|
deviceId: deviceId,
|
||||||
deviceName: 'USB Companion',
|
deviceName: deviceName,
|
||||||
);
|
);
|
||||||
|
|
||||||
// markConnected() should already have been called by the transport.
|
// markConnected() should already have been called by the transport.
|
||||||
@@ -780,6 +873,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_deviceInfo = _deviceInfo.copyWith(
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
connectionState: ConnectionState.error,
|
connectionState: ConnectionState.error,
|
||||||
);
|
);
|
||||||
|
await _disposeSerialService();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -788,7 +882,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Disconnect from USB serial device.
|
/// Disconnect from USB serial device.
|
||||||
Future<void> disconnectSerial() async {
|
Future<void> disconnectSerial() async {
|
||||||
_disposeSerialService();
|
await _disposeSerialService();
|
||||||
_resetConnectionSession();
|
_resetConnectionSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,6 +1741,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';
|
||||||
@@ -1659,10 +1754,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');
|
||||||
@@ -1727,6 +1824,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).
|
||||||
@@ -1781,11 +1914,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();
|
||||||
|
|
||||||
@@ -1872,10 +2007,53 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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:
|
||||||
@@ -2116,43 +2294,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) {
|
||||||
@@ -2220,6 +2361,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) {
|
||||||
@@ -2231,6 +2373,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
autoAddRoomServers: null,
|
autoAddRoomServers: null,
|
||||||
autoAddSensors: null,
|
autoAddSensors: null,
|
||||||
autoAddOverwriteOldest: null,
|
autoAddOverwriteOldest: null,
|
||||||
|
autoAddMaxHops: null,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
@@ -2250,6 +2393,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';
|
||||||
@@ -2264,6 +2408,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,
|
||||||
@@ -2271,6 +2416,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) {
|
||||||
@@ -2279,6 +2425,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 {
|
||||||
@@ -2320,7 +2483,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();
|
||||||
@@ -2363,6 +2525,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// - Total storage in KB (if available)
|
/// - Total storage in KB (if available)
|
||||||
///
|
///
|
||||||
/// Results arrive via onBatteryAndStorage callback and update deviceInfo.
|
/// Results arrive via onBatteryAndStorage callback and update deviceInfo.
|
||||||
|
/// Request the companion device's own telemetry (GPS, temp, sensors).
|
||||||
|
/// The response arrives via onTelemetryReceived with the device's own key.
|
||||||
|
Future<void> requestSelfTelemetry() async {
|
||||||
|
if (!_activeService.isConnected) return;
|
||||||
|
try {
|
||||||
|
// Firmware expects a 4-byte CMD_SEND_TELEMETRY_REQ frame for "self".
|
||||||
|
await _activeService.requestTelemetry(Uint8List(0), zeroHop: true);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [Provider] requestSelfTelemetry failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> getBatteryAndStorage() async {
|
Future<void> getBatteryAndStorage() async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
@@ -2381,7 +2555,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
|
||||||
@@ -2420,10 +2593,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(
|
||||||
@@ -2753,6 +2922,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_rxActivityTimer?.cancel();
|
_rxActivityTimer?.cancel();
|
||||||
_txActivityTimer?.cancel();
|
_txActivityTimer?.cancel();
|
||||||
_stopAckCleanupTimer();
|
_stopAckCleanupTimer();
|
||||||
|
final disconnectTransport = _serialDisconnectTransport;
|
||||||
|
if (disconnectTransport != null) {
|
||||||
|
unawaited(disconnectTransport());
|
||||||
|
_serialDisconnectTransport = null;
|
||||||
|
}
|
||||||
|
_serialService?.dispose();
|
||||||
_bleService.dispose();
|
_bleService.dispose();
|
||||||
_tcpService?.dispose();
|
_tcpService?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -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,42 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear runtime contact state before a live device contact sync begins.
|
||||||
|
///
|
||||||
|
/// This intentionally does not touch persisted storage. It keeps any saved
|
||||||
|
/// contact groups for the active profile, but removes stale in-memory device
|
||||||
|
/// contacts and discovery state so a newly connected device starts from an
|
||||||
|
/// empty list while sync is in progress.
|
||||||
|
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] Clearing runtime contacts before device sync',
|
||||||
|
);
|
||||||
|
_retainedContactsForSync
|
||||||
|
..clear()
|
||||||
|
..addEntries(
|
||||||
|
_contactsForStorage().map(
|
||||||
|
(contact) => MapEntry(contact.publicKeyHex, contact),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_contacts.clear();
|
||||||
|
_pendingAdverts.clear();
|
||||||
|
_estimatedLocations.clear();
|
||||||
|
_rssiObservations.clear();
|
||||||
|
_ensurePublicChannelExists();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove self-contact from loaded contacts (called after BLE connection established)
|
/// Remove self-contact from loaded contacts (called after BLE connection established)
|
||||||
void _removeSelfContact(Uint8List devicePublicKey) {
|
void _removeSelfContact(Uint8List devicePublicKey) {
|
||||||
final selfKeyHex = devicePublicKey
|
final selfKeyHex = devicePublicKey
|
||||||
@@ -345,6 +389,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 +580,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 +684,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 +698,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 +728,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 +1007,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 +1048,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 +1073,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();
|
||||||
@@ -1146,6 +1222,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,
|
||||||
@@ -1195,10 +1293,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Find contact by public key prefix (6 bytes)
|
/// Find contact by public key prefix (6 bytes)
|
||||||
Contact? _findContactByPrefix(Uint8List prefix) {
|
Contact? _findContactByPrefix(Uint8List prefix) {
|
||||||
if (prefix.length < 6) return null;
|
if (prefix.isEmpty) return null;
|
||||||
|
|
||||||
|
final takeLen = prefix.length < 6 ? prefix.length : 6;
|
||||||
final prefixHex = prefix
|
final prefixHex = prefix
|
||||||
.sublist(0, 6)
|
.sublist(0, takeLen)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
@@ -1234,6 +1333,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) {
|
||||||
@@ -1531,6 +1660,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();
|
||||||
@@ -1555,6 +1685,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();
|
||||||
@@ -1637,6 +1768,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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -737,8 +737,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
if (message.isContactMessage) {
|
if (message.isContactMessage) {
|
||||||
// Match by sender key + sender timestamp (matches official app's DB
|
// Match by sender key + sender timestamp (matches official app's DB
|
||||||
// uniqueness: contactPublicKey + senderTimestamp + text + txtType).
|
// uniqueness: contactPublicKey + senderTimestamp + text + txtType).
|
||||||
return existing.senderKeyShort == message.senderKeyShort &&
|
if (existing.senderKeyShort == message.senderKeyShort &&
|
||||||
existing.senderTimestamp == message.senderTimestamp;
|
existing.senderTimestamp == message.senderTimestamp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback dedup when retransmits surface as separate inbound rows
|
||||||
|
// without a stable timestamp/message id, but still carry the same
|
||||||
|
// visible sender identity and payload.
|
||||||
|
return _matchesDuplicateSenderIdentity(existing, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.isChannelMessage) {
|
if (message.isChannelMessage) {
|
||||||
@@ -760,29 +767,29 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final existingSenderKey = existing.senderKeyShort;
|
return _matchesDuplicateSenderIdentity(existing, message);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// System messages and other types: never deduplicate by scope alone.
|
// System messages and other types: never deduplicate by scope alone.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _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;
|
||||||
|
}
|
||||||
|
|
||||||
/// Add multiple messages
|
/// Add multiple messages
|
||||||
void addMessages(List<Message> messages) {
|
void addMessages(List<Message> messages) {
|
||||||
int addedCount = 0;
|
int addedCount = 0;
|
||||||
@@ -2347,17 +2354,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,
|
||||||
|
|||||||
@@ -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 int selfAutoRefreshMinutes = 1;
|
||||||
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 _isRefreshDue(
|
||||||
|
String publicKeyHex, {
|
||||||
|
required int minutes,
|
||||||
|
required DateTime refreshTime,
|
||||||
|
}) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex];
|
||||||
|
return lastRefreshAt == null ||
|
||||||
|
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes);
|
||||||
|
}
|
||||||
|
|
||||||
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) &&
|
||||||
|
_isRefreshDue(
|
||||||
|
self.publicKeyHex,
|
||||||
|
minutes: selfAutoRefreshMinutes,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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/device_info.dart' show ConnectionMode;
|
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';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@@ -23,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';
|
||||||
@@ -68,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;
|
||||||
@@ -107,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
|
||||||
@@ -291,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;
|
||||||
|
|
||||||
@@ -434,6 +422,163 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) {
|
||||||
|
// Request self telemetry so it's fresh
|
||||||
|
context.read<ConnectionProvider>().requestSelfTelemetry();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => Consumer<ContactsProvider>(
|
||||||
|
builder: (context, contactsProvider, child) {
|
||||||
|
final telemetry = contactsProvider.selfTelemetry;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
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)}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _deviceInfoRow(
|
||||||
|
BuildContext context,
|
||||||
|
IconData icon,
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
|
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
|
||||||
final inactive = Colors.grey.withValues(alpha: 0.3);
|
final inactive = Colors.grey.withValues(alpha: 0.3);
|
||||||
return Row(
|
return Row(
|
||||||
@@ -592,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(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,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(
|
||||||
@@ -797,7 +914,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadDeveloperModePreference();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1061,53 +1177,57 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
GestureDetector(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onTap: () =>
|
||||||
children: [
|
_showDeviceInfoSheet(context, deviceInfo),
|
||||||
Icon(
|
child: Row(
|
||||||
isTcpConnected
|
mainAxisSize: MainAxisSize.min,
|
||||||
? Icons.wifi_rounded
|
children: [
|
||||||
: Icons.bluetooth_connected_rounded,
|
Icon(
|
||||||
size: 13,
|
isTcpConnected
|
||||||
color: signalColor,
|
? Icons.wifi_rounded
|
||||||
),
|
: Icons.bluetooth_connected_rounded,
|
||||||
if (!isTcpConnected &&
|
size: 13,
|
||||||
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!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ T? _maybeProvider<T>(BuildContext context) {
|
|||||||
|
|
||||||
class LiveTrafficScreen extends StatefulWidget {
|
class LiveTrafficScreen extends StatefulWidget {
|
||||||
final List<BlePacketLog> Function() logReader;
|
final List<BlePacketLog> Function() logReader;
|
||||||
final int Function()? rxCountReader;
|
|
||||||
final Listenable? refreshListenable;
|
final Listenable? refreshListenable;
|
||||||
final DateTime Function() now;
|
final DateTime Function() now;
|
||||||
final VoidCallback? openPacketLogs;
|
final VoidCallback? openPacketLogs;
|
||||||
@@ -35,7 +34,6 @@ class LiveTrafficScreen extends StatefulWidget {
|
|||||||
const LiveTrafficScreen({
|
const LiveTrafficScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.logReader,
|
required this.logReader,
|
||||||
this.rxCountReader,
|
|
||||||
this.refreshListenable,
|
this.refreshListenable,
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
this.openPacketLogs,
|
this.openPacketLogs,
|
||||||
@@ -49,7 +47,6 @@ class LiveTrafficScreen extends StatefulWidget {
|
|||||||
return LiveTrafficScreen(
|
return LiveTrafficScreen(
|
||||||
key: key,
|
key: key,
|
||||||
logReader: () => provider.bleService.packetLogs,
|
logReader: () => provider.bleService.packetLogs,
|
||||||
rxCountReader: () => provider.rxPacketCount,
|
|
||||||
refreshListenable: provider,
|
refreshListenable: provider,
|
||||||
openPacketLogs: openPacketLogs,
|
openPacketLogs: openPacketLogs,
|
||||||
);
|
);
|
||||||
@@ -118,15 +115,17 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final logs = widget.logReader();
|
||||||
|
final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs);
|
||||||
final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
|
final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
|
||||||
widget.logReader(),
|
logs,
|
||||||
now: widget.now(),
|
now: widget.now(),
|
||||||
clearedAt: _clearedAt,
|
clearedAt: _clearedAt,
|
||||||
preferredHashSize: _preferredHashSize,
|
preferredHashSize: _preferredHashSize,
|
||||||
window: _selectedWindow,
|
window: _selectedWindow,
|
||||||
);
|
);
|
||||||
final snapshot = LiveTrafficSummary.fromLogs(
|
final snapshot = LiveTrafficSummary.fromLogs(
|
||||||
widget.logReader(),
|
logs,
|
||||||
now: widget.now(),
|
now: widget.now(),
|
||||||
clearedAt: _clearedAt,
|
clearedAt: _clearedAt,
|
||||||
preferredHashSize: _preferredHashSize,
|
preferredHashSize: _preferredHashSize,
|
||||||
@@ -177,7 +176,7 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_SummaryPanel(
|
_SummaryPanel(
|
||||||
snapshot: snapshot,
|
snapshot: snapshot,
|
||||||
totalRxCount: widget.rxCountReader?.call(),
|
totalRxCount: totalRxDataCount,
|
||||||
routeHashCounts: routeHashCounts,
|
routeHashCounts: routeHashCounts,
|
||||||
onWindowTap: () => _showWindowPicker(context),
|
onWindowTap: () => _showWindowPicker(context),
|
||||||
),
|
),
|
||||||
@@ -306,8 +305,48 @@ class _SummaryPanel extends StatelessWidget {
|
|||||||
LiveTrafficBusyness.active => 'Active',
|
LiveTrafficBusyness.active => 'Active',
|
||||||
LiveTrafficBusyness.busy => 'Busy',
|
LiveTrafficBusyness.busy => 'Busy',
|
||||||
};
|
};
|
||||||
|
final metrics = [
|
||||||
|
_MetricTile(
|
||||||
|
tileKey: const ValueKey('liveTrafficMetric:rxPackets'),
|
||||||
|
label: AppLocalizations.of(context)!.rxPackets,
|
||||||
|
value: '${snapshot.rxCount}',
|
||||||
|
subtitle: totalRxCount == null
|
||||||
|
? _windowSummaryLabel(snapshot.windowDuration)
|
||||||
|
: 'Device total $totalRxCount',
|
||||||
|
),
|
||||||
|
_MetricTile(
|
||||||
|
tileKey: const ValueKey('liveTrafficMetric:rssi'),
|
||||||
|
label: AppLocalizations.of(context)!.rssi,
|
||||||
|
value: snapshot.latestRssiDbm == null
|
||||||
|
? 'No RX data'
|
||||||
|
: '${snapshot.latestRssiDbm} dBm',
|
||||||
|
subtitle: snapshot.avgRssiDbm == null
|
||||||
|
? 'No average yet'
|
||||||
|
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
|
||||||
|
),
|
||||||
|
_MetricTile(
|
||||||
|
tileKey: const ValueKey('liveTrafficMetric:snr'),
|
||||||
|
label: AppLocalizations.of(context)!.snr,
|
||||||
|
value: snapshot.latestSnrDb == null
|
||||||
|
? 'No RX data'
|
||||||
|
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
|
||||||
|
subtitle: snapshot.avgSnrDb == null
|
||||||
|
? 'No average yet'
|
||||||
|
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
|
||||||
|
),
|
||||||
|
_MetricTile(
|
||||||
|
tileKey: const ValueKey('liveTrafficMetric:multihop'),
|
||||||
|
label: AppLocalizations.of(context)!.multihop,
|
||||||
|
value: '${snapshot.multiHopCount}',
|
||||||
|
subtitle: routeHashCounts.summaryLabel,
|
||||||
|
footer: snapshot.avgHopCount == null
|
||||||
|
? 'No routes yet'
|
||||||
|
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -346,44 +385,26 @@ class _SummaryPanel extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Wrap(
|
LayoutBuilder(
|
||||||
spacing: 10,
|
builder: (context, constraints) {
|
||||||
runSpacing: 10,
|
const spacing = 10.0;
|
||||||
children: [
|
const minTileWidth = 150.0;
|
||||||
_MetricTile(
|
final columns =
|
||||||
label: AppLocalizations.of(context)!.rxPackets,
|
((constraints.maxWidth + spacing) / (minTileWidth + spacing))
|
||||||
value: '${snapshot.rxCount}',
|
.floor()
|
||||||
subtitle: totalRxCount == null
|
.clamp(1, metrics.length);
|
||||||
? _windowSummaryLabel(snapshot.windowDuration)
|
final tileWidth =
|
||||||
: 'Device total $totalRxCount',
|
(constraints.maxWidth - (spacing * (columns - 1))) / columns;
|
||||||
),
|
|
||||||
_MetricTile(
|
return Wrap(
|
||||||
label: AppLocalizations.of(context)!.rssi,
|
spacing: spacing,
|
||||||
value: snapshot.latestRssiDbm == null
|
runSpacing: spacing,
|
||||||
? 'No RX data'
|
children: [
|
||||||
: '${snapshot.latestRssiDbm} dBm',
|
for (final metric in metrics)
|
||||||
subtitle: snapshot.avgRssiDbm == null
|
SizedBox(width: tileWidth, child: metric),
|
||||||
? 'No average yet'
|
],
|
||||||
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
|
);
|
||||||
),
|
},
|
||||||
_MetricTile(
|
|
||||||
label: AppLocalizations.of(context)!.snr,
|
|
||||||
value: snapshot.latestSnrDb == null
|
|
||||||
? 'No RX data'
|
|
||||||
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
|
|
||||||
subtitle: snapshot.avgSnrDb == null
|
|
||||||
? 'No average yet'
|
|
||||||
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
|
|
||||||
),
|
|
||||||
_MetricTile(
|
|
||||||
label: AppLocalizations.of(context)!.multihop,
|
|
||||||
value: '${snapshot.multiHopCount}',
|
|
||||||
subtitle: routeHashCounts.summaryLabel,
|
|
||||||
footer: snapshot.avgHopCount == null
|
|
||||||
? 'No routes yet'
|
|
||||||
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -525,12 +546,14 @@ String _windowSummaryLabel(Duration duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MetricTile extends StatelessWidget {
|
class _MetricTile extends StatelessWidget {
|
||||||
|
final Key? tileKey;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final String? footer;
|
final String? footer;
|
||||||
|
|
||||||
const _MetricTile({
|
const _MetricTile({
|
||||||
|
this.tileKey,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
@@ -540,7 +563,7 @@ class _MetricTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 160,
|
key: tileKey,
|
||||||
height: 136,
|
height: 136,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -949,7 +972,6 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LiveTrafficPacketDetails {
|
class _LiveTrafficPacketDetails {
|
||||||
@@ -1105,7 +1127,6 @@ class _PacketInfoLine extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _GeoPoint {
|
class _GeoPoint {
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
final double longitude;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart' show MeshCoreConstants;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
@@ -28,12 +29,14 @@ import '../services/voice_recorder_service.dart';
|
|||||||
import '../services/voice_codec_service.dart';
|
import '../services/voice_codec_service.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
|
import '../utils/contact_sorting.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
import '../utils/tictactoe_message_parser.dart';
|
import '../utils/tictactoe_message_parser.dart';
|
||||||
import '../providers/image_provider.dart' as ip;
|
import '../providers/image_provider.dart' as ip;
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
|
import '../services/region_scope_preferences.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -47,6 +50,86 @@ class MessagesTab extends StatefulWidget {
|
|||||||
State<MessagesTab> createState() => _MessagesTabState();
|
State<MessagesTab> createState() => _MessagesTabState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ComposerActionTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final Color color;
|
||||||
|
final bool enabled;
|
||||||
|
final bool busy;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const _ComposerActionTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.color,
|
||||||
|
this.enabled = true,
|
||||||
|
this.busy = false,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final effectiveColor = enabled
|
||||||
|
? color
|
||||||
|
: colorScheme.onSurfaceVariant.withValues(alpha: 0.45);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: enabled
|
||||||
|
? colorScheme.surfaceContainerLow
|
||||||
|
: colorScheme.surfaceContainerLowest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
enabled: enabled,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
leading: Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: enabled
|
||||||
|
? effectiveColor.withValues(alpha: 0.16)
|
||||||
|
: colorScheme.outlineVariant.withValues(alpha: 0.16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(icon, color: effectiveColor, size: 20),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: enabled
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: busy
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: effectiveColor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MessagesTabState extends State<MessagesTab> {
|
class _MessagesTabState extends State<MessagesTab> {
|
||||||
static const int _maxContactMessageBytes = 156;
|
static const int _maxContactMessageBytes = 156;
|
||||||
static const int _maxChannelMessageBytes = 127;
|
static const int _maxChannelMessageBytes = 127;
|
||||||
@@ -71,6 +154,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
MessageDestinationPreferences.destinationTypeChannel;
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
Contact? _selectedRecipient;
|
Contact? _selectedRecipient;
|
||||||
|
|
||||||
|
// Region scope state
|
||||||
|
String? _channelRegionScopeName;
|
||||||
|
Uint8List? _channelRegionScopeKey;
|
||||||
|
Map<int, String> _channelRegionScopes = {};
|
||||||
|
|
||||||
// Image sending state
|
// Image sending state
|
||||||
bool _isSendingImage = false;
|
bool _isSendingImage = false;
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
@@ -98,6 +186,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceSettings();
|
_loadVoiceSettings();
|
||||||
|
_loadAllChannelRegionScopes();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkForNavigationRequest();
|
_checkForNavigationRequest();
|
||||||
});
|
});
|
||||||
@@ -299,27 +388,27 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
final contacts =
|
final contacts =
|
||||||
contactsProvider.contacts
|
contactsProvider.chatContacts.where((contact) {
|
||||||
.where((contact) => contact.type == ContactType.chat)
|
if (normalizedQuery.isEmpty) return true;
|
||||||
.where((contact) {
|
return contact.displayName.toLowerCase().contains(normalizedQuery);
|
||||||
if (normalizedQuery.isEmpty) return true;
|
}).toList()..sort((a, b) {
|
||||||
return contact.displayName.toLowerCase().contains(
|
final primary = compareContactsByFavouriteThenLastSeen(a, b);
|
||||||
normalizedQuery,
|
if (primary != 0) {
|
||||||
);
|
return primary;
|
||||||
})
|
}
|
||||||
.toList()
|
|
||||||
..sort((a, b) {
|
final aName = a.displayName.toLowerCase();
|
||||||
final aName = a.displayName.toLowerCase();
|
final bName = b.displayName.toLowerCase();
|
||||||
final bName = b.displayName.toLowerCase();
|
final aStarts =
|
||||||
final aStarts =
|
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
|
||||||
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
|
final bStarts =
|
||||||
final bStarts =
|
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
|
||||||
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
|
if (aStarts != bStarts) {
|
||||||
if (aStarts != bStarts) {
|
return aStarts ? -1 : 1;
|
||||||
return aStarts ? -1 : 1;
|
}
|
||||||
}
|
|
||||||
return aName.compareTo(bName);
|
return compareContactsByDisplayName(a, b);
|
||||||
});
|
});
|
||||||
|
|
||||||
return contacts.take(8).toList(growable: false);
|
return contacts.take(8).toList(growable: false);
|
||||||
}
|
}
|
||||||
@@ -401,6 +490,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
|
// Load region scope for channel destinations
|
||||||
|
await _loadRegionScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show recipient selector bottom sheet
|
/// Show recipient selector bottom sheet
|
||||||
@@ -408,16 +500,33 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
|
||||||
// Filter contacts by type
|
final contacts = List<Contact>.from(contactsProvider.chatContacts)
|
||||||
final contacts = contactsProvider.contacts
|
..sort((a, b) {
|
||||||
.where((c) => c.type == ContactType.chat)
|
final primary = compareContactsByFavouriteThenLastSeen(a, b);
|
||||||
.toList();
|
if (primary != 0) {
|
||||||
final rooms = contactsProvider.contacts
|
return primary;
|
||||||
.where((c) => c.type == ContactType.room)
|
}
|
||||||
.toList();
|
|
||||||
final channels = contactsProvider.contacts
|
return compareContactsByDisplayName(a, b);
|
||||||
.where((c) => c.type == ContactType.channel)
|
});
|
||||||
.toList();
|
final rooms = List<Contact>.from(contactsProvider.rooms)
|
||||||
|
..sort((a, b) {
|
||||||
|
final primary = compareContactsByLastSeen(a, b);
|
||||||
|
if (primary != 0) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareContactsByDisplayName(a, b);
|
||||||
|
});
|
||||||
|
final channels = List<Contact>.from(contactsProvider.channels)
|
||||||
|
..sort((a, b) {
|
||||||
|
final primary = compareContactsByLastSeen(a, b);
|
||||||
|
if (primary != 0) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareContactsByDisplayName(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -437,6 +546,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
currentDestinationType: _destinationType,
|
currentDestinationType: _destinationType,
|
||||||
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
||||||
onSelect: _onRecipientSelected,
|
onSelect: _onRecipientSelected,
|
||||||
|
channelRegionScopes: _channelRegionScopes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -452,6 +562,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
|
// Load region scope for channel destinations
|
||||||
|
await _loadRegionScope();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
await MessageDestinationPreferences.setDestination(
|
await MessageDestinationPreferences.setDestination(
|
||||||
type,
|
type,
|
||||||
@@ -462,6 +575,51 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRegionScope() async {
|
||||||
|
if (_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
if (_channelRegionScopeName != null) {
|
||||||
|
setState(() {
|
||||||
|
_channelRegionScopeName = null;
|
||||||
|
_channelRegionScopeKey = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
final scope = await RegionScopePreferences.getScope(channelIdx);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_channelRegionScopeName = scope?.name;
|
||||||
|
_channelRegionScopeKey = scope?.key;
|
||||||
|
if (scope != null) {
|
||||||
|
_channelRegionScopes[channelIdx] = scope.name;
|
||||||
|
} else {
|
||||||
|
_channelRegionScopes.remove(channelIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAllChannelRegionScopes() async {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final channels = contactsProvider.chatContacts
|
||||||
|
.where((c) => c.publicKey.length > 1 && c.publicKey[0] == 0)
|
||||||
|
.toList();
|
||||||
|
final scopes = <int, String>{};
|
||||||
|
// Always check channel 0 (public)
|
||||||
|
final scope0 = await RegionScopePreferences.getScope(0);
|
||||||
|
if (scope0 != null) scopes[0] = scope0.name;
|
||||||
|
for (final ch in channels) {
|
||||||
|
final idx = ch.publicKey[1];
|
||||||
|
final scope = await RegionScopePreferences.getScope(idx);
|
||||||
|
if (scope != null) scopes[idx] = scope.name;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_channelRegionScopes = scopes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _applyPendingDestination({
|
Future<void> _applyPendingDestination({
|
||||||
required String type,
|
required String type,
|
||||||
String? recipientPublicKeyHex,
|
String? recipientPublicKeyHex,
|
||||||
@@ -534,7 +692,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
|
|
||||||
if (recipient == null) {
|
if (recipient == null) {
|
||||||
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotReplyContactNotFound,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,13 +724,19 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
} else {
|
} else {
|
||||||
final senderPrefix = message.senderPublicKeyPrefix;
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
if (senderPrefix == null || senderPrefix.length < 6) {
|
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||||
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplySenderMissing);
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotReplySenderMissing,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
if (recipient == null) {
|
if (recipient == null) {
|
||||||
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotReplyContactNotFound,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +807,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_focusNode.unfocus();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check destination type and send accordingly
|
// Check destination type and send accordingly
|
||||||
@@ -720,11 +886,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||||
|
|
||||||
// Send to selected channel
|
// Send to selected channel (with region scope if set)
|
||||||
await connectionProvider.sendChannelMessage(
|
await connectionProvider.sendChannelMessage(
|
||||||
channelIdx: channelIdx,
|
channelIdx: channelIdx,
|
||||||
text: text,
|
text: text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
floodScopeKey: _channelRegionScopeKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,6 +1001,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (!shouldContinue) return;
|
if (!shouldContinue) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
ToastLogger.error(context, 'Not connected to device');
|
ToastLogger.error(context, 'Not connected to device');
|
||||||
return;
|
return;
|
||||||
@@ -845,6 +1013,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final rawBytes = await picked.readAsBytes();
|
final rawBytes = await picked.readAsBytes();
|
||||||
|
|
||||||
setState(() => _isSendingImage = true);
|
setState(() => _isSendingImage = true);
|
||||||
|
String? failedMessageId;
|
||||||
try {
|
try {
|
||||||
// Compress AVIF using user-selected size, compression and color mode.
|
// Compress AVIF using user-selected size, compression and color mode.
|
||||||
final maxSize = await ImagePreferences.getMaxSize();
|
final maxSize = await ImagePreferences.getMaxSize();
|
||||||
@@ -922,8 +1091,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
|
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
|
||||||
|
|
||||||
// Add local placeholder message.
|
// Add local placeholder message.
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
|
||||||
final msgId = 'img_${sessionId}_sent';
|
final msgId = 'img_${sessionId}_sent';
|
||||||
|
failedMessageId = msgId;
|
||||||
final isChannel =
|
final isChannel =
|
||||||
_destinationType ==
|
_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel;
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
@@ -954,6 +1123,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
channelIdx: channelIdx ?? 0,
|
channelIdx: channelIdx ?? 0,
|
||||||
text: envelopeText,
|
text: envelopeText,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
|
floodScopeKey: _channelRegionScopeKey,
|
||||||
);
|
);
|
||||||
} else if (recipient != null) {
|
} else if (recipient != null) {
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
final sent = await connectionProvider.sendTextMessage(
|
||||||
@@ -981,10 +1151,24 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'chunk=${imageDataBytesPerFragment}B',
|
'chunk=${imageDataBytesPerFragment}B',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isChannel) {
|
||||||
|
for (final fragment in fragments) {
|
||||||
|
await connectionProvider.sendChannelData(
|
||||||
|
channelIdx: channelIdx ?? 0,
|
||||||
|
dataType: MeshCoreConstants.dataTypeDev,
|
||||||
|
payload: fragment.encodeBinary(),
|
||||||
|
floodScopeKey: _channelRegionScopeKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Image fragments are always served on demand after an explicit IR2
|
// Image fragments are always served on demand after an explicit IR2
|
||||||
// fetch request, including direct contacts.
|
// fetch request, including direct contacts.
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
||||||
|
if (failedMessageId != null) {
|
||||||
|
messagesProvider.markMessageFailed(failedMessageId);
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ToastLogger.error(context, 'Image send failed');
|
ToastLogger.error(context, 'Image send failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1268,6 +1452,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
channelIdx: channelIdx ?? 0,
|
channelIdx: channelIdx ?? 0,
|
||||||
text: envelopeText,
|
text: envelopeText,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
|
floodScopeKey: _channelRegionScopeKey,
|
||||||
);
|
);
|
||||||
} else if (recipient != null) {
|
} else if (recipient != null) {
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
@@ -1293,6 +1478,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('🎙️ [Voice] envelope sent for session $sessionId');
|
debugPrint('🎙️ [Voice] envelope sent for session $sessionId');
|
||||||
|
if (isChannel) {
|
||||||
|
for (final packet in encodedPackets) {
|
||||||
|
await connectionProvider.sendChannelData(
|
||||||
|
channelIdx: channelIdx ?? 0,
|
||||||
|
dataType: MeshCoreConstants.dataTypeDev,
|
||||||
|
payload: packet.encodeBinary(),
|
||||||
|
floodScopeKey: _channelRegionScopeKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
|
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
|
||||||
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
|
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
|
||||||
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
|
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
|
||||||
@@ -1442,82 +1637,166 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
void _showComposerActions() {
|
void _showComposerActions() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return SafeArea(
|
final theme = Theme.of(sheetContext);
|
||||||
child: Column(
|
final l10n = AppLocalizations.of(context)!;
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
Future<void> runAction(Future<void> Function() action) async {
|
||||||
ListTile(
|
await _runAfterSheetDismissal(sheetContext, action);
|
||||||
leading: Icon(Icons.search),
|
}
|
||||||
title: Text(AppLocalizations.of(context)!.searchMessages),
|
|
||||||
onTap: () async {
|
final actions = <Widget>[
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
_ComposerActionTile(
|
||||||
_showFilteredMessageSearch();
|
icon: Icons.search_rounded,
|
||||||
});
|
title: l10n.searchMessages,
|
||||||
},
|
color: const Color(0xFF2B6CB0),
|
||||||
),
|
onTap: () => runAction(() async {
|
||||||
ListTile(
|
_showFilteredMessageSearch();
|
||||||
leading: Icon(Icons.add_location_alt),
|
}),
|
||||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
|
||||||
onTap: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
_showSarDialog();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (_voiceSupported)
|
|
||||||
ListTile(
|
|
||||||
enabled: !_isSendingVoice,
|
|
||||||
leading: Icon(_isRecording ? Icons.stop : Icons.mic),
|
|
||||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
|
||||||
onTap: _isSendingVoice
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
if (_isRecording) {
|
|
||||||
await _stopAndSendVoice();
|
|
||||||
} else {
|
|
||||||
await _startVoiceRecording();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
enabled: !_isSendingImage,
|
|
||||||
leading: Icon(Icons.photo_library),
|
|
||||||
title: Text(AppLocalizations.of(context)!.sendImageFromGallery),
|
|
||||||
onTap: _isSendingImage
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
await _pickAndSendImage(source: ImageSource.gallery);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
enabled: !_isSendingImage,
|
|
||||||
leading: Icon(Icons.camera_alt),
|
|
||||||
title: Text(AppLocalizations.of(context)!.takePhoto),
|
|
||||||
onTap: _isSendingImage
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
await _pickAndSendImage(source: ImageSource.camera);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.grid_3x3),
|
|
||||||
title: Text(AppLocalizations.of(context)!.startTictactoe),
|
|
||||||
subtitle: Text(AppLocalizations.of(context)!.dmOnly),
|
|
||||||
onTap: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
await _startTicTacToeGame();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.add_location_alt_rounded,
|
||||||
|
title: l10n.sendSarMarker,
|
||||||
|
color: const Color(0xFFB45309),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
_showSarDialog();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (_voiceSupported)
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
|
||||||
|
title: _isRecording ? 'Stop recording' : 'Record voice',
|
||||||
|
color: const Color(0xFF7C3AED),
|
||||||
|
enabled: !_isSendingVoice,
|
||||||
|
busy: _isSendingVoice,
|
||||||
|
onTap: !_isSendingVoice
|
||||||
|
? () => runAction(() async {
|
||||||
|
if (_isRecording) {
|
||||||
|
await _stopAndSendVoice();
|
||||||
|
} else {
|
||||||
|
await _startVoiceRecording();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.photo_library_rounded,
|
||||||
|
title: l10n.sendImageFromGallery,
|
||||||
|
color: const Color(0xFF0F766E),
|
||||||
|
enabled: !_isSendingImage,
|
||||||
|
busy: _isSendingImage,
|
||||||
|
onTap: !_isSendingImage
|
||||||
|
? () => runAction(() async {
|
||||||
|
await _pickAndSendImage(source: ImageSource.gallery);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.camera_alt_rounded,
|
||||||
|
title: l10n.takePhoto,
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
enabled: !_isSendingImage,
|
||||||
|
busy: _isSendingImage,
|
||||||
|
onTap: !_isSendingImage
|
||||||
|
? () => runAction(() async {
|
||||||
|
await _pickAndSendImage(source: ImageSource.camera);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.grid_3x3_rounded,
|
||||||
|
title: l10n.startTictactoe,
|
||||||
|
color: const Color(0xFFBE185D),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
await _startTicTacToeGame();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (_destinationType ==
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel)
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: _channelRegionScopeName != null
|
||||||
|
? Icons.language_rounded
|
||||||
|
: Icons.public_rounded,
|
||||||
|
title: _channelRegionScopeName != null
|
||||||
|
? '${l10n.regionScope}: $_channelRegionScopeName'
|
||||||
|
: l10n.setRegionScope,
|
||||||
|
color: const Color(0xFF7C3AED),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
_showRegionScopeSheet();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.72,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'More actions',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
for (var index = 0; index < actions.length; index++) ...[
|
||||||
|
actions[index],
|
||||||
|
if (index != actions.length - 1) const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRegionScopeSheet() {
|
||||||
|
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return _RegionScopeSheet(
|
||||||
|
currentScopeName: _channelRegionScopeName,
|
||||||
|
l10n: l10n,
|
||||||
|
onScopeSelected: (String? name) async {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
if (name == null) {
|
||||||
|
// Clear scope
|
||||||
|
await RegionScopePreferences.clearScope(channelIdx);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_channelRegionScopeName = null;
|
||||||
|
_channelRegionScopeKey = null;
|
||||||
|
_channelRegionScopes.remove(channelIdx);
|
||||||
|
});
|
||||||
|
ToastLogger.success(context, l10n.regionScopeCleared);
|
||||||
|
} else {
|
||||||
|
// Set scope
|
||||||
|
final key = RegionScopePreferences.deriveRegionKey(name);
|
||||||
|
await RegionScopePreferences.setScope(channelIdx, name, key);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_channelRegionScopeName = name;
|
||||||
|
_channelRegionScopeKey = key;
|
||||||
|
_channelRegionScopes[channelIdx] = name;
|
||||||
|
});
|
||||||
|
ToastLogger.success(context, l10n.regionScopeSet(name));
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1809,6 +2088,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
channelIdx: 0,
|
channelIdx: 0,
|
||||||
text: sarMessage,
|
text: sarMessage,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
floodScopeKey: _channelRegionScopeKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -2107,6 +2387,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
onStartVoiceRecording: _startVoiceRecording,
|
onStartVoiceRecording: _startVoiceRecording,
|
||||||
onStopAndSendVoice: _stopAndSendVoice,
|
onStopAndSendVoice: _stopAndSendVoice,
|
||||||
onSendMessage: _sendMessage,
|
onSendMessage: _sendMessage,
|
||||||
|
regionScopeName: _channelRegionScopeName,
|
||||||
|
onRegionScopeTap: _channelRegionScopeName != null
|
||||||
|
? _showRegionScopeSheet
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -2116,3 +2400,165 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bottom sheet for selecting a region scope for the current channel.
|
||||||
|
class _RegionScopeSheet extends StatefulWidget {
|
||||||
|
final String? currentScopeName;
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final ValueChanged<String?> onScopeSelected;
|
||||||
|
|
||||||
|
const _RegionScopeSheet({
|
||||||
|
required this.currentScopeName,
|
||||||
|
required this.l10n,
|
||||||
|
required this.onScopeSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_RegionScopeSheet> createState() => _RegionScopeSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegionScopeSheetState extends State<_RegionScopeSheet> {
|
||||||
|
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';
|
||||||
|
if (utf8.encode(name).length > 30) return;
|
||||||
|
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),
|
||||||
|
|
||||||
|
_RegionOptionTile(
|
||||||
|
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 _RegionOptionTile extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _RegionOptionTile({
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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/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';
|
||||||
|
|
||||||
@@ -100,70 +101,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 +131,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:
|
||||||
@@ -275,30 +232,50 @@ 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 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 +288,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 +532,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 +582,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 +1077,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 +1105,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'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -79,6 +80,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;
|
||||||
@@ -190,9 +192,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_isDeveloperModeEnabled = false;
|
_isDeveloperModeEnabled = false;
|
||||||
_versionTapCount = 0;
|
_versionTapCount = 0;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(
|
||||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeDisabled)));
|
content: Text(AppLocalizations.of(context)!.developerModeDisabled),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +208,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_isDeveloperModeEnabled = true;
|
_isDeveloperModeEnabled = true;
|
||||||
_versionTapCount = 0;
|
_versionTapCount = 0;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(
|
||||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeEnabled)));
|
content: Text(AppLocalizations.of(context)!.developerModeEnabled),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +316,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_locationService.fastLocationMovementThresholdMeters;
|
_locationService.fastLocationMovementThresholdMeters;
|
||||||
_fastLocationActiveCadenceSeconds =
|
_fastLocationActiveCadenceSeconds =
|
||||||
_locationService.fastLocationActiveCadenceSeconds;
|
_locationService.fastLocationActiveCadenceSeconds;
|
||||||
|
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +410,80 @@ 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> _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;
|
||||||
@@ -559,7 +640,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid),
|
content: Text(
|
||||||
|
AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid,
|
||||||
|
),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -584,7 +667,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (!updateInfo.isAvailable) {
|
if (!updateInfo.isAvailable) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.youAreRunningTheLatestVersion),
|
content: Text(
|
||||||
|
AppLocalizations.of(context)!.youAreRunningTheLatestVersion,
|
||||||
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -594,7 +679,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (updateInfo.downloadUrl == null) {
|
if (updateInfo.downloadUrl == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.updateAvailableButDownloadUrlNotFound),
|
content: Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.updateAvailableButDownloadUrlNotFound,
|
||||||
|
),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1178,6 +1267,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader(AppLocalizations.of(context)!.contacts),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => _buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.star_outline_rounded),
|
||||||
|
title: Text(AppLocalizations.of(context)!.favourites),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show the favourites section in the contacts tab',
|
||||||
|
),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.favourites,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.favourites,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.people_alt_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.teamMembers),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show direct team contacts in the contacts tab',
|
||||||
|
),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.teamMembers,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.teamMembers,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.router_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.repeaters),
|
||||||
|
subtitle: const Text('Show repeater nodes in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.repeaters,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.repeaters,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.sensors_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.sensors),
|
||||||
|
subtitle: const Text('Show sensor nodes in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.sensors,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.sensors,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.meeting_room_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.rooms),
|
||||||
|
subtitle: const Text('Show rooms in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.rooms,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.rooms,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.broadcast_on_personal_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.channels),
|
||||||
|
subtitle: const Text('Show channels in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.channels,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.channels,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
_buildSectionHeader('Messaging'),
|
_buildSectionHeader('Messaging'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1205,7 +1388,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.route),
|
secondary: Icon(Icons.route),
|
||||||
title: Text(AppLocalizations.of(context)!.nearestRepeaterFallback),
|
title: Text(
|
||||||
|
AppLocalizations.of(context)!.nearestRepeaterFallback,
|
||||||
|
),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'After normal retries fail, try one final resend through the nearest repeater',
|
'After normal retries fail, try one final resend through the nearest repeater',
|
||||||
),
|
),
|
||||||
@@ -1234,7 +1419,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
'Clear Messages',
|
'Clear Messages',
|
||||||
style: TextStyle(color: Colors.red),
|
style: TextStyle(color: Colors.red),
|
||||||
),
|
),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.deleteAllStoredMessageHistory),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.deleteAllStoredMessageHistory,
|
||||||
|
),
|
||||||
onTap: _clearMessages,
|
onTap: _clearMessages,
|
||||||
),
|
),
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
@@ -1344,26 +1531,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, drawingProvider, child) => SwitchListTile(
|
builder: (context, drawingProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.fmd_good_outlined),
|
secondary: Icon(Icons.fmd_good_outlined),
|
||||||
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
|
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap,
|
||||||
|
),
|
||||||
value: drawingProvider.showSarMarkers,
|
value: drawingProvider.showSarMarkers,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
drawingProvider.toggleSarMarkers();
|
drawingProvider.toggleSarMarkers();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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),
|
||||||
@@ -1420,7 +1596,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.compress),
|
secondary: Icon(Icons.compress),
|
||||||
title: Text(AppLocalizations.of(context)!.voiceCompressor),
|
title: Text(AppLocalizations.of(context)!.voiceCompressor),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.balancesQuietAndLoudSpeechLevels),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.balancesQuietAndLoudSpeechLevels,
|
||||||
|
),
|
||||||
value: appProvider.isVoiceCompressorEnabled,
|
value: appProvider.isVoiceCompressorEnabled,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await appProvider.toggleVoiceCompressorEnabled(value);
|
await appProvider.toggleVoiceCompressorEnabled(value);
|
||||||
@@ -1431,7 +1611,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.speed),
|
secondary: Icon(Icons.speed),
|
||||||
title: Text(AppLocalizations.of(context)!.voiceLimiter),
|
title: Text(AppLocalizations.of(context)!.voiceLimiter),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.preventsClippingPeaksBeforeEncoding),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.preventsClippingPeaksBeforeEncoding,
|
||||||
|
),
|
||||||
value: appProvider.isVoiceLimiterEnabled,
|
value: appProvider.isVoiceLimiterEnabled,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await appProvider.toggleVoiceLimiterEnabled(value);
|
await appProvider.toggleVoiceLimiterEnabled(value);
|
||||||
@@ -1442,7 +1626,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.auto_fix_high),
|
secondary: Icon(Icons.auto_fix_high),
|
||||||
title: Text(AppLocalizations.of(context)!.micAutoGain),
|
title: Text(AppLocalizations.of(context)!.micAutoGain),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel,
|
||||||
|
),
|
||||||
value: appProvider.isVoiceAutoGainEnabled,
|
value: appProvider.isVoiceAutoGainEnabled,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await appProvider.toggleVoiceAutoGainEnabled(value);
|
await appProvider.toggleVoiceAutoGainEnabled(value);
|
||||||
@@ -1478,7 +1664,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.content_cut),
|
secondary: Icon(Icons.content_cut),
|
||||||
title: Text(AppLocalizations.of(context)!.trimSilenceInVoiceMessages),
|
title: Text(
|
||||||
|
AppLocalizations.of(context)!.trimSilenceInVoiceMessages,
|
||||||
|
),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Removes long silent parts before sending voice',
|
'Removes long silent parts before sending voice',
|
||||||
),
|
),
|
||||||
@@ -1648,11 +1836,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.timer),
|
leading: Icon(Icons.timer),
|
||||||
title: Text(AppLocalizations.of(context)!.activeuseUpdateInterval),
|
title: Text(
|
||||||
|
AppLocalizations.of(context)!.activeuseUpdateInterval,
|
||||||
|
),
|
||||||
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
|
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
|
||||||
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(
|
ListTile(
|
||||||
leading: Icon(Icons.location_on),
|
leading: Icon(Icons.location_on),
|
||||||
title: Text(AppLocalizations.of(context)!.locationPermission),
|
title: Text(AppLocalizations.of(context)!.locationPermission),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
178
lib/services/bthome_met_history.dart
Normal file
178
lib/services/bthome_met_history.dart
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
11
lib/services/compass_support.dart
Normal file
11
lib/services/compass_support.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -141,6 +141,18 @@ class LiveTrafficSummary {
|
|||||||
|
|
||||||
const LiveTrafficSummary._();
|
const LiveTrafficSummary._();
|
||||||
|
|
||||||
|
static bool isRxDataLog(BlePacketLog log) {
|
||||||
|
return log.direction == PacketDirection.rx &&
|
||||||
|
log.responseCode == logRxDataResponseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int countRxDataLogs(Iterable<BlePacketLog> logs, {DateTime? since}) {
|
||||||
|
return logs.where((log) {
|
||||||
|
return isRxDataLog(log) &&
|
||||||
|
(since == null || !log.timestamp.isBefore(since));
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
static LiveTrafficSnapshot fromLogs(
|
static LiveTrafficSnapshot fromLogs(
|
||||||
Iterable<BlePacketLog> logs, {
|
Iterable<BlePacketLog> logs, {
|
||||||
required DateTime now,
|
required DateTime now,
|
||||||
@@ -154,15 +166,14 @@ class LiveTrafficSummary {
|
|||||||
? clearedAt
|
? clearedAt
|
||||||
: windowStart;
|
: windowStart;
|
||||||
|
|
||||||
final recentLogs = logs
|
final recentLogs =
|
||||||
.where(
|
logs
|
||||||
(log) =>
|
.where(
|
||||||
log.direction == PacketDirection.rx &&
|
(log) =>
|
||||||
log.responseCode == logRxDataResponseCode &&
|
isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart),
|
||||||
!log.timestamp.isBefore(effectiveStart),
|
)
|
||||||
)
|
.toList()
|
||||||
.toList()
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
||||||
|
|
||||||
final entries = <LiveTrafficEntry>[];
|
final entries = <LiveTrafficEntry>[];
|
||||||
for (final log in recentLogs) {
|
for (final log in recentLogs) {
|
||||||
@@ -215,7 +226,9 @@ class LiveTrafficSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
final visibleEntries = filteredEntries.reversed
|
||||||
|
.take(maxVisibleEntries)
|
||||||
|
.toList();
|
||||||
const txCount = 0;
|
const txCount = 0;
|
||||||
final totalCount = rxCount;
|
final totalCount = rxCount;
|
||||||
final packetsPerMinute =
|
final packetsPerMinute =
|
||||||
@@ -233,7 +246,9 @@ class LiveTrafficSummary {
|
|||||||
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
||||||
latestRssiDbm: latestRssiDbm,
|
latestRssiDbm: latestRssiDbm,
|
||||||
multiHopCount: multiHopCount,
|
multiHopCount: multiHopCount,
|
||||||
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
|
avgHopCount: hopCountSamples == 0
|
||||||
|
? null
|
||||||
|
: hopCountTotal / hopCountSamples,
|
||||||
visibleEntries: visibleEntries,
|
visibleEntries: visibleEntries,
|
||||||
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,6 +49,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);
|
||||||
@@ -79,6 +80,9 @@ class LocationTrackingService {
|
|||||||
/// Cadence for active-use fast GPS updates
|
/// Cadence for active-use fast GPS updates
|
||||||
int fastLocationActiveCadenceSeconds = 10;
|
int fastLocationActiveCadenceSeconds = 10;
|
||||||
|
|
||||||
|
/// Target channel index for fast GPS updates; null means disabled/unset.
|
||||||
|
int? fastLocationChannelIdx;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Properties
|
// State Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -528,8 +532,14 @@ class LocationTrackingService {
|
|||||||
_refreshFastLocationTimer();
|
_refreshFastLocationTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
|
||||||
|
fastLocationChannelIdx = channelIdx;
|
||||||
|
await saveSettings();
|
||||||
|
_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 +562,7 @@ class LocationTrackingService {
|
|||||||
_fastLocationTimer = null;
|
_fastLocationTimer = null;
|
||||||
if (!isTracking ||
|
if (!isTracking ||
|
||||||
!fastLocationUpdatesEnabled ||
|
!fastLocationUpdatesEnabled ||
|
||||||
|
fastLocationChannelIdx == null ||
|
||||||
!_isFastLocationActiveUse) {
|
!_isFastLocationActiveUse) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -567,7 +578,7 @@ 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;
|
||||||
@@ -669,6 +680,7 @@ class LocationTrackingService {
|
|||||||
5,
|
5,
|
||||||
60,
|
60,
|
||||||
);
|
);
|
||||||
|
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 +692,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 +722,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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +237,54 @@ 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);
|
||||||
@@ -279,4 +349,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
86
lib/services/region_scope_preferences.dart
Normal file
86
lib/services/region_scope_preferences.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
lib/services/serial/serial_transport.dart
Normal file
50
lib/services/serial/serial_transport.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
|
||||||
|
import 'serial_transport_stub.dart'
|
||||||
|
if (dart.library.io) 'serial_transport_io.dart'
|
||||||
|
if (dart.library.js_interop) 'serial_transport_web.dart';
|
||||||
|
|
||||||
|
const int kSerialBaudRate = 115200;
|
||||||
|
|
||||||
|
class SerialDeviceInfo {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Object handle;
|
||||||
|
|
||||||
|
const SerialDeviceInfo({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.handle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SerialConnection {
|
||||||
|
final MeshCoreSerialService service;
|
||||||
|
final String deviceId;
|
||||||
|
final String deviceName;
|
||||||
|
final Future<void> Function() disconnect;
|
||||||
|
|
||||||
|
const SerialConnection({
|
||||||
|
required this.service,
|
||||||
|
required this.deviceId,
|
||||||
|
required this.deviceName,
|
||||||
|
required this.disconnect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class SerialTransport {
|
||||||
|
bool get isSupported;
|
||||||
|
bool get canRequestDevice;
|
||||||
|
String get actionLabel;
|
||||||
|
String get emptyStateTitle;
|
||||||
|
String get unsupportedMessage;
|
||||||
|
|
||||||
|
Future<List<SerialDeviceInfo>> listDevices();
|
||||||
|
Future<SerialDeviceInfo?> requestDevice();
|
||||||
|
Future<SerialConnection> connect(SerialDeviceInfo device);
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
SerialTransport createSerialTransport() => createSerialTransportImpl();
|
||||||
283
lib/services/serial/serial_transport_io.dart
Normal file
283
lib/services/serial/serial_transport_io.dart
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_libserialport/flutter_libserialport.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'package:usb_serial/usb_serial.dart';
|
||||||
|
|
||||||
|
import 'serial_transport.dart';
|
||||||
|
|
||||||
|
class _IoSerialTransport implements SerialTransport {
|
||||||
|
@override
|
||||||
|
bool get isSupported =>
|
||||||
|
Platform.isAndroid || Platform.isMacOS || Platform.isWindows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get canRequestDevice => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionLabel =>
|
||||||
|
Platform.isAndroid ? 'Scan USB devices' : 'Scan serial ports';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyStateTitle => Platform.isAndroid
|
||||||
|
? 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.'
|
||||||
|
: 'No serial ports found.\nConnect a MeshCore device over USB serial.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unsupportedMessage =>
|
||||||
|
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SerialDeviceInfo>> listDevices() async {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final devices = await UsbSerial.listDevices();
|
||||||
|
return devices.map(_androidDeviceInfo).toList(growable: false);
|
||||||
|
}
|
||||||
|
if (Platform.isMacOS || Platform.isWindows) {
|
||||||
|
return _desktopSerialDevices();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SerialDeviceInfo?> requestDevice() async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SerialConnection> connect(SerialDeviceInfo device) async {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return _connectAndroid(device);
|
||||||
|
}
|
||||||
|
if (Platform.isMacOS || Platform.isWindows) {
|
||||||
|
return _connectDesktop(device);
|
||||||
|
}
|
||||||
|
throw UnsupportedError(unsupportedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
SerialDeviceInfo _androidDeviceInfo(UsbDevice device) {
|
||||||
|
final title = _firstNonEmpty([
|
||||||
|
device.productName,
|
||||||
|
device.manufacturerName,
|
||||||
|
'USB Serial Device',
|
||||||
|
]);
|
||||||
|
final subtitleParts = <String>[
|
||||||
|
if ((device.manufacturerName ?? '').trim().isNotEmpty)
|
||||||
|
device.manufacturerName!.trim(),
|
||||||
|
if (device.vid != null && device.pid != null)
|
||||||
|
'VID:${_hex(device.vid!)} PID:${_hex(device.pid!)}',
|
||||||
|
];
|
||||||
|
|
||||||
|
return SerialDeviceInfo(
|
||||||
|
id: '${device.deviceId}:${device.vid ?? 'na'}:${device.pid ?? 'na'}:${device.productName ?? 'usb'}',
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitleParts.isEmpty
|
||||||
|
? 'Ready over OTG serial'
|
||||||
|
: subtitleParts.join(' • '),
|
||||||
|
handle: device,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SerialDeviceInfo>> _desktopSerialDevices() async {
|
||||||
|
final ports = <SerialDeviceInfo>[];
|
||||||
|
for (final address in SerialPort.availablePorts) {
|
||||||
|
final port = SerialPort(address);
|
||||||
|
try {
|
||||||
|
final title = _firstNonEmpty([
|
||||||
|
port.productName,
|
||||||
|
port.description,
|
||||||
|
address,
|
||||||
|
]);
|
||||||
|
final subtitleParts = <String>[
|
||||||
|
address,
|
||||||
|
if ((port.manufacturer ?? '').trim().isNotEmpty)
|
||||||
|
port.manufacturer!.trim(),
|
||||||
|
if (port.vendorId != null && port.productId != null)
|
||||||
|
'VID:${_hex(port.vendorId!)} PID:${_hex(port.productId!)}',
|
||||||
|
];
|
||||||
|
ports.add(
|
||||||
|
SerialDeviceInfo(
|
||||||
|
id: address,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitleParts.join(' • '),
|
||||||
|
handle: address,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
port.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SerialConnection> _connectAndroid(SerialDeviceInfo device) async {
|
||||||
|
final usbDevice = device.handle as UsbDevice;
|
||||||
|
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
|
||||||
|
StreamSubscription<Uint8List>? inputSubscription;
|
||||||
|
UsbPort? port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
port = await usbDevice.create();
|
||||||
|
if (port == null) {
|
||||||
|
throw Exception('Failed to create USB port');
|
||||||
|
}
|
||||||
|
final opened = await port.open();
|
||||||
|
if (!opened) {
|
||||||
|
throw Exception('Failed to open USB port');
|
||||||
|
}
|
||||||
|
|
||||||
|
await port.setDTR(true);
|
||||||
|
await port.setRTS(true);
|
||||||
|
await port.setPortParameters(
|
||||||
|
kSerialBaudRate,
|
||||||
|
UsbPort.DATABITS_8,
|
||||||
|
UsbPort.STOPBITS_1,
|
||||||
|
UsbPort.PARITY_NONE,
|
||||||
|
);
|
||||||
|
|
||||||
|
service.writeRaw = (data) async {
|
||||||
|
await port!.write(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
inputSubscription = port.inputStream?.listen(
|
||||||
|
(data) => service.feedRawBytes(data),
|
||||||
|
onError: (error) {
|
||||||
|
debugPrint('❌ [Serial/Android] Read error: $error');
|
||||||
|
service.markDisconnected();
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
debugPrint('⚠️ [Serial/Android] Port closed');
|
||||||
|
service.markDisconnected();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final connected = await service.markConnected();
|
||||||
|
if (!connected) {
|
||||||
|
throw Exception('Serial session initialization failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return SerialConnection(
|
||||||
|
service: service,
|
||||||
|
deviceId:
|
||||||
|
'${usbDevice.deviceId}:${usbDevice.vid ?? 'na'}:${usbDevice.pid ?? 'na'}',
|
||||||
|
deviceName: device.title,
|
||||||
|
disconnect: () async {
|
||||||
|
await inputSubscription?.cancel();
|
||||||
|
inputSubscription = null;
|
||||||
|
service.writeRaw = null;
|
||||||
|
await port?.close();
|
||||||
|
port = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
await inputSubscription?.cancel();
|
||||||
|
service.writeRaw = null;
|
||||||
|
await port?.close();
|
||||||
|
service.dispose();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SerialConnection> _connectDesktop(SerialDeviceInfo device) async {
|
||||||
|
final portName = device.handle as String;
|
||||||
|
final port = SerialPort(portName);
|
||||||
|
final config = SerialPortConfig();
|
||||||
|
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
|
||||||
|
SerialPortReader? reader;
|
||||||
|
StreamSubscription<Uint8List>? inputSubscription;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!port.openReadWrite()) {
|
||||||
|
throw Exception(
|
||||||
|
SerialPort.lastError?.message ?? 'Failed to open serial port',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.baudRate = kSerialBaudRate;
|
||||||
|
config.bits = 8;
|
||||||
|
config.stopBits = 1;
|
||||||
|
config.parity = SerialPortParity.none;
|
||||||
|
config.setFlowControl(SerialPortFlowControl.none);
|
||||||
|
config.dtr = SerialPortDtr.on;
|
||||||
|
config.rts = SerialPortRts.on;
|
||||||
|
port.config = config;
|
||||||
|
|
||||||
|
service.writeRaw = (data) async {
|
||||||
|
var offset = 0;
|
||||||
|
while (offset < data.length) {
|
||||||
|
final written = port.write(data.sublist(offset));
|
||||||
|
if (written <= 0) {
|
||||||
|
throw Exception(
|
||||||
|
SerialPort.lastError?.message ?? 'Failed to write to serial port',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
offset += written;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader = SerialPortReader(port);
|
||||||
|
inputSubscription = reader.stream.listen(
|
||||||
|
(data) => service.feedRawBytes(data),
|
||||||
|
onError: (error) {
|
||||||
|
debugPrint('❌ [Serial/Desktop] Read error: $error');
|
||||||
|
service.markDisconnected();
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
debugPrint('⚠️ [Serial/Desktop] Reader closed');
|
||||||
|
service.markDisconnected();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final connected = await service.markConnected();
|
||||||
|
if (!connected) {
|
||||||
|
throw Exception('Serial session initialization failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return SerialConnection(
|
||||||
|
service: service,
|
||||||
|
deviceId: portName,
|
||||||
|
deviceName: device.title,
|
||||||
|
disconnect: () async {
|
||||||
|
await inputSubscription?.cancel();
|
||||||
|
inputSubscription = null;
|
||||||
|
reader?.close();
|
||||||
|
reader = null;
|
||||||
|
service.writeRaw = null;
|
||||||
|
if (port.isOpen) {
|
||||||
|
port.close();
|
||||||
|
}
|
||||||
|
port.dispose();
|
||||||
|
config.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
await inputSubscription?.cancel();
|
||||||
|
reader?.close();
|
||||||
|
service.writeRaw = null;
|
||||||
|
if (port.isOpen) {
|
||||||
|
port.close();
|
||||||
|
}
|
||||||
|
port.dispose();
|
||||||
|
config.dispose();
|
||||||
|
service.dispose();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _firstNonEmpty(List<String?> values) {
|
||||||
|
for (final value in values) {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
if (trimmed != null && trimmed.isNotEmpty) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Serial Device';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
|
||||||
|
|
||||||
|
SerialTransport createSerialTransportImpl() => _IoSerialTransport();
|
||||||
35
lib/services/serial/serial_transport_stub.dart
Normal file
35
lib/services/serial/serial_transport_stub.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'serial_transport.dart';
|
||||||
|
|
||||||
|
class _UnsupportedSerialTransport implements SerialTransport {
|
||||||
|
@override
|
||||||
|
bool get isSupported => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get canRequestDevice => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionLabel => 'Serial unsupported';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyStateTitle => 'Serial is unavailable on this platform.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unsupportedMessage =>
|
||||||
|
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SerialDeviceInfo>> listDevices() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SerialDeviceInfo?> requestDevice() async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SerialConnection> connect(SerialDeviceInfo device) async {
|
||||||
|
throw UnsupportedError(unsupportedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
SerialTransport createSerialTransportImpl() => _UnsupportedSerialTransport();
|
||||||
209
lib/services/serial/serial_transport_web.dart
Normal file
209
lib/services/serial/serial_transport_web.dart
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:js_interop_unsafe';
|
||||||
|
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:webserial/webserial.dart';
|
||||||
|
|
||||||
|
import 'serial_transport.dart';
|
||||||
|
|
||||||
|
class _WebSerialTransport implements SerialTransport {
|
||||||
|
@override
|
||||||
|
bool get isSupported =>
|
||||||
|
web.window.navigator.hasProperty('serial'.toJS).toDart;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get canRequestDevice => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionLabel => 'Choose serial device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyStateTitle =>
|
||||||
|
'No serial devices granted yet.\nChoose a device and approve access in your browser.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unsupportedMessage =>
|
||||||
|
'Web Serial requires a compatible browser such as Chrome or Edge and a secure context (HTTPS or localhost).';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SerialDeviceInfo>> listDevices() async {
|
||||||
|
if (!isSupported) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final ports = await serial.getPorts().toDart;
|
||||||
|
return ports
|
||||||
|
.toDart
|
||||||
|
.whereType<JSSerialPort>()
|
||||||
|
.map(_deviceInfoForPort)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SerialDeviceInfo?> requestDevice() async {
|
||||||
|
if (!isSupported) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final port = await requestWebSerialPort(null);
|
||||||
|
if (port == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _deviceInfoForPort(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SerialConnection> connect(SerialDeviceInfo device) async {
|
||||||
|
final port = device.handle as JSSerialPort;
|
||||||
|
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
|
||||||
|
web.ReadableStreamDefaultReader? reader;
|
||||||
|
bool keepReading = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await port
|
||||||
|
.open(
|
||||||
|
JSSerialOptions(
|
||||||
|
baudRate: kSerialBaudRate,
|
||||||
|
dataBits: 8,
|
||||||
|
stopBits: 1,
|
||||||
|
parity: 'none',
|
||||||
|
bufferSize: 1024,
|
||||||
|
flowControl: 'none',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toDart;
|
||||||
|
|
||||||
|
await port
|
||||||
|
.setSignals(
|
||||||
|
JSSerialOutputSignals(
|
||||||
|
dataTerminalReady: true,
|
||||||
|
requestToSend: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toDart;
|
||||||
|
|
||||||
|
service.writeRaw = (data) async {
|
||||||
|
final writable = port.writable;
|
||||||
|
if (writable == null) {
|
||||||
|
throw Exception('Serial port is not writable');
|
||||||
|
}
|
||||||
|
final writer =
|
||||||
|
writable.getWriter() as web.WritableStreamDefaultWriter?;
|
||||||
|
if (writer == null) {
|
||||||
|
throw Exception('Failed to acquire serial writer');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await writer.write(data.toJS).toDart;
|
||||||
|
} finally {
|
||||||
|
writer.releaseLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
final readable = port.readable;
|
||||||
|
if (readable == null) {
|
||||||
|
throw Exception('Serial port is not readable');
|
||||||
|
}
|
||||||
|
|
||||||
|
reader = readable.getReader() as web.ReadableStreamDefaultReader?;
|
||||||
|
if (reader == null) {
|
||||||
|
throw Exception('Failed to acquire serial reader');
|
||||||
|
}
|
||||||
|
|
||||||
|
keepReading = true;
|
||||||
|
unawaited(_readLoop(reader, service, () => keepReading));
|
||||||
|
|
||||||
|
final connected = await service.markConnected();
|
||||||
|
if (!connected) {
|
||||||
|
throw Exception('Serial session initialization failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return SerialConnection(
|
||||||
|
service: service,
|
||||||
|
deviceId: device.id,
|
||||||
|
deviceName: device.title,
|
||||||
|
disconnect: () async {
|
||||||
|
keepReading = false;
|
||||||
|
try {
|
||||||
|
await reader?.cancel().toDart;
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
reader?.releaseLock();
|
||||||
|
} catch (_) {}
|
||||||
|
reader = null;
|
||||||
|
service.writeRaw = null;
|
||||||
|
try {
|
||||||
|
await port.close().toDart;
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
keepReading = false;
|
||||||
|
try {
|
||||||
|
await reader?.cancel().toDart;
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
reader?.releaseLock();
|
||||||
|
} catch (_) {}
|
||||||
|
service.writeRaw = null;
|
||||||
|
try {
|
||||||
|
await port.close().toDart;
|
||||||
|
} catch (_) {}
|
||||||
|
service.dispose();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _readLoop(
|
||||||
|
web.ReadableStreamDefaultReader reader,
|
||||||
|
MeshCoreSerialService service,
|
||||||
|
bool Function() shouldContinue,
|
||||||
|
) async {
|
||||||
|
while (shouldContinue()) {
|
||||||
|
try {
|
||||||
|
final result = await reader.read().toDart;
|
||||||
|
if (result.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final value = result.value;
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
service.feedRawBytes((value as JSUint8Array).toDart);
|
||||||
|
} catch (_) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.markDisconnected();
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
SerialDeviceInfo _deviceInfoForPort(JSSerialPort port) {
|
||||||
|
final info = port.getInfo();
|
||||||
|
final vendorId = info.usbVendorId;
|
||||||
|
final productId = info.usbProductId;
|
||||||
|
final title = (vendorId != 0 || productId != 0)
|
||||||
|
? 'USB ${_hex(vendorId)}:${_hex(productId)}'
|
||||||
|
: 'Granted serial device';
|
||||||
|
final subtitle = [
|
||||||
|
if (vendorId != 0 || productId != 0)
|
||||||
|
'VID:${_hex(vendorId)} PID:${_hex(productId)}',
|
||||||
|
'Web Serial',
|
||||||
|
].join(' • ');
|
||||||
|
|
||||||
|
return SerialDeviceInfo(
|
||||||
|
id: '$title::$subtitle',
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
handle: port,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
|
||||||
|
|
||||||
|
SerialTransport createSerialTransportImpl() => _WebSerialTransport();
|
||||||
24
lib/utils/contact_sorting.dart
Normal file
24
lib/utils/contact_sorting.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
int compareContactsByLastSeen(Contact a, Contact b) {
|
||||||
|
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
int compareContactsByFavouriteThenLastSeen(Contact a, Contact b) {
|
||||||
|
if (a.isFavourite != b.isFavourite) {
|
||||||
|
return a.isFavourite ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareContactsByLastSeen(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
int compareContactsByDisplayName(Contact a, Contact b) {
|
||||||
|
final nameCompare = a.displayName.toLowerCase().compareTo(
|
||||||
|
b.displayName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (nameCompare != 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||||
|
}
|
||||||
@@ -1,13 +1,121 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:usb_serial/usb_serial.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';
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -26,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;
|
||||||
|
|
||||||
@@ -33,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(() {
|
||||||
@@ -58,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(() {
|
||||||
@@ -103,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();
|
||||||
@@ -114,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>();
|
||||||
@@ -166,7 +283,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Choose Bluetooth, WiFi, or USB transport',
|
'Choose Bluetooth, WiFi, or Serial transport',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
@@ -192,7 +309,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)),
|
Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)),
|
||||||
Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)),
|
Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)),
|
||||||
Tab(text: 'USB', icon: Icon(Icons.usb_rounded)),
|
Tab(text: 'Serial', icon: Icon(Icons.usb_rounded)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -250,6 +367,36 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorBanner(String message) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState({
|
Widget _buildEmptyState({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
@@ -364,13 +511,17 @@ 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)
|
||||||
|
_buildErrorBanner(connectionProvider.error!),
|
||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
connectionProvider.isScanning &&
|
connectionProvider.isScanning &&
|
||||||
@@ -379,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(
|
||||||
@@ -395,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(() {
|
||||||
@@ -502,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;
|
||||||
@@ -520,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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +728,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUsbTab() {
|
Widget _buildUsbTab() {
|
||||||
return _UsbDeviceList(
|
return _SerialDeviceList(
|
||||||
buildTransportCard:
|
buildTransportCard:
|
||||||
({
|
({
|
||||||
required icon,
|
required icon,
|
||||||
@@ -628,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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,38 +787,48 @@ typedef _EmptyStateBuilder =
|
|||||||
required VoidCallback onAction,
|
required VoidCallback onAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
class _UsbDeviceList 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;
|
||||||
|
|
||||||
const _UsbDeviceList({
|
const _SerialDeviceList({
|
||||||
required this.onConnected,
|
required this.onConnected,
|
||||||
required this.buildTransportCard,
|
required this.buildTransportCard,
|
||||||
required this.buildEmptyState,
|
required this.buildEmptyState,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_UsbDeviceList> createState() => _UsbDeviceListState();
|
State<_SerialDeviceList> createState() => _SerialDeviceListState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UsbDeviceListState extends State<_UsbDeviceList> {
|
class _SerialDeviceListState extends State<_SerialDeviceList> {
|
||||||
List<UsbDevice> _devices = [];
|
final SerialTransport _transport = createSerialTransport();
|
||||||
|
List<SerialDeviceInfo> _devices = [];
|
||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
bool _isConnecting = false;
|
bool _isConnecting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
if (_transport.isSupported) {
|
||||||
_scanDevices();
|
_scanDevices();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_transport.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _scanDevices() async {
|
Future<void> _scanDevices() async {
|
||||||
|
if (!_transport.isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() => _isScanning = true);
|
setState(() => _isScanning = true);
|
||||||
try {
|
try {
|
||||||
final devices = await UsbSerial.listDevices();
|
final devices = await _transport.listDevices();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_devices = devices;
|
_devices = devices;
|
||||||
@@ -700,81 +843,64 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connectToDevice(UsbDevice device) async {
|
Future<void> _requestDevice() async {
|
||||||
|
if (!_transport.canRequestDevice) {
|
||||||
|
await _scanDevices();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isScanning = true);
|
||||||
|
try {
|
||||||
|
final selectedDevice = await _transport.requestDevice();
|
||||||
|
final devices = await _transport.listDevices();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_devices = selectedDevice == null
|
||||||
|
? devices
|
||||||
|
: _mergeDevices(devices, selectedDevice);
|
||||||
|
_isScanning = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isScanning = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SerialDeviceInfo> _mergeDevices(
|
||||||
|
List<SerialDeviceInfo> devices,
|
||||||
|
SerialDeviceInfo selectedDevice,
|
||||||
|
) {
|
||||||
|
final merged = [...devices];
|
||||||
|
if (!merged.any((device) => device.id == selectedDevice.id)) {
|
||||||
|
merged.insert(0, selectedDevice);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectToDevice(SerialDeviceInfo device) async {
|
||||||
setState(() => _isConnecting = true);
|
setState(() => _isConnecting = true);
|
||||||
try {
|
try {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
|
final connection = await _transport.connect(device);
|
||||||
|
final success = await connectionProvider.connectSerial(
|
||||||
final port = await device.create();
|
service: connection.service,
|
||||||
if (port == null) {
|
disconnectTransport: connection.disconnect,
|
||||||
if (mounted) {
|
deviceId: connection.deviceId,
|
||||||
setState(() => _isConnecting = false);
|
deviceName: connection.deviceName,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Failed to create USB port')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final opened = await port.open();
|
|
||||||
if (!opened) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isConnecting = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Failed to open USB port')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await port.setDTR(true);
|
|
||||||
await port.setRTS(true);
|
|
||||||
await port.setPortParameters(
|
|
||||||
115200,
|
|
||||||
UsbPort.DATABITS_8,
|
|
||||||
UsbPort.STOPBITS_1,
|
|
||||||
UsbPort.PARITY_NONE,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
service.writeRaw = (data) async {
|
|
||||||
await port.write(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
port.inputStream?.listen(
|
|
||||||
(data) => service.feedRawBytes(data),
|
|
||||||
onError: (_) {
|
|
||||||
service.markDisconnected();
|
|
||||||
port.close();
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
service.markDisconnected();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final sessionOk = await service.markConnected();
|
|
||||||
if (!sessionOk) {
|
|
||||||
await port.close();
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isConnecting = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('USB session initialization failed')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final success = await connectionProvider.connectSerial(service);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
widget.onConnected();
|
widget.onConnected(_ConnectionDialogResult.connected);
|
||||||
} else {
|
} else {
|
||||||
await port.close();
|
await connection.disconnect();
|
||||||
|
connection.service.dispose();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isConnecting = false);
|
setState(() => _isConnecting = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Failed to connect via USB')),
|
const SnackBar(content: Text('Failed to connect via serial')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -782,20 +908,18 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
setState(() => _isConnecting = false);
|
setState(() => _isConnecting = false);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('USB error: $e')));
|
).showSnackBar(SnackBar(content: Text('Serial error: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
if (!_transport.isSupported) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Text(
|
child: Text(
|
||||||
kIsWeb
|
_transport.unsupportedMessage,
|
||||||
? 'Web Serial is not yet supported.\nUse BLE or Network instead.'
|
|
||||||
: 'USB serial is available on Android only.\nConnect via OTG cable.',
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -811,19 +935,22 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: FilledButton.tonalIcon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: _isConnecting ? null : _scanDevices,
|
onPressed: _isConnecting
|
||||||
|
? null
|
||||||
|
: (_transport.canRequestDevice ? _requestDevice : _scanDevices),
|
||||||
icon: const Icon(Icons.usb_rounded),
|
icon: const Icon(Icons.usb_rounded),
|
||||||
label: const Text('Scan USB devices'),
|
label: Text(_transport.actionLabel),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_devices.isEmpty)
|
if (_devices.isEmpty)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: widget.buildEmptyState(
|
child: widget.buildEmptyState(
|
||||||
icon: Icons.usb_off_rounded,
|
icon: Icons.usb_off_rounded,
|
||||||
title:
|
title: _transport.emptyStateTitle,
|
||||||
'No USB serial devices found.\nConnect a MeshCore device via OTG cable.',
|
actionLabel: _transport.actionLabel,
|
||||||
actionLabel: 'Scan USB devices',
|
onAction: _transport.canRequestDevice
|
||||||
onAction: _scanDevices,
|
? _requestDevice
|
||||||
|
: _scanDevices,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -835,10 +962,8 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
|
|||||||
return widget.buildTransportCard(
|
return widget.buildTransportCard(
|
||||||
icon: Icons.usb_rounded,
|
icon: Icons.usb_rounded,
|
||||||
iconColor: Theme.of(context).colorScheme.primary,
|
iconColor: Theme.of(context).colorScheme.primary,
|
||||||
title: device.productName ?? 'USB Device',
|
title: device.title,
|
||||||
subtitle: (device.manufacturerName?.isNotEmpty ?? false)
|
subtitle: device.subtitle,
|
||||||
? device.manufacturerName!
|
|
||||||
: 'Ready over OTG serial',
|
|
||||||
trailing: _isConnecting
|
trailing: _isConnecting
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,395 +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.surface.withValues(alpha: 0.7),
|
|
||||||
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),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
scheme.surface,
|
|
||||||
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
scheme.primary.withValues(alpha: 0.12),
|
|
||||||
scheme.tertiary.withValues(alpha: 0.08),
|
|
||||||
scheme.primary.withValues(alpha: 0.12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: rangeLeft,
|
|
||||||
top: 12,
|
|
||||||
width: (rangeRight - rangeLeft).clamp(8.0, width),
|
|
||||||
height: 56,
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
color: scheme.primary.withValues(alpha: 0.20),
|
|
||||||
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.tertiary.withValues(alpha: 0.32),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../models/contact.dart';
|
import '../../../models/contact.dart';
|
||||||
import '../../../models/sar_marker.dart';
|
import '../../../models/sar_marker.dart';
|
||||||
|
import 'compass_math.dart';
|
||||||
|
|
||||||
/// Header component for the compass dialog showing compass rose,
|
/// Header component for the compass dialog showing compass rose,
|
||||||
/// heading, elevation, accuracy, and current location in multiple formats.
|
/// heading, elevation, accuracy, and current location in multiple formats.
|
||||||
@@ -76,7 +77,11 @@ class CompassHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoRow(BuildContext context, double? heading, Position? position) {
|
Widget _buildInfoRow(
|
||||||
|
BuildContext context,
|
||||||
|
double? heading,
|
||||||
|
Position? position,
|
||||||
|
) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
@@ -90,17 +95,13 @@ class CompassHeader extends StatelessWidget {
|
|||||||
_buildInfoCard(
|
_buildInfoCard(
|
||||||
context,
|
context,
|
||||||
l10n.elevation,
|
l10n.elevation,
|
||||||
position?.altitude != null
|
position?.altitude != null ? '${position!.altitude.round()}m' : '--',
|
||||||
? '${position!.altitude.round()}m'
|
|
||||||
: '--',
|
|
||||||
Icons.terrain,
|
Icons.terrain,
|
||||||
),
|
),
|
||||||
_buildInfoCard(
|
_buildInfoCard(
|
||||||
context,
|
context,
|
||||||
l10n.accuracy,
|
l10n.accuracy,
|
||||||
position?.accuracy != null
|
position?.accuracy != null ? '±${position!.accuracy.round()}m' : '--',
|
||||||
? '±${position!.accuracy.round()}m'
|
|
||||||
: '--',
|
|
||||||
Icons.gps_fixed,
|
Icons.gps_fixed,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -108,21 +109,22 @@ class CompassHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(
|
Widget _buildInfoCard(
|
||||||
BuildContext context, String label, String value, IconData icon) {
|
BuildContext context,
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
IconData icon,
|
||||||
|
) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
|
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
|
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,11 +195,16 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
|
|
||||||
// Draw degree markers
|
// Draw degree markers
|
||||||
for (int i = 0; i < 360; i += 10) {
|
for (int i = 0; i < 360; i += 10) {
|
||||||
final angle = i * pi / 180 - pi / 2 + heading * pi / 180;
|
final angle = compassDialAngleRadians(
|
||||||
|
markerDegrees: i.toDouble(),
|
||||||
|
headingDegrees: heading,
|
||||||
|
);
|
||||||
final isCardinal = i % 90 == 0;
|
final isCardinal = i % 90 == 0;
|
||||||
final isMajor = i % 30 == 0;
|
final isMajor = i % 30 == 0;
|
||||||
|
|
||||||
final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10);
|
final startRadius = isCardinal
|
||||||
|
? radius - 25
|
||||||
|
: (isMajor ? radius - 15 : radius - 10);
|
||||||
final start = Offset(
|
final start = Offset(
|
||||||
center.dx + startRadius * cos(angle),
|
center.dx + startRadius * cos(angle),
|
||||||
center.dy + startRadius * sin(angle),
|
center.dy + startRadius * sin(angle),
|
||||||
@@ -218,7 +225,10 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
final directions = ['N', 'E', 'S', 'W'];
|
final directions = ['N', 'E', 'S', 'W'];
|
||||||
for (int i = 0; i < 4; i++) {
|
for (int i = 0; i < 4; i++) {
|
||||||
final angle = i * pi / 2 - pi / 2 + heading * pi / 180;
|
final angle = compassDialAngleRadians(
|
||||||
|
markerDegrees: (i * 90).toDouble(),
|
||||||
|
headingDegrees: heading,
|
||||||
|
);
|
||||||
final x = center.dx + (radius - 35) * cos(angle);
|
final x = center.dx + (radius - 35) * cos(angle);
|
||||||
final y = center.dy + (radius - 35) * sin(angle);
|
final y = center.dy + (radius - 35) * sin(angle);
|
||||||
|
|
||||||
@@ -243,20 +253,25 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
final contactsWithDistance = contacts
|
final contactsWithDistance = contacts
|
||||||
.where((c) => c.displayLocation != null)
|
.where((c) => c.displayLocation != null)
|
||||||
.map((contact) {
|
.map((contact) {
|
||||||
final bearing = _calculateBearing(
|
final bearing = _calculateBearing(
|
||||||
currentPosition!.latitude,
|
currentPosition!.latitude,
|
||||||
currentPosition!.longitude,
|
currentPosition!.longitude,
|
||||||
contact.displayLocation!.latitude,
|
contact.displayLocation!.latitude,
|
||||||
contact.displayLocation!.longitude,
|
contact.displayLocation!.longitude,
|
||||||
);
|
);
|
||||||
final distance = _calculateDistance(
|
final distance = _calculateDistance(
|
||||||
currentPosition!.latitude,
|
currentPosition!.latitude,
|
||||||
currentPosition!.longitude,
|
currentPosition!.longitude,
|
||||||
contact.displayLocation!.latitude,
|
contact.displayLocation!.latitude,
|
||||||
contact.displayLocation!.longitude,
|
contact.displayLocation!.longitude,
|
||||||
);
|
);
|
||||||
return {'contact': contact, 'bearing': bearing, 'distance': distance};
|
return {
|
||||||
}).toList();
|
'contact': contact,
|
||||||
|
'bearing': bearing,
|
||||||
|
'distance': distance,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (contactsWithDistance.isEmpty) return;
|
if (contactsWithDistance.isEmpty) return;
|
||||||
|
|
||||||
@@ -277,7 +292,8 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
|
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
|
||||||
|
|
||||||
// Calculate contact position radius (from center to rim based on distance)
|
// Calculate contact position radius (from center to rim based on distance)
|
||||||
final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim
|
final contactRadius =
|
||||||
|
radius * normalizedDistance * 0.85; // 0.85 to keep inside rim
|
||||||
|
|
||||||
// Position of contact dot
|
// Position of contact dot
|
||||||
final dotX = center.dx + contactRadius * cos(angle);
|
final dotX = center.dx + contactRadius * cos(angle);
|
||||||
@@ -288,11 +304,7 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
..color = Colors.lightBlue.withValues(alpha: 0.3)
|
..color = Colors.lightBlue.withValues(alpha: 0.3)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1.5;
|
..strokeWidth = 1.5;
|
||||||
canvas.drawLine(
|
canvas.drawLine(center, Offset(dotX, dotY), linePaint);
|
||||||
center,
|
|
||||||
Offset(dotX, dotY),
|
|
||||||
linePaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw contact dot (size varies with zoom)
|
// Draw contact dot (size varies with zoom)
|
||||||
final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0);
|
final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0);
|
||||||
@@ -341,7 +353,10 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
|
|
||||||
textPainter.paint(
|
textPainter.paint(
|
||||||
canvas,
|
canvas,
|
||||||
Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2),
|
Offset(
|
||||||
|
labelX - textPainter.width / 2,
|
||||||
|
labelY - textPainter.height / 2,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,11 +434,7 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
..color = markerColor.withValues(alpha: 0.3)
|
..color = markerColor.withValues(alpha: 0.3)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 2;
|
..strokeWidth = 2;
|
||||||
canvas.drawLine(
|
canvas.drawLine(center, Offset(dotX, dotY), linePaint);
|
||||||
center,
|
|
||||||
Offset(dotX, dotY),
|
|
||||||
linePaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw SAR marker dot (slightly larger than contacts)
|
// Draw SAR marker dot (slightly larger than contacts)
|
||||||
final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0);
|
final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0);
|
||||||
@@ -472,7 +483,10 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
|
|
||||||
textPainter.paint(
|
textPainter.paint(
|
||||||
canvas,
|
canvas,
|
||||||
Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2),
|
Offset(
|
||||||
|
labelX - textPainter.width / 2,
|
||||||
|
labelY - textPainter.height / 2,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,27 +506,31 @@ class _LargeCompassPainter extends CustomPainter {
|
|||||||
canvas.drawPath(path, indicatorPaint);
|
canvas.drawPath(path, indicatorPaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
double _calculateBearing(
|
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
|
||||||
double lat1, double lon1, double lat2, double lon2) {
|
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
final lat1Rad = lat1 * pi / 180;
|
final lat1Rad = lat1 * pi / 180;
|
||||||
final lat2Rad = lat2 * pi / 180;
|
final lat2Rad = lat2 * pi / 180;
|
||||||
|
|
||||||
final y = sin(dLon) * cos(lat2Rad);
|
final y = sin(dLon) * cos(lat2Rad);
|
||||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
final x =
|
||||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||||
|
|
||||||
final bearing = atan2(y, x) * 180 / pi;
|
final bearing = atan2(y, x) * 180 / pi;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
double _calculateDistance(
|
double _calculateDistance(
|
||||||
double lat1, double lon1, double lat2, double lon2) {
|
double lat1,
|
||||||
|
double lon1,
|
||||||
|
double lat2,
|
||||||
|
double lon2,
|
||||||
|
) {
|
||||||
const R = 6371000; // Earth's radius in meters
|
const R = 6371000; // Earth's radius in meters
|
||||||
final dLat = (lat2 - lat1) * pi / 180;
|
final dLat = (lat2 - lat1) * pi / 180;
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
|
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(lat1 * pi / 180) *
|
cos(lat1 * pi / 180) *
|
||||||
cos(lat2 * pi / 180) *
|
cos(lat2 * pi / 180) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
@@ -572,7 +590,8 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
|
|||||||
final String displayText;
|
final String displayText;
|
||||||
|
|
||||||
if (_showDMS) {
|
if (_showDMS) {
|
||||||
displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
|
displayText =
|
||||||
|
'${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
|
||||||
} else {
|
} else {
|
||||||
displayText = l10n.latLonFormat(
|
displayText = l10n.latLonFormat(
|
||||||
position.latitude.toStringAsFixed(5),
|
position.latitude.toStringAsFixed(5),
|
||||||
@@ -598,9 +617,9 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
|
|||||||
displayText,
|
displayText,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
22
lib/widgets/map/compass/compass_math.dart
Normal file
22
lib/widgets/map/compass/compass_math.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// Rotate the compass rose opposite the device heading so the current heading
|
||||||
|
/// stays under the fixed indicator at the top of the dial.
|
||||||
|
double compassRoseRotationRadians(double headingDegrees) {
|
||||||
|
final normalizedHeading = headingDegrees % 360;
|
||||||
|
return _normalizeRadians(-normalizedHeading * pi / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a compass marker bearing into an on-screen angle for the dial.
|
||||||
|
double compassDialAngleRadians({
|
||||||
|
required double markerDegrees,
|
||||||
|
required double headingDegrees,
|
||||||
|
}) {
|
||||||
|
return _normalizeRadians(
|
||||||
|
markerDegrees * pi / 180 -
|
||||||
|
pi / 2 +
|
||||||
|
compassRoseRotationRadians(headingDegrees),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _normalizeRadians(double angle) => atan2(sin(angle), cos(angle));
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'compass/compass_math.dart';
|
||||||
|
|
||||||
class CompassWidget extends StatelessWidget {
|
class CompassWidget extends StatelessWidget {
|
||||||
final double heading;
|
final double heading;
|
||||||
@@ -21,9 +22,9 @@ class CompassWidget extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Compass rose background - rotates to show true north at top
|
// Rotate the rose opposite the heading under the fixed needle.
|
||||||
Transform.rotate(
|
Transform.rotate(
|
||||||
angle: heading * pi / 180,
|
angle: compassRoseRotationRadians(heading),
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
size: const Size(40, 40),
|
size: const Size(40, 40),
|
||||||
painter: _CompassRosePainter(),
|
painter: _CompassRosePainter(),
|
||||||
@@ -76,9 +77,7 @@ class _CompassRosePainter extends CustomPainter {
|
|||||||
canvas.drawCircle(center, radius, paint);
|
canvas.drawCircle(center, radius, paint);
|
||||||
|
|
||||||
// Draw cardinal direction markers
|
// Draw cardinal direction markers
|
||||||
final textPainter = TextPainter(
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
|
|
||||||
final directions = ['N', 'E', 'S', 'W'];
|
final directions = ['N', 'E', 'S', 'W'];
|
||||||
for (int i = 0; i < 4; i++) {
|
for (int i = 0; i < 4; i++) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ 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 String? regionScopeName;
|
||||||
|
final VoidCallback? onRegionScopeTap;
|
||||||
|
|
||||||
const MessagesComposer({
|
const MessagesComposer({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -49,6 +51,8 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
required this.onStartVoiceRecording,
|
required this.onStartVoiceRecording,
|
||||||
required this.onStopAndSendVoice,
|
required this.onStopAndSendVoice,
|
||||||
required this.onSendMessage,
|
required this.onSendMessage,
|
||||||
|
this.regionScopeName,
|
||||||
|
this.onRegionScopeTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -102,6 +106,14 @@ 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: _DestinationSelector(
|
||||||
@@ -534,3 +546,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
819
lib/widgets/sensors/bthome_met_history_sheet.dart
Normal file
819
lib/widgets/sensors/bthome_met_history_sheet.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -211,8 +213,10 @@ List<SensorMetricOption> sensorMetricOptionsFor(
|
|||||||
// Skip them from extraSensorData to avoid duplicates.
|
// Skip them from extraSensorData to avoid duplicates.
|
||||||
final coreFieldKeys = <String>{
|
final coreFieldKeys = <String>{
|
||||||
if (batteryMilliVolts != null || batteryPercentage != null)
|
if (batteryMilliVolts != null || batteryPercentage != null)
|
||||||
...extraSensorData?.keys.where((k) =>
|
...extraSensorData?.keys.where(
|
||||||
k.startsWith('voltage_') || k.startsWith('analog_input_')) ?? [],
|
(k) => k.startsWith('voltage_') || k.startsWith('analog_input_'),
|
||||||
|
) ??
|
||||||
|
[],
|
||||||
if (temperature != null)
|
if (temperature != null)
|
||||||
...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [],
|
...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [],
|
||||||
if (humidity != null)
|
if (humidity != null)
|
||||||
@@ -290,14 +294,14 @@ SensorMetricCardData? _buildOptionPreviewCardData(
|
|||||||
);
|
);
|
||||||
case 'illuminance':
|
case 'illuminance':
|
||||||
final lux = _previewAsDouble(value);
|
final lux = _previewAsDouble(value);
|
||||||
|
final irradiance = lux == null
|
||||||
|
? null
|
||||||
|
: _previewFormatDaylightIrradiance(lux);
|
||||||
return SensorMetricCardData(
|
return SensorMetricCardData(
|
||||||
fieldKey: fieldKey,
|
fieldKey: fieldKey,
|
||||||
icon: Icons.light_mode_outlined,
|
icon: Icons.light_mode_outlined,
|
||||||
label: label,
|
label: label,
|
||||||
value: previewValue,
|
value: irradiance ?? previewValue,
|
||||||
secondaryValue: lux == null
|
|
||||||
? null
|
|
||||||
: '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight',
|
|
||||||
accent: const Color(0xFFC17B1D),
|
accent: const Color(0xFFC17B1D),
|
||||||
channel: metricKey.channel,
|
channel: metricKey.channel,
|
||||||
);
|
);
|
||||||
@@ -544,12 +548,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(
|
||||||
@@ -674,6 +681,10 @@ double _previewApproxDaylightIrradiance(double lux) {
|
|||||||
return lux / 120.0;
|
return lux / 120.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _previewFormatDaylightIrradiance(double lux) {
|
||||||
|
return '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2';
|
||||||
|
}
|
||||||
|
|
||||||
String _previewFormatCardinalDirection(double degrees) {
|
String _previewFormatCardinalDirection(double degrees) {
|
||||||
const points = <String>['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
const points = <String>['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||||
final normalized = ((degrees % 360) + 360) % 360;
|
final normalized = ((degrees % 360) + 360) % 360;
|
||||||
@@ -699,7 +710,7 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) {
|
|||||||
case 'illuminance':
|
case 'illuminance':
|
||||||
final lux = _previewAsDouble(value);
|
final lux = _previewAsDouble(value);
|
||||||
if (lux == null) return null;
|
if (lux == null) return null;
|
||||||
return '${_formatPreviewNumber(lux, maxFractionDigits: 0)} lx';
|
return _previewFormatDaylightIrradiance(lux);
|
||||||
|
|
||||||
case 'presence':
|
case 'presence':
|
||||||
final isPresent = _previewAsBool(value);
|
final isPresent = _previewAsBool(value);
|
||||||
@@ -1000,6 +1011,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+$'), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,6 +1040,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;
|
||||||
@@ -1036,6 +1057,9 @@ 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.',
|
||||||
@@ -1043,7 +1067,15 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
||||||
@@ -1122,21 +1154,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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1149,10 +1181,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) {
|
||||||
@@ -1165,6 +1221,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>(
|
||||||
@@ -1173,6 +1253,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>(
|
||||||
@@ -1215,6 +1305,11 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
metric.wide)
|
metric.wide)
|
||||||
? constraints.maxWidth
|
? constraints.maxWidth
|
||||||
: compactWidth,
|
: compactWidth,
|
||||||
|
onLongPress: onRefresh == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await onRefresh!();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
@@ -1439,9 +1534,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
fieldKey: _extraFieldKey(rawKey),
|
fieldKey: _extraFieldKey(rawKey),
|
||||||
icon: Icons.light_mode_outlined,
|
icon: Icons.light_mode_outlined,
|
||||||
label: label,
|
label: label,
|
||||||
value: '${_formatNumber(lux, maxFractionDigits: 0)} lx',
|
value: _formatDaylightIrradiance(lux),
|
||||||
secondaryValue:
|
|
||||||
'~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight',
|
|
||||||
accent: const Color(0xFFC17B1D),
|
accent: const Color(0xFFC17B1D),
|
||||||
channel: metricKey.channel,
|
channel: metricKey.channel,
|
||||||
);
|
);
|
||||||
@@ -1780,10 +1873,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':
|
||||||
@@ -1958,6 +2052,10 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
return lux / 120.0;
|
return lux / 120.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatDaylightIrradiance(double lux) {
|
||||||
|
return '~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2';
|
||||||
|
}
|
||||||
|
|
||||||
String _formatCurrent(double amps) {
|
String _formatCurrent(double amps) {
|
||||||
final absolute = amps.abs();
|
final absolute = amps.abs();
|
||||||
if (absolute < 1.0) {
|
if (absolute < 1.0) {
|
||||||
@@ -2019,7 +2117,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) {
|
||||||
@@ -2083,31 +2181,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(
|
||||||
@@ -2115,18 +2210,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2160,6 +2257,7 @@ class SensorMetricTile extends StatelessWidget {
|
|||||||
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,
|
||||||
@@ -2167,6 +2265,7 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _showExpandedMap(BuildContext context) async {
|
Future<void> _showExpandedMap(BuildContext context) async {
|
||||||
@@ -2241,142 +2340,185 @@ class SensorMetricTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Material(
|
||||||
key: ValueKey('${keyPrefix}_${data.fieldKey}'),
|
color: Colors.transparent,
|
||||||
width: width,
|
child: InkWell(
|
||||||
padding: const EdgeInsets.all(8),
|
key: ValueKey('${keyPrefix}_${data.fieldKey}'),
|
||||||
decoration: BoxDecoration(
|
|
||||||
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: onLongPress,
|
||||||
),
|
child: Container(
|
||||||
child: data.mapLocation == null || !allowMapPreview
|
width: 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: 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 || !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: 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: 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,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
flutter_map.FlutterMap(
|
|
||||||
options: flutter_map.MapOptions(
|
|
||||||
initialCenter: data.mapLocation!,
|
|
||||||
initialZoom: 14,
|
|
||||||
interactionOptions:
|
|
||||||
const flutter_map.InteractionOptions(
|
|
||||||
flags: flutter_map.InteractiveFlag.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
flutter_map.TileLayer(
|
flutter_map.FlutterMap(
|
||||||
urlTemplate:
|
options: flutter_map.MapOptions(
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
initialCenter: data.mapLocation!,
|
||||||
userAgentPackageName:
|
initialZoom: 14,
|
||||||
'com.meshcore.sar.meshcore_sar_app',
|
interactionOptions:
|
||||||
),
|
const flutter_map.InteractionOptions(
|
||||||
flutter_map.MarkerLayer(
|
flags:
|
||||||
markers: [
|
flutter_map.InteractiveFlag.none,
|
||||||
flutter_map.Marker(
|
),
|
||||||
point: data.mapLocation!,
|
),
|
||||||
width: 32,
|
children: [
|
||||||
height: 32,
|
flutter_map.TileLayer(
|
||||||
child: Icon(
|
urlTemplate:
|
||||||
Icons.location_on,
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
color: data.accent,
|
userAgentPackageName:
|
||||||
size: 28,
|
'com.meshcore.sar.meshcore_sar_app',
|
||||||
),
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2409,6 +2551,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: [
|
||||||
@@ -2448,12 +2594,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;
|
||||||
@@ -2465,6 +2737,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,
|
||||||
@@ -2496,13 +2769,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) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_avif_linux/flutter_avif_linux_plugin.h>
|
#include <flutter_avif_linux/flutter_avif_linux_plugin.h>
|
||||||
|
#include <flutter_libserialport/flutter_libserialport_plugin.h>
|
||||||
#include <record_linux/record_linux_plugin.h>
|
#include <record_linux/record_linux_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
|
||||||
flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar);
|
flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_libserialport_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterLibserialportPlugin");
|
||||||
|
flutter_libserialport_plugin_register_with_registrar(flutter_libserialport_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
audioplayers_linux
|
audioplayers_linux
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
flutter_avif_linux
|
flutter_avif_linux
|
||||||
|
flutter_libserialport
|
||||||
record_linux
|
record_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import file_picker
|
|||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import flutter_avif_macos
|
import flutter_avif_macos
|
||||||
import flutter_blue_plus_darwin
|
import flutter_blue_plus_darwin
|
||||||
|
import flutter_libserialport
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import nsd_macos
|
import nsd_macos
|
||||||
@@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin"))
|
FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin"))
|
||||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||||
|
FlutterLibserialportPlugin.register(with: registry.registrar(forPlugin: "FlutterLibserialportPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
platform :osx, '10.15'
|
platform :osx, '26.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
@@ -38,6 +38,9 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_macos_build_settings(target)
|
flutter_additional_macos_build_settings(target)
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '26.0'
|
||||||
|
end
|
||||||
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
|
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
|
||||||
# does not compile on x86_64 macOS. Voice messages are iOS-only, so exclude
|
# does not compile on x86_64 macOS. Voice messages are iOS-only, so exclude
|
||||||
# all C source files from the macOS codec2_flutter build entirely.
|
# all C source files from the macOS codec2_flutter build entirely.
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ PODS:
|
|||||||
- flutter_blue_plus_darwin (0.0.2):
|
- flutter_blue_plus_darwin (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- flutter_libserialport (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
|
- libserialport
|
||||||
- flutter_local_notifications (0.0.1):
|
- flutter_local_notifications (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
- geolocator_apple (1.2.0):
|
- geolocator_apple (1.2.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- libserialport (0.1.1)
|
||||||
- nsd_macos (0.0.1):
|
- nsd_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- package_info_plus (0.0.1):
|
- package_info_plus (0.0.1):
|
||||||
@@ -40,6 +44,8 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- url_launcher_macos (0.0.1):
|
- url_launcher_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- wakelock_plus (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`)
|
- audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||||
@@ -49,6 +55,7 @@ DEPENDENCIES:
|
|||||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||||
- flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`)
|
- flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`)
|
||||||
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||||
|
- flutter_libserialport (from `Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos`)
|
||||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
@@ -60,6 +67,11 @@ DEPENDENCIES:
|
|||||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||||
|
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- libserialport
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audioplayers_darwin:
|
audioplayers_darwin:
|
||||||
@@ -76,6 +88,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
|
||||||
flutter_blue_plus_darwin:
|
flutter_blue_plus_darwin:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
||||||
|
flutter_libserialport:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
@@ -98,6 +112,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||||
|
wakelock_plus:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||||
@@ -107,9 +123,11 @@ SPEC CHECKSUMS:
|
|||||||
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
|
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
|
||||||
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
|
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
|
flutter_libserialport: 2c523cb8d8203fc6d1b0da88190da31ac970eb93
|
||||||
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
|
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
|
libserialport: 1cb25e66ef3c92a8e59c2ea3820302c3fa2268cd
|
||||||
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
|
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
|
||||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
@@ -118,7 +136,8 @@ SPEC CHECKSUMS:
|
|||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
|
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
||||||
|
|
||||||
PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b
|
PODFILE CHECKSUM: 467066e8d30d1105257e0877b8cdd20642e7b3db
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -557,7 +557,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -639,7 +639,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -689,7 +689,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -24,10 +24,16 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>$(PRODUCT_COPYRIGHT)</string>
|
<string>$(PRODUCT_COPYRIGHT)</string>
|
||||||
<key>NSMainNibFile</key>
|
<key>NSMainNibFile</key>
|
||||||
<string>MainMenu</string>
|
<string>MainMenu</string>
|
||||||
|
<key>NSLocationUsageDescription</key>
|
||||||
|
<string>MeshCore SAR needs your location to track your team, share your position over the mesh network, and show your location on the map during SAR operations</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
|
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
|
|||||||
@@ -2,7 +2,5 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
52
pubspec.lock
52
pubspec.lock
@@ -218,6 +218,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.3"
|
version: "7.0.3"
|
||||||
|
dylib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dylib
|
||||||
|
sha256: bf609b3eb6492a3309b3d1dbe8f83a4031de5535dd7686be33487051cc760bb0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
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:
|
||||||
@@ -298,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
|
||||||
@@ -479,6 +503,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.4"
|
version: "0.14.4"
|
||||||
|
flutter_libserialport:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_libserialport
|
||||||
|
sha256: f24b5fe6f1821d1c1b3bf2be737ca53f6b0ae78657032a790aa00abb810c8759
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -759,6 +791,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
libserialport:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: libserialport
|
||||||
|
sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.0+1"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -787,8 +827,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: a0deff8
|
||||||
resolved-ref: abff892fac0dd0e8004bfa1f415df74b44b8eb52
|
resolved-ref: a0deff80fcbca974f0e18fe47971b76bec583109
|
||||||
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"
|
||||||
@@ -1477,6 +1517,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
webserial:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: webserial
|
||||||
|
sha256: b39c2175190ee1c75e996bf137bfee3e3e8fd06ee0ec24f0d3c10bdf7b323786
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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.0317.1+33
|
version: 2026.0322.2+39
|
||||||
|
|
||||||
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: a0deff8
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
@@ -123,6 +124,8 @@ dependencies:
|
|||||||
nsd: ^4.0.3
|
nsd: ^4.0.3
|
||||||
usb_serial: ^0.5.2
|
usb_serial: ^0.5.2
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
|
flutter_libserialport: ^0.6.0
|
||||||
|
webserial: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
35
test/models/channel_test.dart
Normal file
35
test/models/channel_test.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/channel.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('hash channels expose deterministic psk_base64', () {
|
||||||
|
final channel = Channel.create(index: 3, name: '#ops');
|
||||||
|
|
||||||
|
expect(channel.isHashChannel, isTrue);
|
||||||
|
expect(channel.pskBase64, 'O2RN43fDLHh5NgWiWqkVvw==');
|
||||||
|
expect(
|
||||||
|
Channel.pskBase64ForHashChannelName('#ops'),
|
||||||
|
'O2RN43fDLHh5NgWiWqkVvw==',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal channels reject derived hashtag psk export helper', () {
|
||||||
|
expect(
|
||||||
|
() => Channel.pskBase64ForHashChannelName('ops'),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal channels still expose their stored psk_base64', () {
|
||||||
|
final channel = Channel.create(
|
||||||
|
index: 4,
|
||||||
|
name: 'ops',
|
||||||
|
explicitSecret: Uint8List.fromList(List<int>.generate(16, (i) => i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(channel.isHashChannel, isFalse);
|
||||||
|
expect(channel.pskBase64, 'AAECAwQFBgcICQoLDA0ODw==');
|
||||||
|
});
|
||||||
|
}
|
||||||
25
test/providers/channels_provider_test.dart
Normal file
25
test/providers/channels_provider_test.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
69
test/providers/contacts_provider_profile_scope_test.dart
Normal file
69
test/providers/contacts_provider_profile_scope_test.dart
Normal 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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
@@ -870,6 +870,139 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ContactsProvider device sync preparation', () {
|
||||||
|
late ContactsProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
provider = ContactsProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'clears runtime contacts before 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, isEmpty);
|
||||||
|
expect(provider.pendingAdverts, isEmpty);
|
||||||
|
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) {
|
||||||
|
|||||||
134
test/providers/media_provider_profile_scope_test.dart
Normal file
134
test/providers/media_provider_profile_scope_test.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -390,6 +390,38 @@ 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('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]);
|
||||||
|
|||||||
@@ -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,43 @@ void main() {
|
|||||||
expect(connectionProvider.pingCalls, 2);
|
expect(connectionProvider.pingCalls, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refreshDueSensors refreshes self every minute', () 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, 1);
|
||||||
|
|
||||||
|
await provider.refreshDueSensors(
|
||||||
|
now: start.add(const Duration(minutes: 1)),
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
expect(connectionProvider.pingCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'addSensor includes available extra telemetry fields by default',
|
'addSensor includes available extra telemetry fields by default',
|
||||||
() async {
|
() async {
|
||||||
@@ -340,4 +420,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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,80 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
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/channel.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact_group.dart';
|
import 'package:meshcore_sar_app/models/contact_group.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/device_info.dart' as device_info;
|
||||||
|
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/channels_provider.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/drawing_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/image_provider.dart'
|
||||||
|
as image_provider;
|
||||||
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/voice_provider.dart';
|
||||||
import 'package:meshcore_sar_app/screens/contacts_tab.dart';
|
import 'package:meshcore_sar_app/screens/contacts_tab.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/contact_grouping.dart';
|
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class _FakeConnectionProvider extends ConnectionProvider {
|
||||||
|
_FakeConnectionProvider({required bool isConnected})
|
||||||
|
: _isConnected = isConnected;
|
||||||
|
|
||||||
|
final bool _isConnected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo(
|
||||||
|
connectionState: _isConnected
|
||||||
|
? device_info.ConnectionState.connected
|
||||||
|
: device_info.ConnectionState.disconnected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeMessagesProvider extends MessagesProvider {
|
||||||
|
@override
|
||||||
|
bool get isInitialized => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeDrawingProvider extends DrawingProvider {
|
||||||
|
@override
|
||||||
|
bool get isInitialized => true;
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
String? clipboardText;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
ProfileStorageScope.setScope(
|
||||||
|
profilesEnabled: false,
|
||||||
|
activeProfileId: 'default',
|
||||||
|
);
|
||||||
|
clipboardText = null;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'Clipboard.setData':
|
||||||
|
clipboardText =
|
||||||
|
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
|
||||||
|
return null;
|
||||||
|
case 'Clipboard.getData':
|
||||||
|
return <String, dynamic>{'text': clipboardText};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
Contact buildChannel({required String name, required int channelIndex}) {
|
Contact buildChannel({required String name, required int channelIndex}) {
|
||||||
@@ -80,8 +138,28 @@ void main() {
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
List<Contact> contacts = const [],
|
List<Contact> contacts = const [],
|
||||||
List<SavedContactGroup> savedGroups = const [],
|
List<SavedContactGroup> savedGroups = const [],
|
||||||
|
ConnectionProvider? connectionProvider,
|
||||||
}) async {
|
}) async {
|
||||||
final contactsProvider = ContactsProvider();
|
final contactsProvider = ContactsProvider();
|
||||||
|
final resolvedConnectionProvider =
|
||||||
|
connectionProvider ?? ConnectionProvider();
|
||||||
|
final messagesProvider = _FakeMessagesProvider();
|
||||||
|
final drawingProvider = _FakeDrawingProvider();
|
||||||
|
final channelsProvider = ChannelsProvider();
|
||||||
|
final voiceProvider = VoiceProvider(
|
||||||
|
codec: VoiceCodecService(),
|
||||||
|
player: VoicePlayerService(),
|
||||||
|
);
|
||||||
|
final imageProvider = image_provider.ImageProvider();
|
||||||
|
final appProvider = AppProvider(
|
||||||
|
connectionProvider: resolvedConnectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
messagesProvider: messagesProvider,
|
||||||
|
drawingProvider: drawingProvider,
|
||||||
|
channelsProvider: channelsProvider,
|
||||||
|
voiceProvider: voiceProvider,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
);
|
||||||
for (final contact in contacts) {
|
for (final contact in contacts) {
|
||||||
contactsProvider.addOrUpdateContact(contact);
|
contactsProvider.addOrUpdateContact(contact);
|
||||||
}
|
}
|
||||||
@@ -95,10 +173,17 @@ void main() {
|
|||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: contactsProvider),
|
ChangeNotifierProvider<ContactsProvider>(
|
||||||
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
create: (_) => contactsProvider,
|
||||||
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
),
|
||||||
|
ChangeNotifierProvider<ConnectionProvider>(
|
||||||
|
create: (_) => resolvedConnectionProvider,
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<MessagesProvider>(
|
||||||
|
create: (_) => messagesProvider,
|
||||||
|
),
|
||||||
ChangeNotifierProvider(create: (_) => MapProvider()),
|
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||||
|
ChangeNotifierProvider<AppProvider>(create: (_) => appProvider),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
@@ -123,6 +208,7 @@ void main() {
|
|||||||
await tester.tap(find.text('Ops'));
|
await tester.tap(find.text('Ops'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Export psk_base64'), findsNothing);
|
||||||
expect(find.text('Delete Channel'), findsOneWidget);
|
expect(find.text('Delete Channel'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Delete Channel'));
|
await tester.tap(find.text('Delete Channel'));
|
||||||
@@ -137,6 +223,26 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('hash channel activity card exports psk_base64', (tester) async {
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
contacts: [buildChannel(name: '#ops', channelIndex: 3)],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#ops'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('#ops'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Export psk_base64'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Export psk_base64'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(clipboardText, Channel.pskBase64ForHashChannelName('#ops'));
|
||||||
|
expect(find.text('psk_base64 copied to clipboard'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('repeaters show Others group when multiple groups exist', (
|
testWidgets('repeaters show Others group when multiple groups exist', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -218,7 +324,25 @@ void main() {
|
|||||||
contacts: [buildSensor(seed: 60, name: 'WX Station')],
|
contacts: [buildSensor(seed: 60, name: 'WX Station')],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('Sensors'), findsOneWidget);
|
expect(find.text('Sensors'), findsWidgets);
|
||||||
expect(find.text('WX Station'), findsOneWidget);
|
expect(find.text('WX Station'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'contacts sections do not show sensor or repeater discovery actions',
|
||||||
|
(tester) async {
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
contacts: [
|
||||||
|
buildRepeater(seed: 70, name: 'Relay 1'),
|
||||||
|
buildSensor(seed: 71, name: 'WX Station'),
|
||||||
|
],
|
||||||
|
connectionProvider: _FakeConnectionProvider(isConnected: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Discover repeaters'), findsNothing);
|
||||||
|
expect(find.byTooltip('Discover sensors'), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.radar), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
206
test/screens/discovery_screen_test.dart
Normal file
206
test/screens/discovery_screen_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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/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/screens/live_traffic_screen.dart';
|
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
|
||||||
|
|
||||||
BlePacketLog _log({
|
BlePacketLog _log({
|
||||||
@@ -50,6 +51,14 @@ List<int> _multiHopRaw({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _testApp(Widget child) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('shows empty state before traffic arrives', (tester) async {
|
testWidgets('shows empty state before traffic arrives', (tester) async {
|
||||||
final logs = <BlePacketLog>[];
|
final logs = <BlePacketLog>[];
|
||||||
@@ -57,8 +66,8 @@ void main() {
|
|||||||
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
|
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
_testApp(
|
||||||
home: LiveTrafficScreen(
|
LiveTrafficScreen(
|
||||||
logReader: () => logs,
|
logReader: () => logs,
|
||||||
refreshListenable: refresh,
|
refreshListenable: refresh,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
@@ -78,10 +87,9 @@ void main() {
|
|||||||
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
|
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
_testApp(
|
||||||
home: LiveTrafficScreen(
|
LiveTrafficScreen(
|
||||||
logReader: () => logs,
|
logReader: () => logs,
|
||||||
rxCountReader: () => 7,
|
|
||||||
refreshListenable: refresh,
|
refreshListenable: refresh,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
),
|
),
|
||||||
@@ -114,10 +122,9 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('1 pkt/min'), findsOneWidget);
|
expect(find.text('1 pkt/min'), findsOneWidget);
|
||||||
expect(find.text('Device total 7'), findsOneWidget);
|
expect(find.text('Device total 1'), findsOneWidget);
|
||||||
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
|
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
|
||||||
expect(find.text('MULTI-HOP'), findsOneWidget);
|
expect(find.text('-84 dBm'), findsOneWidget);
|
||||||
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
|
|
||||||
expect(find.textContaining('Hash:'), findsOneWidget);
|
expect(find.textContaining('Hash:'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
|
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
|
||||||
@@ -143,8 +150,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
_testApp(
|
||||||
home: LiveTrafficScreen(
|
LiveTrafficScreen(
|
||||||
logReader: () => logs,
|
logReader: () => logs,
|
||||||
refreshListenable: refresh,
|
refreshListenable: refresh,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
@@ -152,7 +159,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('MULTI-HOP'), findsOneWidget);
|
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Clear live view'));
|
await tester.tap(find.byTooltip('Clear live view'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -182,4 +189,43 @@ void main() {
|
|||||||
expect(find.text('No packets for this filter'), findsNothing);
|
expect(find.text('No packets for this filter'), findsNothing);
|
||||||
expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
|
expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('summary metrics expand across wide layouts', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
|
final logs = [
|
||||||
|
_log(
|
||||||
|
timestamp: now.subtract(const Duration(seconds: 10)),
|
||||||
|
direction: PacketDirection.rx,
|
||||||
|
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
|
||||||
|
responseCode: 0x88,
|
||||||
|
snrDb: 9.5,
|
||||||
|
rssiDbm: -82,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_testApp(LiveTrafficScreen(logReader: () => logs, now: () => now)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.getSize(
|
||||||
|
find.byKey(const ValueKey('liveTrafficMetric:rxPackets')),
|
||||||
|
)
|
||||||
|
.width >
|
||||||
|
200,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.getSize(find.byKey(const ValueKey('liveTrafficMetric:multihop')))
|
||||||
|
.width >
|
||||||
|
200,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
84
test/services/bthome_met_history_test.dart
Normal file
84
test/services/bthome_met_history_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user