Compare commits

..

33 Commits

Author SHA1 Message Date
Janez T
e85dccea35 feat: Add location-based DM retries 2026-03-23 15:16:59 +01:00
Janez T
019224d955 fix: Hide public channel and show signal pills #123 2026-03-23 15:04:45 +01:00
Janez T
4c048a5a50 fix: Show recipient activity previews 2026-03-23 10:44:20 +01:00
Janez T
2b42e7dd15 feat: Add compass and region support 2026-03-22 19:04:43 +01:00
Janez T
7ea6334f02 fix: Keep composer and sensor sheets closable #123 2026-03-22 18:27:57 +01:00
Janez Troha
6f0a66dc68 Merge pull request #35 from dz0ny/feat/channel-datagrams
feat: Add repeater discovery onboarding flow
2026-03-22 18:06:59 +01:00
Janez T
a26ae96acd feat: Add repeater discovery onboarding flow 2026-03-22 18:06:24 +01:00
Janez Troha
a6dc137e51 Merge pull request #34 from dz0ny/feat/channel-datagrams
feat: Add channel datagrams #1928
2026-03-22 17:08:34 +01:00
Janez Troha
c2501442e3 Merge pull request #33 from dz0ny/feat/update-changelog-with-highlights
Update changelog with weekly highlights and PR links
2026-03-22 17:08:13 +01:00
Janez T
71eac21df9 feat: Add channel datagrams #1928
ref:
2026-03-22 16:46:58 +01:00
Janez T
c299349b6a chore: Update changelog #0 2026-03-22 16:02:17 +01:00
Janez T
ab05f13de9 fix: Remove contact trails from map #123 2026-03-22 15:48:55 +01:00
Janez T
0e61dc5bf1 fix: Revert Darwin BLE override 2026-03-22 13:57:14 +01:00
Janez T
d45924d7b4 fix: Vendor Darwin BLE override 2026-03-22 13:56:23 +01:00
Janez T
a24e699e22 fix: Refresh MeshCore lock #31
ref: dz0ny/meshcore-sar#31
2026-03-22 09:49:16 +01:00
Janez T
7486837ad0 fix: Compact recipient selector #0 2026-03-22 09:37:53 +01:00
Janez T
059125c714 fix: Restore telemetry refresh behavior
ref:
2026-03-22 09:19:33 +01:00
Janez T
9279ff2158 chore: Bump iOS build number #123 2026-03-21 20:45:09 +01:00
Janez T
dc297b0b9f fix: Tighten device settings layout 2026-03-21 20:44:29 +01:00
Janez T
2d24481aba feat: Add MET history isolation 2026-03-21 19:22:43 +01:00
Janez T
20a1df94eb fix: Support MeshCore message links #123 2026-03-21 18:36:56 +01:00
Janez T
5cc81bef65 feat: Show self sensor default 2026-03-20 19:08:24 +01:00
Janez T
6145462555 chore: Bump iOS build #123 2026-03-19 21:39:51 +01:00
Janez T
721fb7e7a2 feat: Add serial compass support #0 2026-03-19 21:38:54 +01:00
Janez T
5a58e45e13 fix: Add contacts test providers 2026-03-19 14:28:10 +01:00
Janez T
de4258c0a4 fix: Resolve DeviceInfo import clash 2026-03-19 14:18:48 +01:00
Janez T
d8cb35c595 fix: Simplify device settings styling #123 2026-03-19 14:18:29 +01:00
Janez T
8da69705ee fix: Align contact sorting labels #0 2026-03-19 14:06:05 +01:00
Janez T
e474168ba8 feat: Show self telemetry in device info sheet
Tapping signal/battery in header now also shows device's own telemetry
(temperature, humidity, pressure, GPS) when available. Triggers a self
telemetry request on sheet open. Data comes from the device contact's
telemetry field.
2026-03-19 13:52:15 +01:00
Janez T
03dba8b3ce feat: Device info sheet on signal/battery tap
Tapping the signal bars or battery indicator in the header now shows
a bottom sheet with device details: BLE signal (dBm), battery %,
voltage, storage usage, firmware version, frequency, and TX power.

Fixes #22 (partial — device telemetry display)
2026-03-19 11:07:08 +01:00
Janez T
bc9e37bd22 fix: Count RX data and speed up Flutter builds #123 2026-03-19 10:33:20 +01:00
Janez T
a32d5d2c85 fix: Show irradiance values #123 2026-03-19 09:24:53 +01:00
Janez T
e683ab933f fix: Show original poster name in room messages, not room server name
Room servers forward messages with the original author's 4-byte public
key prefix (roomPostAuthorPrefix). Was ignoring this — all room messages
showed the room server's name as sender.

Now resolves the actual poster's name from roomPostAuthorPrefix by
matching against the contacts list. Falls back to room server name if
the author isn't a known contact.

Also fixes findContactByPrefix to accept 4-byte prefixes (was rejecting
anything shorter than 6 bytes).

Fixes #22
2026-03-19 09:20:22 +01:00
115 changed files with 13077 additions and 4021 deletions

View File

@@ -40,6 +40,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
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
uses: actions/cache@v4
@@ -95,6 +98,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
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
run: flutter config --enable-linux-desktop
@@ -132,6 +138,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
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
shell: pwsh
@@ -174,6 +183,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
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
run: flutter config --enable-macos-desktop
@@ -181,6 +193,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Install macOS native build tools
run: brew install automake libtool
- name: Build macOS release
run: flutter build macos --release
@@ -216,6 +231,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
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
run: flutter pub get
@@ -272,6 +290,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
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
run: flutter config --enable-web

View File

@@ -27,6 +27,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
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
run: flutter pub get

View File

@@ -1,5 +1,17 @@
# 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
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.

View File

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

View File

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

View File

@@ -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 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 classname="fastlane.lanes" name="2: build_app" time="121.394124">
<testcase classname="fastlane.lanes" name="2: build_app" time="96.919368">
</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>

View File

@@ -2285,7 +2285,7 @@
"@change": {
"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": {
"description": "Updated onboarding overview description"
},
@@ -2297,7 +2297,7 @@
"@wizardOverviewFeature2": {
"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": {
"description": "Updated onboarding overview feature 3"
},
@@ -2410,11 +2410,11 @@
"@wizardNetworkTitle": {
"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": {
"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": {
"description": "Updated onboarding network feature 1"
},
@@ -2422,7 +2422,7 @@
"@wizardNetworkFeature2": {
"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": {
"description": "Updated onboarding network feature 3"
},
@@ -2462,10 +2462,18 @@
"@wizardToolsFeature2": {
"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": {
"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": {
"description": "Contact actions label when already in sensors"
@@ -2770,5 +2778,24 @@
"captured": "Captured",
"size": "Size",
"noCustomChannelsToClear": "No custom channels to clear.",
"noDeviceContactsToClear": "No device contacts to clear."
"noDeviceContactsToClear": "No device contacts to clear.",
"setRegionScope": "Set region scope",
"regionScope": "Region scope",
"regionScopeNone": "None (global)",
"clearRegionScope": "Clear scope",
"regionScopeWarning": "Only repeaters allowing this region will forward.",
"discoverRegions": "Discover from repeaters",
"discoveringRegions": "Discovering regions...",
"enterRegionName": "Region name (e.g. auckland)",
"noRegionsFound": "No regions found on this repeater.",
"regionScopeSet": "Region scope set to {name}",
"@regionScopeSet": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"regionScopeCleared": "Region scope cleared"
}

View File

@@ -3219,7 +3219,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview description
///
/// 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;
/// Updated onboarding overview feature 1
@@ -3237,7 +3237,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview feature 3
///
/// 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;
/// Updated onboarding messaging page title
@@ -3387,13 +3387,13 @@ abstract class AppLocalizations {
/// Updated onboarding network page description
///
/// 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;
/// Updated onboarding network feature 1
///
/// 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;
/// Updated onboarding network feature 2
@@ -3405,7 +3405,7 @@ abstract class AppLocalizations {
/// Updated onboarding network feature 3
///
/// 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;
/// Updated onboarding map page title
@@ -3465,9 +3465,21 @@ abstract class AppLocalizations {
/// Updated onboarding tools feature 3
///
/// 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;
/// 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
///
/// In en, this message translates to:
@@ -4997,6 +5009,72 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'No device contacts to clear.'**
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

View File

@@ -1898,6 +1898,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'In Sensoren';
@@ -2698,4 +2705,40 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1905,6 +1905,13 @@ class AppLocalizationsEl extends AppLocalizations {
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
String get contactInSensors => 'Στους αισθητήρες';
@@ -2700,4 +2707,40 @@ class AppLocalizationsEl extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1717,7 +1717,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get wizardOverviewFeature1 =>
@@ -1729,7 +1729,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get wizardMessagingTitle => 'Messaging and Field Reports';
@@ -1819,11 +1819,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
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
String get wizardNetworkFeature2 =>
@@ -1831,7 +1831,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry';
@@ -1869,7 +1869,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get contactInSensors => 'In Sensors';
@@ -2662,4 +2669,40 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'No device contacts to clear.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1901,6 +1901,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'En Sensores';
@@ -2702,4 +2709,40 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'No hay contactos del dispositivo que borrar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1905,6 +1905,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'Dans Capteurs';
@@ -2706,4 +2713,40 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Aucun contact de l\'appareil à effacer.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1881,6 +1881,13 @@ class AppLocalizationsHr extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'U Senzorima';
@@ -2675,4 +2682,40 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1896,6 +1896,13 @@ class AppLocalizationsIt extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'Nei Sensori';
@@ -2692,4 +2699,40 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Nessun contatto dispositivo da cancellare.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1893,6 +1893,13 @@ class AppLocalizationsPl extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'W Czujnikach';
@@ -2689,4 +2696,40 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Brak kontaktów urządzenia do wyczyszczenia.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1900,6 +1900,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'Nos Sensores';
@@ -2700,4 +2707,40 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Sem contactos do dispositivo para limpar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1890,6 +1890,13 @@ class AppLocalizationsRu extends AppLocalizations {
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
String get contactInSensors => 'В датчиках';
@@ -2685,4 +2692,40 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1881,6 +1881,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'V senzorjih';
@@ -2675,4 +2682,40 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1883,6 +1883,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get wizardToolsFeature3 =>
'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
String get contactInSensors => 'Sensörlerde';
@@ -2673,4 +2680,40 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1896,6 +1896,13 @@ class AppLocalizationsUk extends AppLocalizations {
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
String get contactInSensors => 'У датчиках';
@@ -2696,4 +2703,40 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Немає контактів пристрою для очищення.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1796,6 +1796,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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
String get contactInSensors => '在传感器中';
@@ -2568,4 +2575,40 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noDeviceContactsToClear => '没有设备联系人可清除。';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1 +1,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"
]
}

View File

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

View File

@@ -57,9 +57,7 @@ class Channel {
} else {
// Normal channel: require explicit secret
if (explicitSecret == null || explicitSecret.length != 16) {
throw ArgumentError(
'Normal channels require a 16-byte secret',
);
throw ArgumentError('Normal channels require a 16-byte secret');
}
return Channel(
index: index,
@@ -78,6 +76,18 @@ class Channel {
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)
/// Uses the well-known pre-shared key from MeshCore
factory Channel.publicChannel() {
@@ -85,8 +95,22 @@ class Channel {
index: 0,
name: 'Public Channel',
secret: Uint8List.fromList([
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
0x8b,
0x33,
0x87,
0xe9,
0xc5,
0xcd,
0xea,
0x6a,
0xc9,
0xe5,
0xed,
0xba,
0xa1,
0x15,
0xcd,
0x72,
]),
flags: null,
);
@@ -95,6 +119,9 @@ class Channel {
/// Check if this is a hash-based channel (name starts with '#')
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
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName {
@@ -131,12 +158,7 @@ class Channel {
}
/// Create a copy with modified fields
Channel copyWith({
int? index,
String? name,
Uint8List? secret,
int? flags,
}) {
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
return Channel(
index: index ?? this.index,
name: name ?? this.name,

View File

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

View File

@@ -29,7 +29,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp:
return 'Direct (WiFi)';
case ConnectionMode.usb:
return 'Direct (USB)';
return 'Direct (Serial)';
}
}
@@ -40,7 +40,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device';
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? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? autoAddMaxHops;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -95,6 +96,7 @@ class DeviceInfo {
// Repeat mode (firmware v9+)
final bool? clientRepeat;
final int? pathHashMode;
final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz;
@@ -123,6 +125,7 @@ class DeviceInfo {
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.autoAddMaxHops,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -139,6 +142,7 @@ class DeviceInfo {
this.manufacturerModel,
this.semanticVersion,
this.clientRepeat,
this.pathHashMode,
this.supportsSpectrumScan,
this.spectrumScanMinKhz,
this.spectrumScanMaxKhz,
@@ -254,6 +258,7 @@ class DeviceInfo {
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? autoAddMaxHops,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -270,6 +275,7 @@ class DeviceInfo {
String? manufacturerModel,
String? semanticVersion,
bool? clientRepeat,
int? pathHashMode,
bool? supportsSpectrumScan,
int? spectrumScanMinKhz,
int? spectrumScanMaxKhz,
@@ -299,6 +305,7 @@ class DeviceInfo {
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,
@@ -315,6 +322,7 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat,
pathHashMode: pathHashMode ?? this.pathHashMode,
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,

View File

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

View File

@@ -2,7 +2,8 @@ import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
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 'connection_provider.dart';
import 'contacts_provider.dart';
@@ -107,6 +108,15 @@ class _PendingRepeaterOwnerRequest {
const _PendingRepeaterOwnerRequest({required this.publicKey});
}
enum ContactsTabSection {
favourites,
teamMembers,
repeaters,
sensors,
rooms,
channels,
}
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3;
@@ -162,6 +172,9 @@ class AppProvider with ChangeNotifier {
bool get isContactsEnabled => _isContactsEnabled;
bool _isSensorsEnabled = true;
bool get isSensorsEnabled => _isSensorsEnabled;
final Map<ContactsTabSection, bool> _contactsSectionVisibility = {
for (final section in ContactsTabSection.values) section: true,
};
bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
@@ -199,6 +212,8 @@ class AppProvider with ChangeNotifier {
static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {};
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, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {};
@@ -238,6 +253,7 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking();
_loadMapEnabled();
_loadContactsEnabled();
_loadContactsSectionVisibility();
_loadSensorsEnabled();
_loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled();
@@ -259,6 +275,27 @@ class AppProvider with ChangeNotifier {
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() {
_packetCaptureFlushTimer?.cancel();
_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
Future<void> _loadSensorsEnabled() async {
try {
@@ -933,9 +1001,7 @@ class AppProvider with ChangeNotifier {
// we also import into firmware so subsequent getContact calls work.
// Matches the official app which calls cmdGetAdvertPath for all adverts.
if (source == ContactReceiveSource.advert) {
unawaited(
connectionProvider.importReceivedAdvert(contact.publicKey),
);
unawaited(connectionProvider.importReceivedAdvert(contact.publicKey));
}
final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertContact(
@@ -1134,16 +1200,31 @@ class AppProvider with ChangeNotifier {
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;
Contact? senderContact;
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
final contact = contactsProvider.findContactByKey(
message.senderPublicKeyPrefix!,
);
if (contact != null) {
senderContact = contact;
enrichedMessage = message.copyWith(senderName: contact.advName);
if (message.senderName == null) {
// Try room post author first (the actual person who posted)
if (message.roomPostAuthorPrefix != null) {
final author = contactsProvider.findContactByPrefix(
message.roomPostAuthorPrefix!,
);
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
@@ -1237,6 +1318,7 @@ class AppProvider with ChangeNotifier {
.toLowerCase();
}
voiceProvider.registerEnvelope(voiceEnvelope);
_replayPendingChannelVoicePackets(voiceEnvelope.sessionId);
enrichedMessage = enrichedMessage.copyWith(
isVoice: true,
voiceId: voiceEnvelope.sessionId,
@@ -1274,6 +1356,7 @@ class AppProvider with ChangeNotifier {
.toLowerCase();
}
imageProvider.registerEnvelope(imageEnvelope);
_replayPendingChannelImageFragments(imageEnvelope.sessionId);
messagesProvider.addMessage(
enrichedMessage,
contactLookup: (name) {
@@ -1337,9 +1420,7 @@ class AppProvider with ChangeNotifier {
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
return;
}
debugPrint(
'📊 [AppProvider] Binary response (0x8C tag=$tag) received',
);
debugPrint('📊 [AppProvider] Binary response (0x8C tag=$tag) received');
// Binary responses carry Cayenne LPP telemetry data.
// The data starts with a channel byte — valid LPP always has at least
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
@@ -1393,15 +1474,7 @@ class AppProvider with ChangeNotifier {
return;
}
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(
sender.publicKey.sublist(0, 6),
fastGpsPacket,
);
}
if (_handleFastGpsPayload(payload)) {
return;
}
@@ -1538,66 +1611,44 @@ class AppProvider with ChangeNotifier {
return;
}
if (ImagePacket.isImageBinary(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),
);
if (_handleIncomingImageBinaryPayload(payload)) {
return;
}
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);
_handleIncomingVoiceBinaryPayload(payload);
};
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 =
(payload, snrRaw, rssiDbm, pathLen) {
_handleControlDataDiscovery(
@@ -1710,6 +1761,7 @@ class AppProvider with ChangeNotifier {
return _prepareDirectMessageSend(
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
};
@@ -1779,23 +1831,47 @@ class AppProvider with ChangeNotifier {
Future<Contact> _prepareDirectMessageSend({
required String messageId,
required Contact contact,
required int retryAttempt,
}) async {
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId];
if (session == null) {
final selection = await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
final selection = latestContact.routeHasPath && latestContact.routeHopCount > 0
? PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession(
currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact),
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(
latestContact,
session.currentSelection,
@@ -1806,6 +1882,43 @@ class AppProvider with ChangeNotifier {
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(
Contact contact,
PathSelection selection, {
@@ -1980,6 +2093,10 @@ class AppProvider with ChangeNotifier {
session.currentSelection,
success: true,
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.
// Nothing else to do — the callback pipeline handles everything.
if (wasKnown) {
debugPrint(
' [pushAdvert] Existing contact refreshed',
);
debugPrint(' [pushAdvert] Existing contact refreshed');
}
}
@@ -2479,12 +2594,10 @@ class AppProvider with ChangeNotifier {
try {
_isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self
// 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(
await contactsProvider.prepareForDeviceContactSync(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
channelsProvider.prepareForDeviceSync();
// Note: Device clock is automatically synced during connection in MeshCoreBleService
// No need to sync it again here
@@ -2560,10 +2673,14 @@ class AppProvider with ChangeNotifier {
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
);
await contactsProvider.initialize(
await contactsProvider.prepareForDeviceContactSync(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
channelsProvider.prepareForDeviceSync();
await connectionProvider.getContacts();
await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels,
);
final messageCount = await connectionProvider.syncAllMessages(
force: true,
@@ -2706,6 +2823,7 @@ class AppProvider with ChangeNotifier {
await Future.wait([
_loadMapEnabled(),
_loadContactsEnabled(),
_loadContactsSectionVisibility(),
_loadSensorsEnabled(),
_loadVoiceSilenceTrimmingEnabled(),
_loadVoiceBandPassFilterEnabled(),
@@ -2728,6 +2846,23 @@ class AppProvider with ChangeNotifier {
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;
if (publicKey == null || publicKey.length < 6) {
return;
@@ -2745,10 +2880,14 @@ class AppProvider with ChangeNotifier {
);
debugPrint(
'📍 [AppProvider] Sending fast GPS update ($reason): '
'${position.latitude}, ${position.longitude}',
'${position.latitude}, ${position.longitude} via channel $channelIdx',
);
try {
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary());
await connectionProvider.sendChannelData(
channelIdx: channelIdx,
dataType: MeshCoreConstants.dataTypeDev,
payload: packet.encodeBinary(),
);
} catch (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.
///
/// Binary voice packets arrive without a chat message, so we synthesise one
@@ -3567,6 +3873,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.getContacts();
// Sync all channels so refresh reflects the full device state.
channelsProvider.prepareForDeviceSync();
await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels,
);
@@ -3689,6 +3996,8 @@ class AppProvider with ChangeNotifier {
_imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear();
_pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear();
_lowBatteryNotifiedNodeIds.clear();
notifyListeners();
}
@@ -3798,6 +4107,8 @@ class AppProvider with ChangeNotifier {
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
_pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear();
super.dispose();
}
}

View File

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

View File

@@ -73,6 +73,7 @@ class ConnectionProvider with ChangeNotifier {
final MeshCoreBleService _bleService = MeshCoreBleService();
MeshCoreTcpService? _tcpService;
MeshCoreSerialService? _serialService;
Future<void> Function()? _serialDisconnectTransport;
/// Expose BLE service for background location tracking
MeshCoreBleService get bleService => _bleService;
@@ -104,8 +105,6 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false;
bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
final Set<int> _pendingDeletedChannelIndices = <int>{};
String? _error;
@@ -211,6 +210,15 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
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)?
onControlDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
@@ -246,7 +254,7 @@ class ConnectionProvider with ChangeNotifier {
}
if (activeMode != ConnectionMode.usb && _serialService != null) {
_disposeSerialService();
await _disposeSerialService();
}
}
@@ -260,10 +268,20 @@ class ConnectionProvider with ChangeNotifier {
service.dispose();
}
void _disposeSerialService() {
_serialService?.markDisconnected();
_serialService?.dispose();
Future<void> _disposeSerialService() async {
final service = _serialService;
final disconnectTransport = _serialDisconnectTransport;
_serialService = null;
_serialDisconnectTransport = null;
if (service == null) {
return;
}
service.markDisconnected();
service.writeRaw = null;
if (disconnectTransport != null) {
await disconnectTransport();
}
service.dispose();
}
void _beginConnectionAttempt({
@@ -452,10 +470,6 @@ class ConnectionProvider with ChangeNotifier {
};
service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
@@ -522,6 +536,16 @@ class ConnectionProvider with ChangeNotifier {
service.onRawDataReceived = (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) =>
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
@@ -536,10 +560,15 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
pathHashMode: deviceInfo['pathHashMode'] as int?,
);
// Clear any stale flood scope on connect (firmware v8+)
final fwVer = deviceInfo['firmwareVersion'] as int?;
if (fwVer != null && fwVer >= 8) {
clearFloodScope();
}
notifyListeners();
};
@@ -552,6 +581,9 @@ class ConnectionProvider with ChangeNotifier {
publicKey: selfInfo['publicKey'] as Uint8List?,
advLat: selfInfo['advLat'] 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?,
radioFreq: selfInfo['radioFreq'] as int?,
radioBw: selfInfo['radioBw'] as int?,
@@ -594,6 +626,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
);
notifyListeners();
};
@@ -635,6 +668,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
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(
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
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
@@ -762,16 +849,22 @@ class ConnectionProvider with ChangeNotifier {
/// The caller is responsible for opening the serial port and wiring
/// [service.writeRaw] + [service.feedRawBytes] before calling this.
/// 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()');
await _prepareForConnectionSwitch(ConnectionMode.usb);
_disposeSerialService();
await _disposeSerialService();
_serialService = service;
_serialDisconnectTransport = disconnectTransport;
_wireServiceCallbacks(_serialService!);
_beginConnectionAttempt(
mode: ConnectionMode.usb,
deviceId: 'usb',
deviceName: 'USB Companion',
deviceId: deviceId,
deviceName: deviceName,
);
// markConnected() should already have been called by the transport.
@@ -780,6 +873,7 @@ class ConnectionProvider with ChangeNotifier {
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
await _disposeSerialService();
notifyListeners();
return false;
}
@@ -788,7 +882,7 @@ class ConnectionProvider with ChangeNotifier {
/// Disconnect from USB serial device.
Future<void> disconnectSerial() async {
_disposeSerialService();
await _disposeSerialService();
_resetConnectionSession();
}
@@ -1647,6 +1741,7 @@ class ConnectionProvider with ChangeNotifier {
required int channelIdx,
required String text,
String? messageId,
Uint8List? floodScopeKey,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1659,10 +1754,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Channel: $channelIdx');
debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId');
debugPrint(' FloodScope: ${floodScopeKey != null ? "set (${floodScopeKey.length}B)" : "none"}');
await _activeService.sendChannelMessage(
channelIdx: channelIdx,
text: text,
floodScopeKey: floodScopeKey,
);
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
///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -1781,11 +1914,13 @@ class ConnectionProvider with ChangeNotifier {
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
final future = _isSelfPublicKey(contactPublicKey)
? _runSelfTelemetryPing(contactPublicKey)
: _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
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) {
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)
///
/// 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
Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) {
@@ -2220,6 +2361,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
);
notifyListeners();
} catch (e) {
@@ -2231,6 +2373,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: null,
autoAddSensors: null,
autoAddOverwriteOldest: null,
autoAddMaxHops: null,
);
notifyListeners();
return;
@@ -2250,6 +2393,7 @@ class ConnectionProvider with ChangeNotifier {
required bool autoAddRoomServers,
required bool autoAddSensors,
required bool overwriteOldest,
int maxHops = 0,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -2264,6 +2408,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest,
maxHops: maxHops,
);
_deviceInfo = _deviceInfo.copyWith(
autoAddUsers: autoAddUsers,
@@ -2271,6 +2416,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
autoAddOverwriteOldest: overwriteOldest,
autoAddMaxHops: maxHops,
);
notifyListeners();
} 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.
/// Pass null to export self.
Future<String?> exportContactUrl(Uint8List? publicKey) async {
@@ -2320,7 +2483,6 @@ class ConnectionProvider with ChangeNotifier {
}
Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
@@ -2363,6 +2525,18 @@ class ConnectionProvider with ChangeNotifier {
/// - Total storage in KB (if available)
///
/// 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 {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -2381,7 +2555,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue
/// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) {
// Another sync (single or loop) is in progress
@@ -2420,10 +2593,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device
Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
@@ -2753,6 +2922,12 @@ class ConnectionProvider with ChangeNotifier {
_rxActivityTimer?.cancel();
_txActivityTimer?.cancel();
_stopAckCleanupTimer();
final disconnectTransport = _serialDisconnectTransport;
if (disconnectTransport != null) {
unawaited(disconnectTransport());
_serialDisconnectTransport = null;
}
_serialService?.dispose();
_bleService.dispose();
_tcpService?.dispose();
super.dispose();

View File

@@ -7,6 +7,7 @@ import '../models/contact_group.dart';
import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
import '../services/profiles_feature_service.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/rssi_location_estimator.dart';
import '../utils/key_comparison.dart';
@@ -126,11 +127,13 @@ class _RetainedRoute {
class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0;
static const String autoGroupIdPrefix = 'auto_group_';
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final Map<String, LatLng> _estimatedLocations = {};
final Map<String, List<RssiObservation>> _rssiObservations = {};
final Map<String, Contact> _retainedContactsForSync = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
bool _isPersisting = false;
@@ -138,9 +141,12 @@ class ContactsProvider with ChangeNotifier {
bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false;
String? _storageNamespace;
String? _selfPublicKeyHex;
ContactTelemetry? _selfTelemetry;
// Add default public channel on initialization
ContactsProvider() {
ContactsProvider()
: _storageNamespace = ProfileStorageScope.effectiveNamespace {
_ensurePublicChannelExists();
}
@@ -160,6 +166,7 @@ class ContactsProvider with ChangeNotifier {
Uint8List? devicePublicKey,
}) async {
_storageNamespace = namespace;
_setSelfDevicePublicKey(devicePublicKey);
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
}
@@ -251,6 +258,7 @@ class ContactsProvider with ChangeNotifier {
/// Initialize and load persisted contacts
/// [devicePublicKey] - device's own public key to exclude from loaded contacts
Future<void> initialize({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey);
if (_isInitialized) {
// If already initialized (from early load), just filter out self-contact
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)
void _removeSelfContact(Uint8List devicePublicKey) {
final selfKeyHex = devicePublicKey
@@ -345,6 +389,7 @@ class ContactsProvider with ChangeNotifier {
}
List<Contact> get contacts => _contacts.values.toList();
ContactTelemetry? get selfTelemetry => _selfTelemetry;
List<Contact> get favouriteContacts =>
_contacts.values.where((c) => c.isFavourite).toList();
List<SavedContactGroup> get savedContactGroups =>
@@ -535,9 +580,11 @@ class ContactsProvider with ChangeNotifier {
// Replace existing observation from the same repeater, or add new
final repeaterKey =
'${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}';
observations.removeWhere((o) =>
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
repeaterKey);
observations.removeWhere(
(o) =>
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
repeaterKey,
);
observations.add(observation);
// Keep at most 8 observations (most recent per repeater)
@@ -637,7 +684,9 @@ class ContactsProvider with ChangeNotifier {
}
// Check if this is a new contact
final existingContact = _contacts[contact.publicKeyHex];
final existingContact =
_contacts[contact.publicKeyHex] ??
_retainedContactsForSync[contact.publicKeyHex];
final isNewContact = existingContact == null;
debugPrint(
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
@@ -649,6 +698,7 @@ class ContactsProvider with ChangeNotifier {
);
_contacts[contact.publicKeyHex] = updatedContact;
_retainedContactsForSync.remove(contact.publicKeyHex);
// Keep repeaters and sensors in pending adverts so they remain visible
// in the discovery list (with a checkmark). Remove others.
if (contact.type != ContactType.repeater &&
@@ -678,11 +728,14 @@ class ContactsProvider with ChangeNotifier {
excluded++;
continue;
}
final existingContact = _contacts[contact.publicKeyHex];
final existingContact =
_contacts[contact.publicKeyHex] ??
_retainedContactsForSync[contact.publicKeyHex];
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
incomingContact: contact,
existingContact: existingContact,
);
_retainedContactsForSync.remove(contact.publicKeyHex);
if (contact.type != ContactType.repeater &&
contact.type != ContactType.sensor) {
_pendingAdverts.remove(contact.publicKeyHex);
@@ -954,17 +1007,29 @@ class ContactsProvider with ChangeNotifier {
// Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) {
final isSelfTelemetry =
contact == null && _matchesSelfPrefix(publicKeyPrefix);
if (contact == null && !isSelfTelemetry) {
debugPrint(' ❌ Contact not found for this prefix');
return;
}
debugPrint(' ✅ Found contact: ${contact.advName}');
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}');
if (isSelfTelemetry) {
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 {
// Parse Cayenne LPP data
var telemetry = CayenneLppParser.parse(lppData);
var telemetry = _withRawTelemetryHex(
CayenneLppParser.parse(lppData),
lppData,
);
debugPrint(' ✅ Parsed new telemetry');
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(
existingTelemetry: previousTelemetry,
@@ -1006,29 +1073,38 @@ class ContactsProvider with ChangeNotifier {
timestamp: mergedTelemetry.timestamp,
humidity: mergedTelemetry.humidity,
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
// lastAdvert is Unix timestamp in seconds
final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000)
.round();
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
debugPrint(' Old lastAdvert: ${resolvedContact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp');
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
final updatedContact = contact.copyWith(
final updatedContact = resolvedContact.copyWith(
telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time
advLat: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
: contact.advLat,
: resolvedContact.advLat,
advLon: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
: contact.advLon,
: resolvedContact.advLon,
);
_contacts[contact.publicKeyHex] = updatedContact;
_contacts[resolvedContact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
_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>? existingExtraSensorData,
Map<String, dynamic>? incomingExtraSensorData,
@@ -1195,10 +1293,11 @@ class ContactsProvider with ChangeNotifier {
/// Find contact by public key prefix (6 bytes)
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
.sublist(0, 6)
.sublist(0, takeLen)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
@@ -1234,6 +1333,36 @@ class ContactsProvider with ChangeNotifier {
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
/// prefer flood routing until the radio reports a fresh route.
void markPathUnhealthy(Uint8List publicKey) {
@@ -1531,6 +1660,7 @@ class ContactsProvider with ChangeNotifier {
void clearContacts() {
_contacts.clear();
_pendingAdverts.clear();
_retainedContactsForSync.clear();
_ensurePublicChannelExists();
_persistContacts();
_persistPendingAdverts();
@@ -1555,6 +1685,7 @@ class ContactsProvider with ChangeNotifier {
// Then remove from local storage
_contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex);
_retainedContactsForSync.remove(publicKeyHex);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
@@ -1637,6 +1768,10 @@ class ContactsProvider with ChangeNotifier {
_contacts.clear();
_savedContactGroups.clear();
_pendingAdverts.clear();
_estimatedLocations.clear();
_rssiObservations.clear();
_retainedContactsForSync.clear();
_selfTelemetry = null;
}
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {

View File

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

View File

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

View File

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

View File

@@ -737,8 +737,15 @@ class MessagesProvider with ChangeNotifier {
if (message.isContactMessage) {
// Match by sender key + sender timestamp (matches official app's DB
// uniqueness: contactPublicKey + senderTimestamp + text + txtType).
return existing.senderKeyShort == message.senderKeyShort &&
existing.senderTimestamp == message.senderTimestamp;
if (existing.senderKeyShort == message.senderKeyShort &&
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) {
@@ -760,29 +767,29 @@ class MessagesProvider with ChangeNotifier {
return false;
}
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
if (existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
return true;
}
return false;
return _matchesDuplicateSenderIdentity(existing, message);
}
// System messages and other types: never deduplicate by scope alone.
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
void addMessages(List<Message> messages) {
int addedCount = 0;
@@ -2347,17 +2354,6 @@ class MessagesProvider with ChangeNotifier {
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) {
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,

View File

@@ -14,6 +14,7 @@ enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1);
static const int selfAutoRefreshMinutes = 1;
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans';
@@ -373,6 +374,20 @@ class SensorsProvider with ChangeNotifier {
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(
String publicKeyHex,
String fieldKey,
@@ -474,6 +489,28 @@ class SensorsProvider with ChangeNotifier {
bool isWatched(String 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 {
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
return;
@@ -549,22 +586,117 @@ class SensorsProvider with ChangeNotifier {
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>[
...contactsProvider.chatContacts,
...contactsProvider.repeaters,
...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.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
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({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) 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;
}
@@ -572,7 +704,7 @@ class SensorsProvider with ChangeNotifier {
notifyListeners();
try {
for (final key in _watchedSensorKeys) {
for (final key in keysToRefresh) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
@@ -597,13 +729,11 @@ class SensorsProvider with ChangeNotifier {
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
contact = entry;
break;
}
}
final contact = contactForDisplay(
publicKeyHex,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
if (contact == null) {
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
@@ -635,7 +765,17 @@ class SensorsProvider with ChangeNotifier {
}
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) {
return;
}
@@ -686,4 +826,29 @@ class SensorsProvider with ChangeNotifier {
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners();
}
Contact? _buildSelfCandidate(
ConnectionProvider connectionProvider, {
ContactTelemetry? telemetry,
}) {
final deviceInfo = connectionProvider.deviceInfo;
final selfKey = deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) {
return null;
}
return Contact(
publicKey: Uint8List.fromList(selfKey),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: deviceInfo.selfName ?? 'My Device',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: deviceInfo.advLat ?? 0,
advLon: deviceInfo.advLon ?? 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: telemetry,
);
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,12 @@ import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryMenuAction { repeaters, sensors }
enum _DiscoveryListFilter { all, repeaters, sensors, others }
class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key});
final bool autoDiscoverRepeatersOnOpen;
const DiscoveryScreen({super.key, this.autoDiscoverRepeatersOnOpen = false});
@override
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
@@ -23,7 +25,11 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
static const int _sensorAdvertType = 4;
final Set<String> _resolvingAdvertKeys = <String>{};
final Set<int> _runningDiscoveryTypes = <int>{};
final TextEditingController _searchController = TextEditingController();
bool _isResolvingAll = false;
bool _hasQueuedAutoRepeaterDiscovery = false;
String _searchQuery = '';
_DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all;
late final Future<List<MeshMapNode>> _cachedNodesFuture;
@override
@@ -32,17 +38,19 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
if (widget.autoDiscoverRepeatersOnOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_autoDiscoverRepeaters();
});
}
}
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
switch (action) {
case _DiscoveryMenuAction.repeaters:
await _discoverNodeType(_repeaterAdvertType);
break;
case _DiscoveryMenuAction.sensors:
await _discoverNodeType(_sensorAdvertType);
break;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _clearAllDiscoveries() async {
@@ -80,7 +88,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return;
}
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,
);
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent',
_sensorAdvertType => 'Sensor discovery sent',
_repeaterAdvertType => l10n.repeaterDiscoverySent,
_sensorAdvertType => l10n.sensorDiscoverySent,
_ => 'Discovery sent',
};
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 {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
@@ -293,8 +312,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
};
}
String? _typeLabelForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
String? _typeLabelForValue(int? typeValue) {
return switch (typeValue) {
_repeaterAdvertType => 'Repeater',
_sensorAdvertType => 'Sensor',
3 => 'Room',
@@ -303,22 +322,22 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
};
}
String? _resolvedTypeLabelForAdvert(
int? _resolvedTypeValueForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
final directType = _typeLabelForAdvert(advert);
if (directType != null) {
final directType = advert.typeValue;
if (directType != null && directType != 0) {
return directType;
}
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
return switch (node.type) {
1 => 'Repeater',
4 => 'Sensor',
3 => 'Room',
2 => 'Chat',
1 => _repeaterAdvertType,
4 => _sensorAdvertType,
3 => 3,
2 => 1,
_ => null,
};
}
@@ -327,6 +346,81 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
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(
BuildContext context, {
required String displayName,
@@ -362,193 +456,236 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
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),
],
),
),
],
);
},
),
],
),
appBar: AppBar(title: Text(l10n.discovery)),
body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture,
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
builder: (context, nodesSnapshot) =>
Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
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
final resolvedKeySet = <String>{};
for (final advert in pendingAdverts) {
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
resolvedKeySet.add(advert.publicKeyHex);
}
}
// Track which pending adverts are already in the contacts list
final resolvedKeySet = <String>{};
for (final advert in pendingAdverts) {
if (contactsProvider.findContactByKey(advert.publicKey) !=
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(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.person_search),
const SizedBox(width: 12),
Expanded(
child: Text(
'Discovered nodes ($totalCount)',
style: Theme.of(context).textTheme.titleMedium,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.person_search),
const SizedBox(width: 12),
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.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
Row(
),
),
const SizedBox(height: 16),
if (totalCount == 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Expanded(
child: OutlinedButton.icon(
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),
),
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: Icon(Icons.clear_all_rounded),
label: Text(l10n.clearAllLabel),
),
const SizedBox(height: 16),
Text(
'No discovered nodes',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'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,
}) {
final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex);
final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes);
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes);
final downMetric = SignalMetric.fromValues(
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(
BuildContext context, {
SignalMetric? downMetric,

View File

@@ -8,7 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart';
import '../providers/connection_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/contacts_provider.dart';
import '../theme/app_theme.dart';
@@ -23,13 +23,11 @@ import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'profiles_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/profile_device_key_resolver.dart';
import '../services/profile_manager.dart';
@@ -68,7 +66,6 @@ class _HomeScreenState extends State<HomeScreen>
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
@@ -107,7 +104,6 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed
@@ -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() {
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}) {
final inactive = Colors.grey.withValues(alpha: 0.3);
return Row(
@@ -592,12 +737,10 @@ class _HomeScreenState extends State<HomeScreen>
}
}
void _showConnectionDialog(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
Future<void> _showConnectionDialog(BuildContext context) async {
await showConnectionDialogFlow(
context,
backgroundColor: Colors.transparent,
builder: (context) => const ConnectionDialog(),
);
}
@@ -654,32 +797,6 @@ class _HomeScreenState extends State<HomeScreen>
.read<ProfileManager>()
.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(
PopupMenuItem(
child: Row(
@@ -797,7 +914,6 @@ class _HomeScreenState extends State<HomeScreen>
),
);
_loadRxTxPreference();
_loadDeveloperModePreference();
});
},
),
@@ -1061,53 +1177,57 @@ class _HomeScreenState extends State<HomeScreen>
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
GestureDetector(
onTap: () =>
_showDeviceInfoSheet(context, deviceInfo),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
],
],
),
),
],
),

View File

@@ -27,7 +27,6 @@ T? _maybeProvider<T>(BuildContext context) {
class LiveTrafficScreen extends StatefulWidget {
final List<BlePacketLog> Function() logReader;
final int Function()? rxCountReader;
final Listenable? refreshListenable;
final DateTime Function() now;
final VoidCallback? openPacketLogs;
@@ -35,7 +34,6 @@ class LiveTrafficScreen extends StatefulWidget {
const LiveTrafficScreen({
super.key,
required this.logReader,
this.rxCountReader,
this.refreshListenable,
DateTime Function()? now,
this.openPacketLogs,
@@ -49,7 +47,6 @@ class LiveTrafficScreen extends StatefulWidget {
return LiveTrafficScreen(
key: key,
logReader: () => provider.bleService.packetLogs,
rxCountReader: () => provider.rxPacketCount,
refreshListenable: provider,
openPacketLogs: openPacketLogs,
);
@@ -118,15 +115,17 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
@override
Widget build(BuildContext context) {
final logs = widget.logReader();
final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs);
final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
widget.logReader(),
logs,
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
window: _selectedWindow,
);
final snapshot = LiveTrafficSummary.fromLogs(
widget.logReader(),
logs,
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
@@ -177,7 +176,7 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
children: [
_SummaryPanel(
snapshot: snapshot,
totalRxCount: widget.rxCountReader?.call(),
totalRxCount: totalRxDataCount,
routeHashCounts: routeHashCounts,
onWindowTap: () => _showWindowPicker(context),
),
@@ -306,8 +305,48 @@ class _SummaryPanel extends StatelessWidget {
LiveTrafficBusyness.active => 'Active',
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(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -346,44 +385,26 @@ class _SummaryPanel extends StatelessWidget {
],
),
const SizedBox(height: 14),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_MetricTile(
label: AppLocalizations.of(context)!.rxPackets,
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
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(
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',
),
],
LayoutBuilder(
builder: (context, constraints) {
const spacing = 10.0;
const minTileWidth = 150.0;
final columns =
((constraints.maxWidth + spacing) / (minTileWidth + spacing))
.floor()
.clamp(1, metrics.length);
final tileWidth =
(constraints.maxWidth - (spacing * (columns - 1))) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final metric in metrics)
SizedBox(width: tileWidth, child: metric),
],
);
},
),
],
),
@@ -525,12 +546,14 @@ String _windowSummaryLabel(Duration duration) {
}
class _MetricTile extends StatelessWidget {
final Key? tileKey;
final String label;
final String value;
final String subtitle;
final String? footer;
const _MetricTile({
this.tileKey,
required this.label,
required this.value,
required this.subtitle,
@@ -540,7 +563,7 @@ class _MetricTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 160,
key: tileKey,
height: 136,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -949,7 +972,6 @@ class _LiveTrafficCard extends StatelessWidget {
),
);
}
}
class _LiveTrafficPacketDetails {
@@ -1105,7 +1127,6 @@ class _PacketInfoLine extends StatelessWidget {
}
}
class _GeoPoint {
final double latitude;
final double longitude;

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
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';
import 'package:latlong2/latlong.dart';
@@ -10,6 +11,7 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/compass_support.dart';
import '../utils/slovenian_crs.dart';
import '../providers/contacts_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/map_marker_service.dart';
import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart';
import '../services/profiles_feature_service.dart';
import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart';
@@ -201,46 +202,64 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
}
void _startCompassTracking() {
if (!CompassSupport.isAvailable) {
return;
}
final compassStream = FlutterCompass.events;
if (compassStream == null) {
return;
}
// Start listening to compass events
_compassStreamSubscription = compassStream.listen((CompassEvent event) {
// Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
_compassStreamSubscription = compassStream.listen(
(CompassEvent event) {
// Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
try {
setState(() {
_compassHeading = event.heading;
});
try {
setState(() {
_compassHeading = event.heading;
});
// Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready
if (_rotateMarkerWithHeading &&
event.heading != null &&
_isMapReady &&
!_isDisposing) {
try {
// Use moveAndRotate to set absolute rotation
final camera = _mapController.camera;
_mapController.moveAndRotate(
camera.center,
camera.zoom,
-event.heading!,
);
} catch (e) {
// Map not ready yet, ignore
// Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready
if (_rotateMarkerWithHeading &&
event.heading != null &&
_isMapReady &&
!_isDisposing) {
try {
// Use moveAndRotate to set absolute rotation
final camera = _mapController.camera;
_mapController.moveAndRotate(
camera.center,
camera.zoom,
-event.heading!,
);
} catch (e) {
// 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
if (!_isDisposing) {
debugPrint('Compass tracking error: $e');
},
onError: (Object error, StackTrace stackTrace) {
if (_isDisposing) {
return;
}
}
});
if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass tracking stream error: $error');
},
);
}
Future<void> _loadSettings() async {
@@ -873,7 +892,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!hasCustomMap)
ListTile(
leading: Icon(Icons.add_photo_alternate),
title: Text(AppLocalizations.of(context)!.loadFromGallery),
title: Text(
AppLocalizations.of(context)!.loadFromGallery,
),
subtitle: const Text(
'Use a cave map image instead of GPS tiles',
),
@@ -905,7 +926,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
ListTile(
leading: Icon(Icons.swap_horizontal_circle),
title: Text(AppLocalizations.of(context)!.replaceImage),
title: Text(
AppLocalizations.of(context)!.replaceImage,
),
subtitle: const Text(
'Pick a different map from the gallery',
),
@@ -941,7 +964,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (customMapConfig.isCalibrated)
ListTile(
leading: Icon(Icons.clear),
title: Text(AppLocalizations.of(context)!.clearScale),
title: Text(
AppLocalizations.of(context)!.clearScale,
),
onTap: () async {
await mapProvider.clearCustomMapCalibration();
if (!context.mounted) return;
@@ -999,7 +1024,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading,
initialHeading: _compassHeading,
contacts: contacts,
sarMarkers: sarMarkers,
),
@@ -1025,7 +1050,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading,
initialHeading: _compassHeading,
contacts: contacts,
sarMarkers: sarMarkers,
preSelectedContact: selectedContact,
@@ -1277,9 +1302,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
_stopCustomMapCalibration();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.customMapScaleSaved)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.customMapScaleSaved),
),
);
}
}
@@ -1419,7 +1446,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.chat_bubble_outline),
title: Text(AppLocalizations.of(context)!.openMessage),
subtitle: Text(AppLocalizations.of(context)!.jumpToTheRelatedSarMessage),
subtitle: Text(
AppLocalizations.of(context)!.jumpToTheRelatedSarMessage,
),
onTap: () async {
Navigator.pop(sheetContext);
await _openSarMarkerMessage(
@@ -1655,7 +1684,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.pleaseSelectADestinationToSendSarMarker),
content: Text(
AppLocalizations.of(
context,
)!.pleaseSelectADestinationToSendSarMarker,
),
backgroundColor: Colors.red,
),
);
@@ -1785,7 +1818,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.sarMarkerBroadcastToPublicChannel),
content: Text(
AppLocalizations.of(context)!.sarMarkerBroadcastToPublicChannel,
),
backgroundColor: Colors.orange,
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)
const LocationTrailLayer(),
],
@@ -2604,7 +2593,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
context: context,
mapRotation: _getMapRotation(),
userPosition: _locationService.currentPosition,
estimatedLocations: contactsProvider.estimatedLocations,
estimatedLocations:
contactsProvider.estimatedLocations,
onTap: (contact) {
_showDetailedCompassWithContact(
context,

View File

@@ -6,6 +6,7 @@ import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_client/meshcore_client.dart' show MeshCoreConstants;
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../providers/messages_provider.dart';
@@ -28,12 +29,14 @@ import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart';
import '../utils/contact_sorting.dart';
import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../utils/tictactoe_message_parser.dart';
import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart';
import '../services/image_preferences.dart';
import '../services/region_scope_preferences.dart';
import 'package:image_picker/image_picker.dart';
import '../l10n/app_localizations.dart';
@@ -47,6 +50,86 @@ class MessagesTab extends StatefulWidget {
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> {
static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127;
@@ -71,6 +154,11 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient;
// Region scope state
String? _channelRegionScopeName;
Uint8List? _channelRegionScopeKey;
Map<int, String> _channelRegionScopes = {};
// Image sending state
bool _isSendingImage = false;
final ImagePicker _imagePicker = ImagePicker();
@@ -98,6 +186,7 @@ class _MessagesTabState extends State<MessagesTab> {
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings();
_loadAllChannelRegionScopes();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest();
});
@@ -299,27 +388,27 @@ class _MessagesTabState extends State<MessagesTab> {
final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase();
final contacts =
contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(
normalizedQuery,
);
})
.toList()
..sort((a, b) {
final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase();
final aStarts =
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
final bStarts =
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
if (aStarts != bStarts) {
return aStarts ? -1 : 1;
}
return aName.compareTo(bName);
});
contactsProvider.chatContacts.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(normalizedQuery);
}).toList()..sort((a, b) {
final primary = compareContactsByFavouriteThenLastSeen(a, b);
if (primary != 0) {
return primary;
}
final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase();
final aStarts =
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
final bStarts =
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
if (aStarts != bStarts) {
return aStarts ? -1 : 1;
}
return compareContactsByDisplayName(a, b);
});
return contacts.take(8).toList(growable: false);
}
@@ -401,6 +490,9 @@ class _MessagesTabState extends State<MessagesTab> {
}
_enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope();
}
/// Show recipient selector bottom sheet
@@ -408,16 +500,33 @@ class _MessagesTabState extends State<MessagesTab> {
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Filter contacts by type
final contacts = contactsProvider.contacts
.where((c) => c.type == ContactType.chat)
.toList();
final rooms = contactsProvider.contacts
.where((c) => c.type == ContactType.room)
.toList();
final channels = contactsProvider.contacts
.where((c) => c.type == ContactType.channel)
.toList();
final contacts = List<Contact>.from(contactsProvider.chatContacts)
..sort((a, b) {
final primary = compareContactsByFavouriteThenLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
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(
context: context,
@@ -437,6 +546,7 @@ class _MessagesTabState extends State<MessagesTab> {
currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected,
channelRegionScopes: _channelRegionScopes,
),
);
}
@@ -452,6 +562,9 @@ class _MessagesTabState extends State<MessagesTab> {
_enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope();
// Save to preferences
await MessageDestinationPreferences.setDestination(
type,
@@ -462,6 +575,51 @@ class _MessagesTabState extends State<MessagesTab> {
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({
required String type,
String? recipientPublicKeyHex,
@@ -534,7 +692,10 @@ class _MessagesTabState extends State<MessagesTab> {
}).firstOrNull;
if (recipient == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplyContactNotFound,
);
return;
}
}
@@ -563,13 +724,19 @@ class _MessagesTabState extends State<MessagesTab> {
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplySenderMissing);
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplySenderMissing,
);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotReplyContactNotFound,
);
return;
}
@@ -640,7 +807,6 @@ class _MessagesTabState extends State<MessagesTab> {
}
_textController.clear();
_focusNode.unfocus();
try {
// Check destination type and send accordingly
@@ -720,11 +886,12 @@ class _MessagesTabState extends State<MessagesTab> {
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send to selected channel
// Send to selected channel (with region scope if set)
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
);
}
@@ -834,6 +1001,7 @@ class _MessagesTabState extends State<MessagesTab> {
if (!shouldContinue) return;
if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
return;
@@ -845,6 +1013,7 @@ class _MessagesTabState extends State<MessagesTab> {
final rawBytes = await picked.readAsBytes();
setState(() => _isSendingImage = true);
String? failedMessageId;
try {
// Compress AVIF using user-selected size, compression and color mode.
final maxSize = await ImagePreferences.getMaxSize();
@@ -922,8 +1091,8 @@ class _MessagesTabState extends State<MessagesTab> {
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
// Add local placeholder message.
final messagesProvider = context.read<MessagesProvider>();
final msgId = 'img_${sessionId}_sent';
failedMessageId = msgId;
final isChannel =
_destinationType ==
MessageDestinationPreferences.destinationTypeChannel;
@@ -954,6 +1123,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0,
text: envelopeText,
messageId: msgId,
floodScopeKey: _channelRegionScopeKey,
);
} else if (recipient != null) {
final sent = await connectionProvider.sendTextMessage(
@@ -981,10 +1151,24 @@ class _MessagesTabState extends State<MessagesTab> {
'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
// fetch request, including direct contacts.
} catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
if (failedMessageId != null) {
messagesProvider.markMessageFailed(failedMessageId);
}
if (!mounted) return;
ToastLogger.error(context, 'Image send failed');
} finally {
@@ -1268,6 +1452,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0,
text: envelopeText,
messageId: msgId,
floodScopeKey: _channelRegionScopeKey,
);
} else if (recipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -1293,6 +1478,16 @@ class _MessagesTabState extends State<MessagesTab> {
}
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).
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
@@ -1442,82 +1637,166 @@ class _MessagesTabState extends State<MessagesTab> {
void _showComposerActions() {
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.search),
title: Text(AppLocalizations.of(context)!.searchMessages),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showFilteredMessageSearch();
});
},
),
ListTile(
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();
});
},
),
],
final theme = Theme.of(sheetContext);
final l10n = AppLocalizations.of(context)!;
Future<void> runAction(Future<void> Function() action) async {
await _runAfterSheetDismissal(sheetContext, action);
}
final actions = <Widget>[
_ComposerActionTile(
icon: Icons.search_rounded,
title: l10n.searchMessages,
color: const Color(0xFF2B6CB0),
onTap: () => runAction(() async {
_showFilteredMessageSearch();
}),
),
_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,
text: sarMessage,
messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
);
if (!mounted) return;
@@ -2107,6 +2387,10 @@ class _MessagesTabState extends State<MessagesTab> {
onStartVoiceRecording: _startVoiceRecording,
onStopAndSendVoice: _stopAndSendVoice,
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,
),
),
),
],
),
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/sensors_provider.dart';
import '../widgets/sensors/bthome_met_history_sheet.dart';
import '../widgets/sensors/sensor_telemetry_card.dart';
import '../l10n/app_localizations.dart';
@@ -100,70 +101,26 @@ class _SensorsTabState extends State<SensorsTab> {
Future<void> _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>();
final candidates = sensorsProvider.availableCandidates(contactsProvider);
final candidates = sensorsProvider.availableCandidates(
contactsProvider,
connectionProvider: context.read<ConnectionProvider>(),
);
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
if (candidates.isEmpty) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No eligible nodes available. Discover a relay or node first.',
),
),
isScrollControlled: true,
builder: (sheetContext) => AddSensorSheet(
candidates: candidates,
onClose: () => Navigator.of(sheetContext).pop(),
onSelect: (contact) 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')),
);
}
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 {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (pageContext) => _SensorCustomizeView(
builder: (pageContext) => SensorCustomizeView(
publicKeyHex: publicKeyHex,
initialContact: contact,
onRenameMetric:
@@ -275,30 +232,50 @@ class _SensorsTabState extends State<SensorsTab> {
onPressed: () => _showAddSensorSheet(context),
child: const Icon(Icons.add),
),
body: Consumer2<SensorsProvider, ContactsProvider>(
builder: (context, sensorsProvider, contactsProvider, child) {
final watchedKeys = sensorsProvider.watchedSensorKeys;
body: Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
builder:
(
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(
onRefresh: () => _refreshAll(context),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
if (watchedKeys.isEmpty)
const _EmptySensorsState()
else
...watchedKeys.map((key) {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == key) {
contact = entry;
break;
}
}
final availableFieldKeys = sensorMetricKeysFor(contact);
final visibleFields = sensorsProvider
.effectiveVisibleFieldsFor(key, availableFieldKeys);
return SensorTelemetryCard(
Widget buildSensorCard(
String key, {
required int index,
required int totalCount,
required bool isWatchedCard,
}) {
final contact = sensorsProvider.contactForDisplay(
key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final availableFieldKeys = sensorMetricKeysFor(contact);
final visibleFields = sensorsProvider.effectiveVisibleFieldsFor(
key,
availableFieldKeys,
);
return Padding(
key: ValueKey<String>('sensor_card_$key'),
padding: EdgeInsets.only(
bottom: index == totalCount - 1 ? 0 : 12,
),
child: ReorderableDelayedDragStartListener(
enabled: isWatchedCard,
index: index,
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(key),
visibleFields: visibleFields,
@@ -311,30 +288,242 @@ class _SensorsTabState extends State<SensorsTab> {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(key, field),
},
onRemove: () async {
await sensorsProvider.removeSensor(key);
},
onRemove: isWatchedCard
? () async {
await sensorsProvider.removeSensor(key);
}
: null,
onCustomize: () =>
_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(
publicKeyHex: key,
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 Contact? initialContact;
final bool showLivePreview;
final Future<void> Function({
required BuildContext context,
required String publicKeyHex,
@@ -343,10 +532,12 @@ class _SensorCustomizeView extends StatelessWidget {
})
onRenameMetric;
const _SensorCustomizeView({
const SensorCustomizeView({
super.key,
required this.publicKeyHex,
required this.initialContact,
required this.onRenameMetric,
this.showLivePreview = true,
});
@override
@@ -391,29 +582,39 @@ class _SensorCustomizeView extends StatelessWidget {
body: ListView(
padding: EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.livePreview,
subtitle:
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
publicKeyHex,
visibleFields,
if (showLivePreview)
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.livePreview,
subtitle:
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
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(
title: AppLocalizations.of(context)!.refreshSchedule,
subtitle:
@@ -876,55 +1077,8 @@ class _EmptySensorsState extends StatefulWidget {
}
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
Widget build(BuildContext context) {
final isConnected =
context.watch<ConnectionProvider>().deviceInfo.isConnected;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 64),
child: Column(
@@ -951,28 +1105,84 @@ class _EmptySensorsStateState extends State<_EmptySensorsState> {
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
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,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: isConnected && !_discoveryInProgress
? _discoverSensors
: null,
icon: _discoveryInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
],
),
);
}
}
class _SelfOnlySensorsCta extends StatelessWidget {
final VoidCallback onAddSensor;
const _SelfOnlySensorsCta({required this.onAddSensor});
@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 Icon(Icons.sensors_outlined),
label: Text(_discoveryInProgress
? 'Discovering...'
: 'Discover Sensors'),
const SizedBox(height: 8),
Text(
'Bring in weather stations, repeaters, or other devices to watch them here.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.35,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: onAddSensor,
icon: const Icon(Icons.add, size: 18),
label: const Text('Choose'),
),
),
],
),

View File

@@ -16,6 +16,7 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../models/contact.dart';
import '../models/config_profile.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
@@ -79,6 +80,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10;
int? _fastLocationChannelIdx;
bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
@@ -190,9 +192,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_isDeveloperModeEnabled = false;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeDisabled)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.developerModeDisabled),
),
);
return;
}
@@ -204,9 +208,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_isDeveloperModeEnabled = true;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeEnabled)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.developerModeEnabled),
),
);
return;
}
@@ -310,6 +316,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_locationService.fastLocationMovementThresholdMeters;
_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 {
await ImagePreferences.setMaxSize(size);
if (!mounted) return;
@@ -559,7 +640,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid),
content: Text(
AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid,
),
backgroundColor: Colors.orange,
),
);
@@ -584,7 +667,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!updateInfo.isAvailable) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.youAreRunningTheLatestVersion),
content: Text(
AppLocalizations.of(context)!.youAreRunningTheLatestVersion,
),
backgroundColor: Colors.green,
),
);
@@ -594,7 +679,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (updateInfo.downloadUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.updateAvailableButDownloadUrlNotFound),
content: Text(
AppLocalizations.of(
context,
)!.updateAvailableButDownloadUrlNotFound,
),
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'),
_buildSettingsCard([
ListTile(
@@ -1205,7 +1388,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.nearestRepeaterFallback),
title: Text(
AppLocalizations.of(context)!.nearestRepeaterFallback,
),
subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater',
),
@@ -1234,7 +1419,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Clear Messages',
style: TextStyle(color: Colors.red),
),
subtitle: Text(AppLocalizations.of(context)!.deleteAllStoredMessageHistory),
subtitle: Text(
AppLocalizations.of(context)!.deleteAllStoredMessageHistory,
),
onTap: _clearMessages,
),
Consumer<AppProvider>(
@@ -1344,26 +1531,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, drawingProvider, child) => SwitchListTile(
secondary: Icon(Icons.fmd_good_outlined),
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
subtitle: Text(AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap),
subtitle: Text(
AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap,
),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
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>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: Icon(Icons.router_outlined),
@@ -1420,7 +1596,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.compress),
title: Text(AppLocalizations.of(context)!.voiceCompressor),
subtitle: Text(AppLocalizations.of(context)!.balancesQuietAndLoudSpeechLevels),
subtitle: Text(
AppLocalizations.of(
context,
)!.balancesQuietAndLoudSpeechLevels,
),
value: appProvider.isVoiceCompressorEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceCompressorEnabled(value);
@@ -1431,7 +1611,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.speed),
title: Text(AppLocalizations.of(context)!.voiceLimiter),
subtitle: Text(AppLocalizations.of(context)!.preventsClippingPeaksBeforeEncoding),
subtitle: Text(
AppLocalizations.of(
context,
)!.preventsClippingPeaksBeforeEncoding,
),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
@@ -1442,7 +1626,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.auto_fix_high),
title: Text(AppLocalizations.of(context)!.micAutoGain),
subtitle: Text(AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel),
subtitle: Text(
AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel,
),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
@@ -1478,7 +1664,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.content_cut),
title: Text(AppLocalizations.of(context)!.trimSilenceInVoiceMessages),
title: Text(
AppLocalizations.of(context)!.trimSilenceInVoiceMessages,
),
subtitle: const Text(
'Removes long silent parts before sending voice',
),
@@ -1648,11 +1836,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: Icon(Icons.timer),
title: Text(AppLocalizations.of(context)!.activeuseUpdateInterval),
title: Text(
AppLocalizations.of(context)!.activeuseUpdateInterval,
),
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence,
),
ListTile(
leading: const Icon(Icons.forum),
title: const Text('Fast GPS target channel'),
subtitle: Text(
_describeFastLocationChannel(
context.watch<ContactsProvider>().channels,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationChannel,
),
ListTile(
leading: Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -141,6 +141,18 @@ class 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(
Iterable<BlePacketLog> logs, {
required DateTime now,
@@ -154,15 +166,14 @@ class LiveTrafficSummary {
? clearedAt
: windowStart;
final recentLogs = logs
.where(
(log) =>
log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final recentLogs =
logs
.where(
(log) =>
isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[];
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;
final totalCount = rxCount;
final packetsPerMinute =
@@ -233,7 +246,9 @@ class LiveTrafficSummary {
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
avgHopCount: hopCountSamples == 0
? null
: hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
);

View File

@@ -49,6 +49,7 @@ class LocationTrackingService {
'fast_location_movement_threshold_meters';
static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds';
static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx';
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
@@ -79,6 +80,9 @@ class LocationTrackingService {
/// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10;
/// Target channel index for fast GPS updates; null means disabled/unset.
int? fastLocationChannelIdx;
// ============================================================================
// State Properties
// ============================================================================
@@ -528,8 +532,14 @@ class LocationTrackingService {
_refreshFastLocationTimer();
}
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
fastLocationChannelIdx = channelIdx;
await saveSettings();
_refreshFastLocationTimer();
}
void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled) return;
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final previous = _lastFastLocationSentPosition;
if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial');
@@ -552,6 +562,7 @@ class LocationTrackingService {
_fastLocationTimer = null;
if (!isTracking ||
!fastLocationUpdatesEnabled ||
fastLocationChannelIdx == null ||
!_isFastLocationActiveUse) {
return;
}
@@ -567,7 +578,7 @@ class LocationTrackingService {
}
void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled) return;
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final now = DateTime.now();
final previous = _lastFastLocationSentPosition;
@@ -669,6 +680,7 @@ class LocationTrackingService {
5,
60,
);
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));
debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m');
@@ -680,6 +692,7 @@ class LocationTrackingService {
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
);
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}');
}
/// Save settings to SharedPreferences
@@ -709,6 +722,12 @@ class LocationTrackingService {
_scopedKey(_prefKeyFastActiveCadence),
fastLocationActiveCadenceSeconds,
);
final channelKey = _scopedKey(_prefKeyFastChannelIdx);
if (fastLocationChannelIdx == null) {
await prefs.remove(channelKey);
} else {
await prefs.setInt(channelKey, fastLocationChannelIdx!);
}
debugPrint('✅ [LocationTracking] Settings saved');
}

View File

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

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
@@ -9,7 +10,7 @@ import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
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 _topRotationCount = 3;
@@ -59,6 +60,11 @@ class PathHistoryService {
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
);
await _saveHistory(
@@ -104,6 +110,11 @@ class PathHistoryService {
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
);
await _saveHistory(
@@ -172,6 +183,10 @@ class PathHistoryService {
PathSelection selection, {
required bool success,
int? roundTripTimeMs,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize();
final history = _historyFor(contactPublicKeyHex);
@@ -206,6 +221,13 @@ class PathHistoryService {
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0),
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(
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) {
return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex);
@@ -279,4 +349,68 @@ class PathHistoryService {
String _signature(Uint8List bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
int _compareLocationFit(
PathRecord a,
PathRecord b, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
final aDistance = _locationDistanceScore(
a,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
final bDistance = _locationDistanceScore(
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
return aDistance.compareTo(bDistance);
}
double _locationDistanceScore(
PathRecord record, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
var total = 0.0;
var matched = false;
if (senderLatitude != null &&
senderLongitude != null &&
record.senderLatitude != null &&
record.senderLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
senderLatitude,
senderLongitude,
record.senderLatitude!,
record.senderLongitude!,
);
}
if (recipientLatitude != null &&
recipientLongitude != null &&
record.recipientLatitude != null &&
record.recipientLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
recipientLatitude,
recipientLongitude,
record.recipientLatitude!,
record.recipientLongitude!,
);
}
return matched ? total : double.infinity;
}
}

View File

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

View File

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

View File

@@ -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();

View 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();

View 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();

View 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();

View 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);
}

View File

@@ -1,13 +1,121 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import 'package:provider/provider.dart';
import 'package:usb_serial/usb_serial.dart';
import '../l10n/app_localizations.dart';
import '../providers/app_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/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
class ConnectionDialog extends StatefulWidget {
@@ -26,6 +134,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
int _scannedCount = 0;
int _totalToScan = 0;
int _lastTabIndex = 0;
bool _hasRequestedBleScan = false;
String? _connectingToServerKey;
String? _connectingBleDeviceId;
@@ -33,10 +142,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) {
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
setState(() {
@@ -58,11 +163,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
listen: false,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_refreshBleDevices();
});
_networkScanner.onServerDiscovered = (server) {
if (!mounted) return;
setState(() {
@@ -103,6 +203,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
}
Future<void> _refreshBleDevices() async {
if (!_hasRequestedBleScan && mounted) {
setState(() {
_hasRequestedBleScan = true;
});
} else {
_hasRequestedBleScan = true;
}
await _connectionProvider.stopScan();
if (!mounted) return;
await _connectionProvider.startScan();
@@ -114,6 +221,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
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
Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>();
@@ -166,7 +283,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
),
const SizedBox(height: 2),
Text(
'Choose Bluetooth, WiFi, or USB transport',
'Choose Bluetooth, WiFi, or Serial transport',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
@@ -192,7 +309,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
tabs: const [
Tab(text: 'BLE', icon: Icon(Icons.bluetooth_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({
required IconData icon,
required String title,
@@ -364,13 +511,17 @@ class _ConnectionDialogState extends State<ConnectionDialog>
}
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
_buildSectionBanner(
icon: Icons.bluetooth_searching_rounded,
message: AppLocalizations.of(context)!.defaultPinInfo,
message: l10n.defaultPinInfo,
onRefresh: _refreshBleDevices,
),
if (connectionProvider.error != null)
_buildErrorBanner(connectionProvider.error!),
Expanded(
child:
connectionProvider.isScanning &&
@@ -379,8 +530,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
: connectionProvider.scannedDevices.isEmpty
? _buildEmptyState(
icon: Icons.bluetooth_searching_rounded,
title: AppLocalizations.of(context)!.noDevicesFound,
actionLabel: AppLocalizations.of(context)!.scanAgain,
title: _hasRequestedBleScan
? l10n.noDevicesFound
: 'Press scan to search for nearby devices',
actionLabel: _hasRequestedBleScan ? l10n.scanAgain : 'Scan',
onAction: _refreshBleDevices,
)
: ListView.builder(
@@ -395,19 +548,25 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final isConnecting = _connectingBleDeviceId == deviceId;
Future<void> connectBle() async {
final appProvider = context.read<AppProvider>();
setState(() {
_connectingBleDeviceId = deviceId;
});
try {
Navigator.pop(context);
final success = await connectionProvider.connect(
device,
);
if (success &&
connectionProvider.deviceInfo.isConnected) {
await appProvider.initialize();
if (!success) {
final name = device.platformName.isNotEmpty
? device.platformName
: 'device';
throw Exception(
connectionProvider.error ??
'Failed to connect to $name',
);
}
_closeOnSuccessfulConnection();
} catch (error) {
_showConnectionError(error);
} finally {
if (mounted) {
setState(() {
@@ -502,9 +661,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Future<void> connectServer() async {
final connectionProvider = context
.read<ConnectionProvider>();
final appProvider = context.read<AppProvider>();
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
setState(() {
_connectingToServerKey = serverKey;
@@ -520,48 +676,23 @@ class _ConnectionDialogState extends State<ConnectionDialog>
);
}
await connectionProvider.connectTcp(
final success = await connectionProvider.connectTcp(
server.ipAddress,
server.port,
);
await appProvider.initialize();
if (mounted) {
navigator.pop();
if (!success) {
throw Exception(
connectionProvider.error ??
'Failed to connect to ${server.ipAddress}:${server.port}',
);
}
_closeOnSuccessfulConnection();
} catch (e) {
if (!mounted) return;
setState(() {
_connectingToServerKey = null;
});
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),
),
);
_showConnectionError(e);
}
}
@@ -597,7 +728,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
}
Widget _buildUsbTab() {
return _UsbDeviceList(
return _SerialDeviceList(
buildTransportCard:
({
required icon,
@@ -628,8 +759,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
actionLabel: actionLabel,
onAction: onAction,
),
onConnected: () {
if (mounted) Navigator.of(context).pop();
onConnected: (result) {
if (mounted) {
Navigator.of(context).pop(result);
}
},
);
}
@@ -654,38 +787,48 @@ typedef _EmptyStateBuilder =
required VoidCallback onAction,
});
class _UsbDeviceList extends StatefulWidget {
final VoidCallback onConnected;
class _SerialDeviceList extends StatefulWidget {
final ValueChanged<_ConnectionDialogResult> onConnected;
final _TransportCardBuilder buildTransportCard;
final _EmptyStateBuilder buildEmptyState;
const _UsbDeviceList({
const _SerialDeviceList({
required this.onConnected,
required this.buildTransportCard,
required this.buildEmptyState,
});
@override
State<_UsbDeviceList> createState() => _UsbDeviceListState();
State<_SerialDeviceList> createState() => _SerialDeviceListState();
}
class _UsbDeviceListState extends State<_UsbDeviceList> {
List<UsbDevice> _devices = [];
class _SerialDeviceListState extends State<_SerialDeviceList> {
final SerialTransport _transport = createSerialTransport();
List<SerialDeviceInfo> _devices = [];
bool _isScanning = false;
bool _isConnecting = false;
@override
void initState() {
super.initState();
if (defaultTargetPlatform == TargetPlatform.android) {
if (_transport.isSupported) {
_scanDevices();
}
}
@override
void dispose() {
_transport.dispose();
super.dispose();
}
Future<void> _scanDevices() async {
if (!_transport.isSupported) {
return;
}
setState(() => _isScanning = true);
try {
final devices = await UsbSerial.listDevices();
final devices = await _transport.listDevices();
if (!mounted) return;
setState(() {
_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);
try {
final connectionProvider = context.read<ConnectionProvider>();
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
final port = await device.create();
if (port == null) {
if (mounted) {
setState(() => _isConnecting = false);
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,
final connection = await _transport.connect(device);
final success = await connectionProvider.connectSerial(
service: connection.service,
disconnectTransport: connection.disconnect,
deviceId: connection.deviceId,
deviceName: connection.deviceName,
);
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 (success) {
widget.onConnected();
widget.onConnected(_ConnectionDialogResult.connected);
} else {
await port.close();
await connection.disconnect();
connection.service.dispose();
if (!mounted) return;
setState(() => _isConnecting = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to connect via USB')),
const SnackBar(content: Text('Failed to connect via serial')),
);
}
} catch (e) {
@@ -782,20 +908,18 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
setState(() => _isConnecting = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('USB error: $e')));
).showSnackBar(SnackBar(content: Text('Serial error: $e')));
}
}
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform != TargetPlatform.android) {
if (!_transport.isSupported) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
kIsWeb
? 'Web Serial is not yet supported.\nUse BLE or Network instead.'
: 'USB serial is available on Android only.\nConnect via OTG cable.',
_transport.unsupportedMessage,
textAlign: TextAlign.center,
),
),
@@ -811,19 +935,22 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: FilledButton.tonalIcon(
onPressed: _isConnecting ? null : _scanDevices,
onPressed: _isConnecting
? null
: (_transport.canRequestDevice ? _requestDevice : _scanDevices),
icon: const Icon(Icons.usb_rounded),
label: const Text('Scan USB devices'),
label: Text(_transport.actionLabel),
),
),
if (_devices.isEmpty)
Expanded(
child: widget.buildEmptyState(
icon: Icons.usb_off_rounded,
title:
'No USB serial devices found.\nConnect a MeshCore device via OTG cable.',
actionLabel: 'Scan USB devices',
onAction: _scanDevices,
title: _transport.emptyStateTitle,
actionLabel: _transport.actionLabel,
onAction: _transport.canRequestDevice
? _requestDevice
: _scanDevices,
),
)
else
@@ -835,10 +962,8 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
return widget.buildTransportCard(
icon: Icons.usb_rounded,
iconColor: Theme.of(context).colorScheme.primary,
title: device.productName ?? 'USB Device',
subtitle: (device.manufacturerName?.isNotEmpty ?? false)
? device.manufacturerName!
: 'Ready over OTG serial',
title: device.title,
subtitle: device.subtitle,
trailing: _isConnecting
? const SizedBox(
width: 24,

File diff suppressed because it is too large Load Diff

View File

@@ -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,
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart';
import '../../../models/sar_marker.dart';
import 'compass_math.dart';
/// Header component for the compass dialog showing compass rose,
/// 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)!;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
@@ -90,17 +95,13 @@ class CompassHeader extends StatelessWidget {
_buildInfoCard(
context,
l10n.elevation,
position?.altitude != null
? '${position!.altitude.round()}m'
: '--',
position?.altitude != null ? '${position!.altitude.round()}m' : '--',
Icons.terrain,
),
_buildInfoCard(
context,
l10n.accuracy,
position?.accuracy != null
? '±${position!.accuracy.round()}m'
: '--',
position?.accuracy != null ? '±${position!.accuracy.round()}m' : '--',
Icons.gps_fixed,
),
],
@@ -108,21 +109,22 @@ class CompassHeader extends StatelessWidget {
}
Widget _buildInfoCard(
BuildContext context, String label, String value, IconData icon) {
BuildContext context,
String label,
String value,
IconData icon,
) {
return Column(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
@@ -193,11 +195,16 @@ class _LargeCompassPainter extends CustomPainter {
// Draw degree markers
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 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(
center.dx + startRadius * cos(angle),
center.dy + startRadius * sin(angle),
@@ -218,7 +225,10 @@ class _LargeCompassPainter extends CustomPainter {
final textPainter = TextPainter(textDirection: TextDirection.ltr);
final directions = ['N', 'E', 'S', 'W'];
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 y = center.dy + (radius - 35) * sin(angle);
@@ -243,20 +253,25 @@ class _LargeCompassPainter extends CustomPainter {
final contactsWithDistance = contacts
.where((c) => c.displayLocation != null)
.map((contact) {
final bearing = _calculateBearing(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final distance = _calculateDistance(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
return {'contact': contact, 'bearing': bearing, 'distance': distance};
}).toList();
final bearing = _calculateBearing(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final distance = _calculateDistance(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
return {
'contact': contact,
'bearing': bearing,
'distance': distance,
};
})
.toList();
if (contactsWithDistance.isEmpty) return;
@@ -277,7 +292,8 @@ class _LargeCompassPainter extends CustomPainter {
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
// 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
final dotX = center.dx + contactRadius * cos(angle);
@@ -288,11 +304,7 @@ class _LargeCompassPainter extends CustomPainter {
..color = Colors.lightBlue.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawLine(
center,
Offset(dotX, dotY),
linePaint,
);
canvas.drawLine(center, Offset(dotX, dotY), linePaint);
// Draw contact dot (size varies with zoom)
final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0);
@@ -341,7 +353,10 @@ class _LargeCompassPainter extends CustomPainter {
textPainter.paint(
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)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawLine(
center,
Offset(dotX, dotY),
linePaint,
);
canvas.drawLine(center, Offset(dotX, dotY), linePaint);
// Draw SAR marker dot (slightly larger than contacts)
final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0);
@@ -472,7 +483,10 @@ class _LargeCompassPainter extends CustomPainter {
textPainter.paint(
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);
}
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
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
final dLat = (lat2 - lat1) * 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(lat2 * pi / 180) *
sin(dLon / 2) *
@@ -572,7 +590,8 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
final String displayText;
if (_showDMS) {
displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
displayText =
'${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
} else {
displayText = l10n.latLonFormat(
position.latitude.toStringAsFixed(5),
@@ -598,9 +617,9 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
displayText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
),

View 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));

View File

@@ -1,5 +1,6 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'compass/compass_math.dart';
class CompassWidget extends StatelessWidget {
final double heading;
@@ -21,9 +22,9 @@ class CompassWidget extends StatelessWidget {
child: Stack(
alignment: Alignment.center,
children: [
// Compass rose background - rotates to show true north at top
// Rotate the rose opposite the heading under the fixed needle.
Transform.rotate(
angle: heading * pi / 180,
angle: compassRoseRotationRadians(heading),
child: CustomPaint(
size: const Size(40, 40),
painter: _CompassRosePainter(),
@@ -76,9 +77,7 @@ class _CompassRosePainter extends CustomPainter {
canvas.drawCircle(center, radius, paint);
// Draw cardinal direction markers
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
final textPainter = TextPainter(textDirection: TextDirection.ltr);
final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) {

View File

@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:latlong2/latlong.dart';
import '../../services/compass_support.dart';
import '../../models/contact.dart';
import '../../models/sar_marker.dart';
import '../common/location_display.dart';
@@ -90,17 +92,33 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
});
}
// Subscribe to compass updates
final compassStream = FlutterCompass.events;
// Subscribe to compass updates on supported targets only.
final compassStream = CompassSupport.isAvailable
? FlutterCompass.events
: null;
if (compassStream != null) {
_compassSubscription = compassStream.listen((event) {
if (mounted && event.heading != null) {
setState(() {
_currentHeading = event.heading;
_compassAccuracy = event.accuracy;
});
}
});
_compassSubscription = compassStream.listen(
(event) {
if (mounted && event.heading != null) {
setState(() {
_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

View File

@@ -1,11 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/location_trail.dart';
import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart';
/// Trail management controls widget
@@ -14,17 +11,8 @@ class TrailControls extends StatelessWidget {
void _showTrailMenu(BuildContext context) {
final mapProvider = Provider.of<MapProvider>(context, listen: false);
final contactsProvider = Provider.of<ContactsProvider>(
context,
listen: false,
);
final l10n = AppLocalizations.of(context)!;
// Get contacts with trails (advertHistory >= 2 points)
final contactsWithTrails = contactsProvider.contactsWithLocation
.where((c) => c.advertHistory.length >= 2)
.toList();
showModalBottomSheet(
context: context,
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),
// 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(
BuildContext context,
MapProvider mapProvider,

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart';
import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../models/sar_marker.dart';
@@ -10,6 +12,7 @@ import '../../models/sar_template.dart';
import '../../models/map_drawing.dart';
import '../../models/map_coordinate_space.dart';
import '../../providers/messages_provider.dart';
import '../../providers/channels_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
@@ -31,6 +34,7 @@ import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart';
import '../../screens/add_contact_screen.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart';
@@ -72,9 +76,28 @@ class MessageBubble extends StatefulWidget {
}
class _MessageBubbleState extends State<MessageBubble> {
static const int _meshcoreAdvertMinBytes = 98;
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 _showReceivedStats = false;
final List<TapGestureRecognizer> _linkRecognizers = [];
@override
void dispose() {
_disposeLinkRecognizers();
super.dispose();
}
@override
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) {
final matches = _mentionPattern.allMatches(text).toList();
_disposeLinkRecognizers();
final matches = _messageTokenPattern.allMatches(text).toList();
if (matches.isEmpty) {
return Text(text, style: baseBodyStyle);
}
final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final linkColor = Theme.of(context).colorScheme.primary;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of(
context,
@@ -129,8 +246,9 @@ class _MessageBubbleState extends State<MessageBubble> {
);
}
final rawMatch = match.group(0) ?? '';
final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) {
if (_mentionPattern.hasMatch(rawMatch) && mentionName.isNotEmpty) {
spans.add(
WidgetSpan(
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 {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle));
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
}
cursor = match.end;
@@ -1812,10 +1956,12 @@ class _MessageBubbleState extends State<MessageBubble> {
final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final messageFontScale = context.watch<AppProvider>().messageFontScale;
final l10n = AppLocalizations.of(context)!;
// Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final channelsProvider = context.watch<ChannelsProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey);
@@ -1861,7 +2007,7 @@ class _MessageBubbleState extends State<MessageBubble> {
// Get rich display name (with emoji if available)
final displayName = isOwnMessage
? AppLocalizations.of(context)!.you
? l10n.you
: message.getRichDisplayName(senderContact);
// Look up destination/source display labels for direct/channel messages
@@ -1896,15 +2042,30 @@ class _MessageBubbleState extends State<MessageBubble> {
}
}
} else if (message.isChannelMessage) {
if (message.channelIdx == 0) {
channelDisplayName = AppLocalizations.of(context)!.publicChannel;
final channelIdx = message.channelIdx ?? 0;
if (channelIdx == 0) {
channelDisplayName = l10n.publicChannel;
} else {
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;
final syncedChannel = channelsProvider.getChannel(channelIdx);
final syncedChannelDisplayName =
syncedChannel != null && syncedChannel.hasCustomName
? syncedChannel.displayName
: null;
final contactChannelDisplayName = channelContact
?.getLocalizedDisplayName(context)
.trim();
channelDisplayName =
channelContact?.getLocalizedDisplayName(context) ??
'${AppLocalizations.of(context)!.channel} ${message.channelIdx}';
syncedChannelDisplayName ??
(contactChannelDisplayName != null &&
contactChannelDisplayName.isNotEmpty
? contactChannelDisplayName
: null) ??
syncedChannel?.displayName ??
'${l10n.channel} $channelIdx';
}
if (isOwnMessage) {
@@ -1914,14 +2075,14 @@ class _MessageBubbleState extends State<MessageBubble> {
final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${AppLocalizations.of(context)!.channel}: $recipientDisplayName'
? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName;
final directCounterpartLabel = !message.isChannelMessage
? (isOwnMessage ? recipientSubtitle : AppLocalizations.of(context)!.you)
? (isOwnMessage ? recipientSubtitle : l10n.you)
: null;
final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${AppLocalizations.of(context)!.channel}: $channelDisplayName'
? '${l10n.channel}: $channelDisplayName'
: null;
final shouldFloatBubble = widget.isCompact;

View File

@@ -27,6 +27,8 @@ class MessagesComposer extends StatelessWidget {
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage;
final String? regionScopeName;
final VoidCallback? onRegionScopeTap;
const MessagesComposer({
super.key,
@@ -49,6 +51,8 @@ class MessagesComposer extends StatelessWidget {
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onSendMessage,
this.regionScopeName,
this.onRegionScopeTap,
});
@override
@@ -102,6 +106,14 @@ class MessagesComposer extends StatelessWidget {
? onStopAndSendVoice
: onShowComposerActions,
),
if (regionScopeName != null &&
onRegionScopeTap != null) ...[
const SizedBox(width: 6),
_RegionScopeChip(
name: regionScopeName!,
onTap: onRegionScopeTap!,
),
],
const SizedBox(width: 8),
Expanded(
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

View File

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

View File

@@ -1,12 +1,14 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/sensors_provider.dart';
import '../../services/bthome_met_history.dart';
import '../../utils/location_formats.dart';
class SensorMetricOption {
@@ -211,8 +213,10 @@ List<SensorMetricOption> sensorMetricOptionsFor(
// Skip them from extraSensorData to avoid duplicates.
final coreFieldKeys = <String>{
if (batteryMilliVolts != null || batteryPercentage != null)
...extraSensorData?.keys.where((k) =>
k.startsWith('voltage_') || k.startsWith('analog_input_')) ?? [],
...extraSensorData?.keys.where(
(k) => k.startsWith('voltage_') || k.startsWith('analog_input_'),
) ??
[],
if (temperature != null)
...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [],
if (humidity != null)
@@ -290,14 +294,14 @@ SensorMetricCardData? _buildOptionPreviewCardData(
);
case 'illuminance':
final lux = _previewAsDouble(value);
final irradiance = lux == null
? null
: _previewFormatDaylightIrradiance(lux);
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.light_mode_outlined,
label: label,
value: previewValue,
secondaryValue: lux == null
? null
: '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight',
value: irradiance ?? previewValue,
accent: const Color(0xFFC17B1D),
channel: metricKey.channel,
);
@@ -544,12 +548,15 @@ SensorMetricCardData? _buildOptionPreviewCardData(
fieldKey: fieldKey,
icon: Icons.explore_outlined,
label: label,
value: previewValue,
value: degrees == null
? previewValue
: '${_formatPreviewNumber(degrees, maxFractionDigits: 0)}°',
secondaryValue: degrees == null
? null
: _previewFormatCardinalDirection(degrees),
accent: const Color(0xFF8A5A44),
channel: metricKey.channel,
directionDegrees: degrees,
);
case 'unixtime':
return SensorMetricCardData(
@@ -674,6 +681,10 @@ double _previewApproxDaylightIrradiance(double lux) {
return lux / 120.0;
}
String _previewFormatDaylightIrradiance(double lux) {
return '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2';
}
String _previewFormatCardinalDirection(double degrees) {
const points = <String>['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final normalized = ((degrees % 360) + 360) % 360;
@@ -699,7 +710,7 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) {
case 'illuminance':
final lux = _previewAsDouble(value);
if (lux == null) return null;
return '${_formatPreviewNumber(lux, maxFractionDigits: 0)} lx';
return _previewFormatDaylightIrradiance(lux);
case 'presence':
final isPresent = _previewAsBool(value);
@@ -1000,6 +1011,13 @@ String _formatPreviewNumber(num value, {int maxFractionDigits = 2}) {
? math.min(maxFractionDigits, 1)
: maxFractionDigits;
final text = value.toStringAsFixed(digits);
return _trimFractionZeros(text);
}
String _trimFractionZeros(String text) {
if (!text.contains('.')) {
return text;
}
return text.replaceFirst(RegExp(r'\.?0+$'), '');
}
@@ -1022,6 +1040,9 @@ class SensorTelemetryCard extends StatelessWidget {
final Future<void> Function()? onRemove;
final Future<void> Function()? onRefresh;
final VoidCallback? onCustomize;
final Future<void> Function(Contact contact)? onShowMetHistory;
final Future<void> Function()? onMoveUp;
final Future<void> Function()? onMoveDown;
final EdgeInsetsGeometry margin;
final String emptyMetricsMessage;
final Map<String, String> labelOverrides;
@@ -1036,6 +1057,9 @@ class SensorTelemetryCard extends StatelessWidget {
this.onRemove,
this.onRefresh,
this.onCustomize,
this.onShowMetHistory,
this.onMoveUp,
this.onMoveDown,
this.margin = const EdgeInsets.only(bottom: 16),
this.emptyMetricsMessage =
'All fields are hidden. Use Visible fields to choose what to show.',
@@ -1043,7 +1067,15 @@ class SensorTelemetryCard extends StatelessWidget {
});
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
Widget build(BuildContext context) {
@@ -1122,21 +1154,21 @@ class SensorTelemetryCard extends StatelessWidget {
),
if (state == SensorRefreshState.refreshing)
_InlineStateMeta(
label: l10n.refreshing,
color: Color(0xFF266AC2),
spinning: true,
tooltip: l10n.refreshing,
),
if (state == SensorRefreshState.success)
const _InlineStateMeta(
label: 'Updated',
color: Color(0xFF218B63),
icon: Icons.check_circle,
tooltip: 'Updated',
),
if (state == SensorRefreshState.unavailable)
_InlineStateMeta(
label: l10n.unavailable,
color: Color(0xFFB13B55),
icon: Icons.error_outline,
tooltip: l10n.unavailable,
),
],
),
@@ -1149,10 +1181,34 @@ class SensorTelemetryCard extends StatelessWidget {
onSelected: (value) async {
if (value == 'refresh' && onRefresh != null) {
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) {
await onRemove!();
} else if (value == 'customize' && onCustomize != null) {
onCustomize!();
} else if (value == 'met_history' &&
contact != null &&
onShowMetHistory != null) {
await onShowMetHistory!(contact!);
}
},
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) {
items.add(
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) {
items.add(
PopupMenuItem<String>(
@@ -1215,6 +1305,11 @@ class SensorTelemetryCard extends StatelessWidget {
metric.wide)
? constraints.maxWidth
: compactWidth,
onLongPress: onRefresh == null
? null
: () async {
await onRefresh!();
},
),
)
.toList(),
@@ -1439,9 +1534,7 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey),
icon: Icons.light_mode_outlined,
label: label,
value: '${_formatNumber(lux, maxFractionDigits: 0)} lx',
secondaryValue:
'~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight',
value: _formatDaylightIrradiance(lux),
accent: const Color(0xFFC17B1D),
channel: metricKey.channel,
);
@@ -1780,10 +1873,11 @@ class SensorTelemetryCard extends StatelessWidget {
fieldKey: _extraFieldKey(rawKey),
icon: Icons.explore_outlined,
label: label,
value: '${_formatNumber(degrees, maxFractionDigits: 0)} deg',
value: '${_formatNumber(degrees, maxFractionDigits: 0)}°',
secondaryValue: _formatCardinalDirection(degrees),
accent: const Color(0xFF8A5A44),
channel: metricKey.channel,
directionDegrees: degrees,
);
case 'rotation':
@@ -1958,6 +2052,10 @@ class SensorTelemetryCard extends StatelessWidget {
return lux / 120.0;
}
String _formatDaylightIrradiance(double lux) {
return '~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2';
}
String _formatCurrent(double amps) {
final absolute = amps.abs();
if (absolute < 1.0) {
@@ -2019,7 +2117,7 @@ class SensorTelemetryCard extends StatelessWidget {
? math.min(maxFractionDigits, 1)
: maxFractionDigits;
final text = value.toStringAsFixed(digits);
return text.replaceFirst(RegExp(r'\.?0+$'), '');
return _trimFractionZeros(text);
}
_Vector3? _asVector3(dynamic value) {
@@ -2083,31 +2181,28 @@ class SensorTelemetryCard extends StatelessWidget {
}
class _InlineStateMeta extends StatelessWidget {
final String label;
final Color color;
final IconData? icon;
final bool spinning;
final String? tooltip;
const _InlineStateMeta({
required this.label,
required this.color,
this.icon,
this.spinning = false,
this.tooltip,
});
@override
Widget build(BuildContext context) {
return Container(
final badge = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (spinning)
SizedBox(
child: spinning
? SizedBox(
width: 11,
height: 11,
child: CircularProgressIndicator(
@@ -2115,18 +2210,20 @@ class _InlineStateMeta extends StatelessWidget {
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
else if (icon != null)
Icon(icon, size: 11, color: color),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
: Icon(
icon ?? Icons.info_outline,
size: 11,
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 String keyPrefix;
final bool allowMapPreview;
final GestureLongPressCallback? onLongPress;
const SensorMetricTile({
super.key,
@@ -2167,6 +2265,7 @@ class SensorMetricTile extends StatelessWidget {
required this.width,
this.keyPrefix = 'sensor_metric',
this.allowMapPreview = true,
this.onLongPress,
});
Future<void> _showExpandedMap(BuildContext context) async {
@@ -2241,142 +2340,185 @@ class SensorMetricTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
key: ValueKey('${keyPrefix}_${data.fieldKey}'),
width: width,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.08),
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey('${keyPrefix}_${data.fieldKey}'),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
),
child: data.mapLocation == null || !allowMapPreview
? Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
onLongPress: onLongPress,
child: Container(
width: width,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
),
child: data.directionDegrees != null
? Stack(
children: [
_MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10),
Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_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(
right: 0,
bottom: 0,
child: Text(
'ch${data.channel}',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: data.accent.withValues(alpha: 0.5),
if (data.channel != null)
Positioned(
right: 0,
bottom: 0,
child: Text(
'ch${data.channel}',
style: TextStyle(
fontSize: 9,
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),
),
],
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
if (data.channel != null)
Positioned(
right: 0,
bottom: 0,
child: Text(
'ch${data.channel}',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: data.accent.withValues(alpha: 0.5),
),
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10),
Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10),
Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix),
),
],
),
],
),
const SizedBox(height: 10),
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => _showExpandedMap(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
height: 104,
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,
),
),
const SizedBox(height: 10),
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => _showExpandedMap(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
height: 104,
width: double.infinity,
child: Stack(
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'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,
),
flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCenter: data.mapLocation!,
initialZoom: 14,
interactionOptions:
const flutter_map.InteractionOptions(
flags:
flutter_map.InteractiveFlag.none,
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.meshcore.sar.meshcore_sar_app',
),
flutter_map.MarkerLayer(
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
Widget build(BuildContext context) {
if (data.directionDegrees != null) {
return _DirectionMetricText(data: data);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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 {
final String fieldKey;
final IconData icon;
final String label;
final String value;
final String? secondaryValue;
final double? directionDegrees;
final Color accent;
final bool wide;
final LatLng? mapLocation;
@@ -2465,6 +2737,7 @@ class SensorMetricCardData {
required this.label,
required this.value,
this.secondaryValue,
this.directionDegrees,
required this.accent,
this.wide = false,
this.mapLocation,
@@ -2496,13 +2769,20 @@ class _RgbColor {
}
const String _telemetrySourceChannelPrefix = '__source_channel:';
const String _rawTelemetryHexKey = '__raw_lpp_hex';
String _extraFieldKey(String label) {
return 'extra:$label';
}
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) {

View File

@@ -9,6 +9,7 @@
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_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 <url_launcher_linux/url_launcher_plugin.h>
@@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
flutter_avif_linux
flutter_libserialport
record_linux
url_launcher_linux
)

View File

@@ -11,6 +11,7 @@ import file_picker
import file_selector_macos
import flutter_avif_macos
import flutter_blue_plus_darwin
import flutter_libserialport
import flutter_local_notifications
import geolocator_apple
import nsd_macos
@@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FlutterLibserialportPlugin.register(with: registry.registrar(forPlugin: "FlutterLibserialportPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))

View File

@@ -1,4 +1,4 @@
platform :osx, '10.15'
platform :osx, '26.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -38,6 +38,9 @@ end
post_install do |installer|
installer.pods_project.targets.each do |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
# 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.

View File

@@ -15,12 +15,16 @@ PODS:
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- flutter_libserialport (0.0.1):
- FlutterMacOS
- libserialport
- flutter_local_notifications (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- libserialport (0.1.1)
- nsd_macos (0.0.1):
- FlutterMacOS
- package_info_plus (0.0.1):
@@ -40,6 +44,8 @@ PODS:
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- 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`)
- 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_libserialport (from `Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- 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`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- 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:
audioplayers_darwin:
@@ -76,6 +88,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
flutter_blue_plus_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
flutter_libserialport:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos
flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
FlutterMacOS:
@@ -98,6 +112,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
SPEC CHECKSUMS:
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
@@ -107,9 +123,11 @@ SPEC CHECKSUMS:
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_libserialport: 2c523cb8d8203fc6d1b0da88190da31ac970eb93
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
libserialport: 1cb25e66ef3c92a8e59c2ea3820302c3fa2268cd
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
@@ -118,7 +136,8 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b
PODFILE CHECKSUM: 467066e8d30d1105257e0877b8cdd20642e7b3db
COCOAPODS: 1.16.2

View File

@@ -557,7 +557,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -639,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -689,7 +689,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;

View File

@@ -2,11 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

View File

@@ -24,10 +24,16 @@
<false/>
<key>LSMinimumSystemVersion</key>
<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>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<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>
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
<key>NSPrincipalClass</key>

View File

@@ -2,7 +2,5 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict>
</plist>

View File

@@ -218,6 +218,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -298,6 +314,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description: flutter
@@ -479,6 +503,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct dev"
description:
@@ -759,6 +791,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
libserialport:
dependency: transitive
description:
name: libserialport
sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727"
url: "https://pub.dev"
source: hosted
version: "0.3.0+1"
lints:
dependency: transitive
description:
@@ -787,8 +827,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: main
resolved-ref: abff892fac0dd0e8004bfa1f415df74b44b8eb52
ref: a0deff8
resolved-ref: a0deff80fcbca974f0e18fe47971b76bec583109
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"
@@ -1477,6 +1517,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webserial:
dependency: "direct main"
description:
name: webserial
sha256: b39c2175190ee1c75e996bf137bfee3e3e8fd06ee0ec24f0d3c10bdf7b323786
url: "https://pub.dev"
source: hosted
version: "1.2.0"
win32:
dependency: transitive
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0317.1+33
version: 2026.0322.2+39
environment:
sdk: ^3.9.2
@@ -44,7 +44,7 @@ dependencies:
meshcore_client:
git:
url: https://github.com/dz0ny/meshcore_client.git
ref: main
ref: a0deff8
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter:
@@ -71,6 +71,7 @@ dependencies:
url: https://github.com/fleaflet/flutter_map.git
ref: master
latlong2: ^0.9.0
fl_chart: ^1.2.0
http: ^1.2.0
@@ -123,6 +124,8 @@ dependencies:
nsd: ^4.0.3
usb_serial: ^0.5.2
web: ^1.1.1
flutter_libserialport: ^0.6.0
webserial: ^1.2.0
dev_dependencies:
flutter_test:

View 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==');
});
}

View 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);
});
});
}

View File

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

View File

@@ -173,14 +173,8 @@ void main() {
var updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull);
expect(
updated.displayLocation!.latitude,
closeTo(45.1234, 0.0001),
);
expect(
updated.displayLocation!.longitude,
closeTo(13.8765, 0.0001),
);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
// Invalid 0,0 GPS frame should behave the same way.
final invalidGps = CayenneLppParser.createGpsData(
@@ -192,14 +186,8 @@ void main() {
updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull);
expect(
updated.displayLocation!.latitude,
closeTo(45.1234, 0.0001),
);
expect(
updated.displayLocation!.longitude,
closeTo(13.8765, 0.0001),
);
expect(updated.displayLocation!.latitude, 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));
});
test('retains scalar telemetry but wipes stale extra sensor fields on refresh', () {
final fullTelemetry = ContactTelemetry(
gpsLocation: const LatLng(46.0569, 14.5058),
batteryPercentage: 54.0,
batteryMilliVolts: 3780,
temperature: 19.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
humidity: 58.0,
pressure: 1011.2,
extraSensorData: const {'pm25': 8.0},
);
test(
'retains scalar telemetry but wipes stale extra sensor fields on refresh',
() {
final fullTelemetry = ContactTelemetry(
gpsLocation: const LatLng(46.0569, 14.5058),
batteryPercentage: 54.0,
batteryMilliVolts: 3780,
temperature: 19.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
humidity: 58.0,
pressure: 1011.2,
extraSensorData: const {'pm25': 8.0},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: fullTelemetry),
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: fullTelemetry),
);
final batteryOnly = CayenneLppParser.createBatteryData(3.95);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
final batteryOnly = CayenneLppParser.createBatteryData(3.95);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull);
expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.telemetry!.batteryPercentage, isNotNull);
expect(updated.telemetry!.temperature, equals(19.5));
expect(updated.telemetry!.humidity, equals(58.0));
expect(updated.telemetry!.pressure, equals(1011.2));
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:battery', 0),
);
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:voltage', 0),
);
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
});
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull);
expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.telemetry!.batteryPercentage, isNotNull);
expect(updated.telemetry!.temperature, equals(19.5));
expect(updated.telemetry!.humidity, equals(58.0));
expect(updated.telemetry!.pressure, equals(1011.2));
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:battery', 0),
);
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:voltage', 0),
);
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
},
);
test('replaces old source-channel mappings when a metric moves channels', () {
final initialTelemetry = ContactTelemetry(
gpsLocation: null,
batteryPercentage: null,
batteryMilliVolts: null,
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
humidity: null,
pressure: null,
extraSensorData: const {
'__source_channel:temperature': 2,
'temperature_2': 21.5,
'humidity_4': 66.0,
},
);
test(
'replaces old source-channel mappings when a metric moves channels',
() {
final initialTelemetry = ContactTelemetry(
gpsLocation: null,
batteryPercentage: null,
batteryMilliVolts: null,
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
humidity: null,
pressure: null,
extraSensorData: const {
'__source_channel:temperature': 2,
'temperature_2': 21.5,
'humidity_4': 66.0,
},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: initialTelemetry),
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: initialTelemetry),
);
final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
23.5,
channel: 3,
);
final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
23.5,
channel: 3,
);
provider.updateTelemetry(publicKey.sublist(0, 6), movedChannelTelemetry);
provider.updateTelemetry(
publicKey.sublist(0, 6),
movedChannelTelemetry,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:temperature', 3),
);
expect(
updated.telemetry!.extraSensorData,
containsPair('temperature_3', closeTo(23.5, 0.1)),
);
expect(
updated.telemetry!.extraSensorData,
isNot(contains('temperature_2')),
);
expect(updated.telemetry!.extraSensorData, isNot(contains('humidity_4')));
});
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:temperature', 3),
);
expect(
updated.telemetry!.extraSensorData,
containsPair('temperature_3', closeTo(23.5, 0.1)),
);
expect(
updated.telemetry!.extraSensorData,
isNot(contains('temperature_2')),
);
expect(
updated.telemetry!.extraSensorData,
isNot(contains('humidity_4')),
);
},
);
test('builds message snapshot from latest valid telemetry', () {
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) {

View File

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

View File

@@ -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', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);

View File

@@ -6,6 +6,7 @@ import 'package:meshcore_sar_app/models/device_info.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_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:shared_preferences/shared_preferences.dart';
@@ -19,10 +20,17 @@ class _FakeContactsProvider extends ContactsProvider {
}
class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected})
: _isConnected = isConnected;
_FakeConnectionProvider({
required bool isConnected,
Uint8List? publicKey,
String? selfName,
}) : _isConnected = isConnected,
_publicKey = publicKey,
_selfName = selfName;
final bool _isConnected;
final Uint8List? _publicKey;
final String? _selfName;
int pingCalls = 0;
@@ -31,6 +39,8 @@ class _FakeConnectionProvider extends ConnectionProvider {
connectionState: _isConnected
? ConnectionState.connected
: ConnectionState.disconnected,
publicKey: _publicKey,
selfName: _selfName,
);
@override
@@ -65,9 +75,12 @@ void main() {
expect(provider.isLoaded, isTrue);
}
Contact buildSensorContact() {
Contact buildSensorContact({
int firstByte = 0x44,
String name = 'WX Station',
}) {
final publicKey = Uint8List(32);
publicKey[0] = 0x44;
publicKey[0] = firstByte;
return Contact(
publicKey: publicKey,
@@ -75,7 +88,7 @@ void main() {
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 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(
'unsupported auto refresh minutes normalize to nearest option',
() async {
@@ -313,6 +356,43 @@ void main() {
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(
'addSensor includes available extra telemetry fields by default',
() 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(),
);
},
);
}

View File

@@ -1,22 +1,80 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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_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/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/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/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:provider/provider.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() {
String? clipboardText;
setUp(() {
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}) {
@@ -80,8 +138,28 @@ void main() {
WidgetTester tester, {
List<Contact> contacts = const [],
List<SavedContactGroup> savedGroups = const [],
ConnectionProvider? connectionProvider,
}) async {
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) {
contactsProvider.addOrUpdateContact(contact);
}
@@ -95,10 +173,17 @@ void main() {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider<ContactsProvider>(
create: (_) => contactsProvider,
),
ChangeNotifierProvider<ConnectionProvider>(
create: (_) => resolvedConnectionProvider,
),
ChangeNotifierProvider<MessagesProvider>(
create: (_) => messagesProvider,
),
ChangeNotifierProvider(create: (_) => MapProvider()),
ChangeNotifierProvider<AppProvider>(create: (_) => appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -123,6 +208,7 @@ void main() {
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Export psk_base64'), findsNothing);
expect(find.text('Delete Channel'), findsOneWidget);
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', (
tester,
) async {
@@ -218,7 +324,25 @@ void main() {
contacts: [buildSensor(seed: 60, name: 'WX Station')],
);
expect(find.text('Sensors'), findsOneWidget);
expect(find.text('Sensors'), findsWidgets);
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);
},
);
}

View File

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

View File

@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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';
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() {
testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[];
@@ -57,8 +66,8 @@ void main() {
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
@@ -78,10 +87,9 @@ void main() {
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
rxCountReader: () => 7,
refreshListenable: refresh,
now: () => now,
),
@@ -114,10 +122,9 @@ void main() {
await tester.pump();
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('MULTI-HOP'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
expect(find.text('-84 dBm'), findsOneWidget);
expect(find.textContaining('Hash:'), findsOneWidget);
expect(
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
@@ -143,8 +150,8 @@ void main() {
);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
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.pump();
@@ -182,4 +189,43 @@ void main() {
expect(find.text('No packets for this filter'), findsNothing);
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,
);
});
}

View File

@@ -4,27 +4,16 @@ 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/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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
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() {
Contact buildSensorContact({
int firstByte = 0x44,
String name = 'WX Station',
}) {
final publicKey = Uint8List(32);
publicKey[0] = 0x44;
publicKey[0] = firstByte;
return Contact(
publicKey: publicKey,
@@ -32,7 +21,7 @@ void main() {
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 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 sensorsProvider = SensorsProvider();
final contactsProvider = ContactsProvider();
await waitUntilLoaded(sensorsProvider);
contactsProvider.addOrUpdateContact(contact);
await sensorsProvider.addSensor(contact);
final option = sensorMetricOptionsFor(contact, labelOverrides: const {})
.firstWhere(
(entry) => entry.key.contains('illuminance') && entry.channel == 2,
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<ContactsProvider>.value(
value: contactsProvider,
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
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.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(
find.byKey(
const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'),
),
find.byKey(ValueKey('sensor_selector_metric_${option.key}')),
findsOneWidget,
);
expect(find.text('Channel 2'), findsOneWidget);
});
testWidgets('add sensor sheet keeps close action available when scrolled', (
tester,
) async {
tester.view.physicalSize = const Size(320, 480);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var didClose = false;
final candidates = List<Contact>.generate(
20,
(index) =>
buildSensorContact(firstByte: index + 1, name: 'Sensor ${index + 1}'),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AddSensorSheet(
candidates: candidates,
onSelect: (_) async {},
onClose: () {
didClose = true;
},
),
),
),
);
await tester.pump();
final closeButton = find.byTooltip('Close');
expect(closeButton, findsOneWidget);
await tester.drag(find.byType(ListView).last, const Offset(0, -600));
await tester.pump();
expect(closeButton, findsOneWidget);
await tester.tap(closeButton);
await tester.pump();
expect(didClose, isTrue);
});
}

View File

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

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