mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
7 Commits
v2026.0412
...
feat/repor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5834454087 | ||
|
|
73e78691ed | ||
|
|
d067095345 | ||
|
|
0b463f6454 | ||
|
|
a472b0c05c | ||
|
|
4181b33da1 | ||
|
|
f9dfdc2340 |
72
.github/workflows/build-artifacts.yml
vendored
72
.github/workflows/build-artifacts.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
@@ -44,6 +44,14 @@ jobs:
|
||||
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: Setup Ruby
|
||||
if: github.event_name == 'release'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: true
|
||||
working-directory: android
|
||||
|
||||
- name: Cache Android NDK
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -55,22 +63,76 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate Android release secrets
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
test -n "$ANDROID_KEY_ALIAS"
|
||||
test -n "$ANDROID_KEY_PASSWORD"
|
||||
test -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
|
||||
|
||||
- name: Create Android signing files
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/release-keystore.jks
|
||||
cat <<EOF > android/key.properties
|
||||
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||
keyPassword=$ANDROID_KEY_PASSWORD
|
||||
keyAlias=$ANDROID_KEY_ALIAS
|
||||
storeFile=../release-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Prepare APK artifact
|
||||
- name: Build Play release AAB
|
||||
if: github.event_name == 'release'
|
||||
run: flutter build appbundle --release
|
||||
|
||||
- name: Prepare Android artifacts
|
||||
run: |
|
||||
RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
TAG="${RAW_TAG//\//-}"
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "${APP_NAME}-${TAG}-android.apk"
|
||||
if [ -f build/app/outputs/bundle/release/app-release.aab ]; then
|
||||
cp build/app/outputs/bundle/release/app-release.aab "${APP_NAME}-${TAG}-play.aab"
|
||||
fi
|
||||
|
||||
- name: Upload APK artifact
|
||||
- name: Upload Android artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-android
|
||||
path: "${{ env.APP_NAME }}-*-android.apk"
|
||||
path: |
|
||||
${{ env.APP_NAME }}-*-android.apk
|
||||
${{ env.APP_NAME }}-*-play.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Play release to internal testing
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
SKIP_ANDROID_BUILD: "1"
|
||||
run: cd android && bundle exec fastlane android internal
|
||||
|
||||
- name: Upload Play release to production
|
||||
if: github.event_name == 'release' && !github.event.release.prerelease
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
SKIP_ANDROID_BUILD: "1"
|
||||
run: cd android && bundle exec fastlane android production
|
||||
|
||||
build-linux:
|
||||
name: Build Linux App
|
||||
runs-on: ubuntu-latest
|
||||
@@ -348,6 +410,7 @@ jobs:
|
||||
body: |
|
||||
Release assets are published for all supported build targets:
|
||||
- Android (`.apk`)
|
||||
- Google Play (`.aab`)
|
||||
- Linux (`.tar.gz`)
|
||||
- macOS (`.dmg`)
|
||||
- Windows (`.zip`)
|
||||
@@ -362,6 +425,7 @@ jobs:
|
||||
4. Install the signed `.ipa` on your iPhone using Apple Configurator 2 or Xcode (Devices and Simulators).
|
||||
files: |
|
||||
dist/*.apk
|
||||
dist/*.aab
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
|
||||
4
android/Gemfile
Normal file
4
android/Gemfile
Normal file
@@ -0,0 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "ostruct"
|
||||
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Bluetooth permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
@@ -62,6 +63,12 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<service
|
||||
android:name="id.flutter.flutter_background_service.BackgroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location"
|
||||
android:stopWithTask="false"
|
||||
tools:replace="android:exported" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -16,44 +16,72 @@
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
def project_root
|
||||
File.expand_path("../..", __dir__)
|
||||
end
|
||||
|
||||
def play_service_account_json
|
||||
ENV.fetch("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON")
|
||||
end
|
||||
|
||||
def play_build_command
|
||||
"cd #{project_root} && flutter build appbundle --release"
|
||||
end
|
||||
|
||||
def play_aab_path
|
||||
File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
|
||||
end
|
||||
|
||||
def play_package_name
|
||||
"com.meshcore.sar.meshcore_sar_app"
|
||||
end
|
||||
|
||||
def direct_apk_build_command
|
||||
"cd #{project_root} && flutter build apk --release"
|
||||
end
|
||||
|
||||
def direct_apk_path
|
||||
File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
|
||||
end
|
||||
|
||||
def skip_android_build?
|
||||
ENV["SKIP_ANDROID_BUILD"] == "1"
|
||||
end
|
||||
|
||||
desc "Runs all the tests"
|
||||
lane :test do
|
||||
gradle(task: "test")
|
||||
end
|
||||
|
||||
desc "Build release APK"
|
||||
lane :beta do
|
||||
# Get the project root directory (two levels up from android/fastlane/)
|
||||
project_root = File.expand_path("../..", __dir__)
|
||||
lane :direct_apk do
|
||||
sh(direct_apk_build_command)
|
||||
|
||||
# Build APK with Flutter
|
||||
sh("cd #{project_root} && flutter build apk --release")
|
||||
|
||||
apk_path = File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
|
||||
|
||||
# Uncomment to distribute via Firebase App Distribution:
|
||||
# firebase_app_distribution(
|
||||
# app: "YOUR_FIREBASE_APP_ID",
|
||||
# apk_path: apk_path,
|
||||
# groups: "testers"
|
||||
# )
|
||||
|
||||
UI.success("APK built at: #{apk_path}")
|
||||
UI.success("APK built successfully at: #{direct_apk_path}")
|
||||
end
|
||||
|
||||
desc "Deploy a new version to the Google Play"
|
||||
lane :deploy do
|
||||
# Get the project root directory (two levels up from android/fastlane/)
|
||||
project_root = File.expand_path("../..", __dir__)
|
||||
|
||||
# Build AAB with Flutter (required for Play Store since 2021)
|
||||
sh("cd #{project_root} && flutter build appbundle --release")
|
||||
|
||||
aab_path = File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
|
||||
desc "Build Play release AAB and upload to internal testing"
|
||||
lane :internal do
|
||||
sh(play_build_command) unless skip_android_build?
|
||||
|
||||
upload_to_play_store(
|
||||
aab: aab_path,
|
||||
track: "internal"
|
||||
aab: play_aab_path,
|
||||
package_name: play_package_name,
|
||||
track: "internal",
|
||||
release_status: "draft",
|
||||
json_key_data: play_service_account_json
|
||||
)
|
||||
end
|
||||
|
||||
desc "Build Play release AAB and upload to production"
|
||||
lane :production do
|
||||
sh(play_build_command) unless skip_android_build?
|
||||
|
||||
upload_to_play_store(
|
||||
aab: play_aab_path,
|
||||
package_name: play_package_name,
|
||||
track: "production",
|
||||
json_key_data: play_service_account_json
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
128
docs/google-play-release.md
Normal file
128
docs/google-play-release.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Google Play Release Runbook
|
||||
|
||||
## Purpose
|
||||
|
||||
This runbook prepares and ships the Android release build for `com.meshcore.sar.meshcore_sar_app`.
|
||||
|
||||
## Distribution Modes
|
||||
|
||||
- GitHub release: signed APK for direct install or manual distribution.
|
||||
- Google Play: signed AAB uploaded through Fastlane to Play internal testing and, for non-prerelease GitHub releases, production.
|
||||
|
||||
## Required Secrets and Local Files
|
||||
|
||||
GitHub Actions secrets for `dz0ny/meshcore-sar`:
|
||||
|
||||
- `ANDROID_KEYSTORE_BASE64`
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
|
||||
|
||||
Local files used to seed the secrets:
|
||||
|
||||
- `android/key.properties`
|
||||
- release keystore referenced by `android/key.properties`
|
||||
- `/Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json`
|
||||
|
||||
The Play Console service account must have release access for `com.meshcore.sar.meshcore_sar_app`.
|
||||
|
||||
## CI Release Flow
|
||||
|
||||
The release flow runs from `.github/workflows/build-artifacts.yml` when a GitHub release is published.
|
||||
|
||||
Android release steps:
|
||||
|
||||
1. Validate all Android signing and Google Play secrets.
|
||||
2. Recreate `android/release-keystore.jks` and `android/key.properties` from GitHub secrets.
|
||||
3. Build the signed APK with `flutter build apk --release`.
|
||||
4. Build the signed Play AAB with `flutter build appbundle --release`.
|
||||
5. Upload the APK and AAB as GitHub release assets.
|
||||
6. Upload the AAB to Play internal testing with `bundle exec fastlane android internal`.
|
||||
7. Upload the AAB to Play production when the GitHub release is not marked as prerelease.
|
||||
|
||||
The same workflow also builds Linux, macOS, Windows, iOS unsigned, and web artifacts.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- Version source of truth: `pubspec.yaml`.
|
||||
- Android `versionCode` is the number after `+`.
|
||||
- `versionCode` must always increase for Play uploads.
|
||||
- `make bump`, `make build`, and `make bundle` increment the version.
|
||||
- Use `make build-no-bump` or `make bundle-no-bump` only when intentionally rebuilding the same version locally.
|
||||
|
||||
## Manual Build Commands
|
||||
|
||||
Run commands from the repo root.
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
Repo shortcuts:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make bundle
|
||||
make build-no-bump
|
||||
make bundle-no-bump
|
||||
```
|
||||
|
||||
## Fastlane Lanes
|
||||
|
||||
Run commands from `android/`.
|
||||
|
||||
```bash
|
||||
bundle exec fastlane android direct_apk
|
||||
bundle exec fastlane android internal
|
||||
bundle exec fastlane android production
|
||||
```
|
||||
|
||||
Set `SKIP_ANDROID_BUILD=1` when an AAB has already been built and Fastlane should only upload it.
|
||||
|
||||
```bash
|
||||
SKIP_ANDROID_BUILD=1 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON="$(cat /Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json)" bundle exec fastlane android internal
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- APK: `build/app/outputs/flutter-apk/app-release.apk`
|
||||
- AAB: `build/app/outputs/bundle/release/app-release.aab`
|
||||
- Release APK asset: `meshcore-sar-<tag>-android.apk`
|
||||
- Release AAB asset: `meshcore-sar-<tag>-play.aab`
|
||||
|
||||
## Internal Testing Release Checklist
|
||||
|
||||
1. Confirm `pubspec.yaml` version is correct and `versionCode` increased.
|
||||
2. Confirm `android/key.properties` points to the release keystore for local builds.
|
||||
3. Confirm all five GitHub Actions secrets exist.
|
||||
4. Publish a prerelease in GitHub to build assets and upload Play internal testing without production promotion.
|
||||
5. Install from the internal testing track on a real Android device.
|
||||
6. Smoke test startup, permissions, map, messaging, telemetry, and offline map behavior.
|
||||
|
||||
## Production Release Checklist
|
||||
|
||||
1. Complete internal testing validation first.
|
||||
2. Confirm Play Console forms are current:
|
||||
- App content
|
||||
- Data safety
|
||||
- App access
|
||||
- Ads declaration
|
||||
- Content rating
|
||||
3. Confirm store assets are current:
|
||||
- app icon
|
||||
- feature graphic
|
||||
- phone screenshots
|
||||
- tablet screenshots if used
|
||||
- support URL
|
||||
- privacy policy URL
|
||||
4. Publish a non-prerelease GitHub release.
|
||||
5. Confirm the GitHub release has the APK and AAB assets.
|
||||
6. Confirm Play Console has the new internal and production release entries.
|
||||
|
||||
## Rollback Rules
|
||||
|
||||
- Never reuse or lower an Android `versionCode`.
|
||||
- If a Play release is bad, halt rollout in Play Console and ship a new higher-version fix.
|
||||
- Keep GitHub direct APK and Play AAB artifacts separate; do not upload APKs to Play.
|
||||
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 137;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
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 = 137;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
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 = 137;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
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 = 137;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
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 = 137;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
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 = 137;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>137</string>
|
||||
<string>141</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000194">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000252">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.331157">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.48394">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="116.573845">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="153.436107">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="420.572527">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="6642.682605">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -2018,9 +2018,9 @@
|
||||
},
|
||||
"reply": "Reply",
|
||||
"@reply": {},
|
||||
"technicalDetails": "Technical details",
|
||||
"technicalDetails": "Details",
|
||||
"@technicalDetails": {},
|
||||
"messageTechnicalDetails": "Message technical details",
|
||||
"messageTechnicalDetails": "Message details",
|
||||
"@messageTechnicalDetails": {},
|
||||
"linkQuality": "Link quality",
|
||||
"@linkQuality": {},
|
||||
|
||||
@@ -2769,13 +2769,13 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @technicalDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Technical details'**
|
||||
/// **'Details'**
|
||||
String get technicalDetails;
|
||||
|
||||
/// No description provided for @messageTechnicalDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Message technical details'**
|
||||
/// **'Message details'**
|
||||
String get messageTechnicalDetails;
|
||||
|
||||
/// No description provided for @linkQuality.
|
||||
|
||||
@@ -1471,10 +1471,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get reply => 'Reply';
|
||||
|
||||
@override
|
||||
String get technicalDetails => 'Technical details';
|
||||
String get technicalDetails => 'Details';
|
||||
|
||||
@override
|
||||
String get messageTechnicalDetails => 'Message technical details';
|
||||
String get messageTechnicalDetails => 'Message details';
|
||||
|
||||
@override
|
||||
String get linkQuality => 'Link quality';
|
||||
|
||||
@@ -1773,7 +1773,13 @@ class AppProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
'🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount',
|
||||
);
|
||||
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
|
||||
messagesProvider.handleMessageEcho(
|
||||
messageId,
|
||||
echoCount,
|
||||
snrRaw,
|
||||
rssiDbm,
|
||||
pathBytes: _latestChannelEchoPathBytes(),
|
||||
);
|
||||
};
|
||||
|
||||
connectionProvider.prepareDirectMessageSendCallback =
|
||||
@@ -4056,6 +4062,23 @@ class AppProvider with ChangeNotifier {
|
||||
return decoded.pathBytes;
|
||||
}
|
||||
|
||||
Uint8List? _latestChannelEchoPathBytes() {
|
||||
for (final log in connectionProvider.bleService.packetLogs.reversed) {
|
||||
if (log.responseCode != 0x88) continue;
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null ||
|
||||
decoded.payloadType != 0x05 ||
|
||||
decoded.pathBytes.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Uint8List.fromList(decoded.pathBytes);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
||||
|
||||
|
||||
@@ -1263,11 +1263,17 @@ class ContactsProvider with ChangeNotifier {
|
||||
Map<String, dynamic>? existingExtraSensorData,
|
||||
Map<String, dynamic>? incomingExtraSensorData,
|
||||
) {
|
||||
final merged = <String, dynamic>{...?existingExtraSensorData};
|
||||
if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) {
|
||||
return merged;
|
||||
return <String, dynamic>{...?existingExtraSensorData};
|
||||
}
|
||||
|
||||
final isFullTelemetryRefresh = incomingExtraSensorData.containsKey(
|
||||
_rawTelemetryHexKey,
|
||||
);
|
||||
final merged = <String, dynamic>{
|
||||
if (!isFullTelemetryRefresh) ...?existingExtraSensorData,
|
||||
};
|
||||
|
||||
final incomingMetricFamilies = incomingExtraSensorData.keys
|
||||
.map(_telemetryMetricFamilyForKey)
|
||||
.whereType<String>()
|
||||
|
||||
@@ -39,6 +39,7 @@ class MapProvider with ChangeNotifier {
|
||||
|
||||
LocationTrail? _currentTrail;
|
||||
bool _isTrailVisible = true;
|
||||
bool _isTrailRecordingEnabled = true;
|
||||
final List<LocationTrail> _trailHistory = [];
|
||||
|
||||
bool _showCadastralOverlay = false;
|
||||
@@ -71,6 +72,7 @@ class MapProvider with ChangeNotifier {
|
||||
String? get targetMapId => _targetMapId;
|
||||
LocationTrail? get currentTrail => _currentTrail;
|
||||
bool get isTrailVisible => _isTrailVisible;
|
||||
bool get isTrailRecordingEnabled => _isTrailRecordingEnabled;
|
||||
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||
|
||||
@@ -373,6 +375,10 @@ class MapProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||
if (!_isTrailRecordingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||
startTrail();
|
||||
}
|
||||
@@ -405,6 +411,24 @@ class MapProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setTrailRecordingEnabled(bool enabled) {
|
||||
if (_isTrailRecordingEnabled == enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isTrailRecordingEnabled = enabled;
|
||||
if (!enabled) {
|
||||
if (_currentTrail != null) {
|
||||
endTrail();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearCurrentTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail = null;
|
||||
@@ -698,6 +722,7 @@ class MapProvider with ChangeNotifier {
|
||||
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
|
||||
'importedTrail': _importedTrail?.toJson(),
|
||||
'isTrailVisible': _isTrailVisible,
|
||||
'isTrailRecordingEnabled': _isTrailRecordingEnabled,
|
||||
'showCadastralOverlay': _showCadastralOverlay,
|
||||
'showForestRoadsOverlay': _showForestRoadsOverlay,
|
||||
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
|
||||
@@ -728,6 +753,8 @@ class MapProvider with ChangeNotifier {
|
||||
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
|
||||
: null;
|
||||
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
|
||||
_isTrailRecordingEnabled =
|
||||
json['isTrailRecordingEnabled'] as bool? ?? true;
|
||||
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
|
||||
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
|
||||
@@ -198,6 +198,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messages[index] = _messages[index].copyWith(
|
||||
usedFloodFallback: selection.usesFlood,
|
||||
pathLen: nextPathLen,
|
||||
pathBytes: selection.hasDirectPath
|
||||
? Uint8List.fromList(selection.pathBytes)
|
||||
: Uint8List(0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -803,15 +806,30 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (contactLocationSnapshot != null) {
|
||||
_messageContactLocations[existingId] = contactLocationSnapshot;
|
||||
}
|
||||
_messageReceptionDetails[existingId] =
|
||||
MessageReceptionDetails.mergeDuplicate(
|
||||
existing: _messageReceptionDetails[existingId],
|
||||
incoming: receptionDetailsSnapshot,
|
||||
);
|
||||
final mergedReceptionDetails = MessageReceptionDetails.mergeDuplicate(
|
||||
existing: _messageReceptionDetails[existingId],
|
||||
incoming: receptionDetailsSnapshot,
|
||||
);
|
||||
_messageReceptionDetails[existingId] = mergedReceptionDetails;
|
||||
final existingMessage = _messages[matchingSentReplayIndex];
|
||||
final routeMetadata = _messageRouteMetadata[existingId];
|
||||
_messages[matchingSentReplayIndex] = existingMessage.copyWith(
|
||||
pathLen: finalMessage.pathLen > 0 ? finalMessage.pathLen : existingMessage.pathLen,
|
||||
pathBytes: finalMessage.pathBytes ?? existingMessage.pathBytes,
|
||||
echoCount: _mergeSentReplayEchoCount(
|
||||
existingMessage,
|
||||
mergedReceptionDetails,
|
||||
),
|
||||
pathLen: _mergeSentReplayPathLen(
|
||||
existingMessage,
|
||||
finalMessage,
|
||||
routeMetadata,
|
||||
),
|
||||
pathBytes: _mergeSentReplayPathBytes(
|
||||
existingMessage,
|
||||
finalMessage,
|
||||
routeMetadata,
|
||||
),
|
||||
firstEchoAt: existingMessage.firstEchoAt ?? DateTime.now(),
|
||||
lastEchoAt: DateTime.now(),
|
||||
);
|
||||
_persistMessages();
|
||||
return;
|
||||
@@ -1032,6 +1050,51 @@ class MessagesProvider with ChangeNotifier {
|
||||
return _matchesDuplicateSenderIdentity(existing, message);
|
||||
}
|
||||
|
||||
int _mergeSentReplayEchoCount(
|
||||
Message existing,
|
||||
MessageReceptionDetails mergedReceptionDetails,
|
||||
) {
|
||||
final replayCount = mergedReceptionDetails.receivedCopies > 0
|
||||
? mergedReceptionDetails.receivedCopies - 1
|
||||
: 0;
|
||||
return replayCount > existing.echoCount ? replayCount : existing.echoCount;
|
||||
}
|
||||
|
||||
int _mergeSentReplayPathLen(
|
||||
Message existing,
|
||||
Message incoming,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
if (routeMetadata?.mode == PathSelectionMode.flood ||
|
||||
existing.usedFloodFallback) {
|
||||
return existing.pathLen;
|
||||
}
|
||||
|
||||
final routeHopCount = routeMetadata?.hopCount;
|
||||
if (routeHopCount != null && routeHopCount > 0) {
|
||||
return routeHopCount;
|
||||
}
|
||||
|
||||
if (existing.pathLen > 0) {
|
||||
return existing.pathLen;
|
||||
}
|
||||
|
||||
return incoming.pathLen > 0 ? incoming.pathLen : existing.pathLen;
|
||||
}
|
||||
|
||||
Uint8List? _mergeSentReplayPathBytes(
|
||||
Message existing,
|
||||
Message incoming,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
if (routeMetadata?.mode == PathSelectionMode.flood ||
|
||||
existing.usedFloodFallback) {
|
||||
return existing.pathBytes;
|
||||
}
|
||||
|
||||
return existing.pathBytes ?? incoming.pathBytes;
|
||||
}
|
||||
|
||||
/// Add multiple messages
|
||||
void addMessages(List<Message> messages) {
|
||||
int addedCount = 0;
|
||||
@@ -2166,7 +2229,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
int echoCount,
|
||||
int snrRaw,
|
||||
int rssiDbm,
|
||||
) {
|
||||
{
|
||||
Uint8List? pathBytes,
|
||||
}) {
|
||||
debugPrint('🔊 [MessagesProvider] handleMessageEcho called');
|
||||
debugPrint(' Message ID: $messageId');
|
||||
debugPrint(' Echo count: $echoCount');
|
||||
@@ -2181,18 +2246,35 @@ class MessagesProvider with ChangeNotifier {
|
||||
' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
|
||||
);
|
||||
|
||||
final nextEchoCount = echoCount > message.echoCount
|
||||
? echoCount
|
||||
: message.echoCount + 1;
|
||||
|
||||
// Update echo count
|
||||
final updatedMessage = message.copyWith(
|
||||
echoCount: echoCount,
|
||||
echoCount: nextEchoCount,
|
||||
firstEchoAt: message.firstEchoAt ?? DateTime.now(),
|
||||
lastEchoSnrRaw: snrRaw.toSigned(8),
|
||||
lastEchoRssiDbm: rssiDbm.toSigned(8),
|
||||
lastEchoAt: DateTime.now(),
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
_messageReceptionDetails[messageId] = _messageReceptionDetails[messageId]
|
||||
?.copyWith(
|
||||
capturedAt: DateTime.now(),
|
||||
rssiDbm: rssiDbm.toSigned(8),
|
||||
snrDb: snrRaw.toSigned(8) / 4.0,
|
||||
pathBytes: pathBytes?.toList(),
|
||||
) ??
|
||||
MessageReceptionDetails(
|
||||
capturedAt: DateTime.now(),
|
||||
rssiDbm: rssiDbm.toSigned(8),
|
||||
snrDb: snrRaw.toSigned(8) / 4.0,
|
||||
pathBytes: pathBytes?.toList(),
|
||||
);
|
||||
_clearChannelSendWarning(messageId);
|
||||
|
||||
debugPrint(' Updated echo count to: $echoCount');
|
||||
debugPrint(' Updated echo count to: $nextEchoCount');
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
debugPrint(' ✅ Echo update complete, UI notified');
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -14,8 +17,13 @@ class BackgroundLocationService {
|
||||
static const String _prefKeyDistance = 'background_tracking_distance';
|
||||
static const String _prefKeyLastLat = 'background_last_lat';
|
||||
static const String _prefKeyLastLon = 'background_last_lon';
|
||||
static const String _notificationChannelId =
|
||||
'meshcore_sar_background_tracking';
|
||||
static const int _notificationId = 9101;
|
||||
|
||||
MeshCoreBleService? _bleService;
|
||||
final FlutterBackgroundService _service = FlutterBackgroundService();
|
||||
bool _serviceConfigured = false;
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
@@ -33,8 +41,6 @@ class BackgroundLocationService {
|
||||
/// Start location tracking and automatic advertisement
|
||||
/// Returns true if successful, false otherwise
|
||||
///
|
||||
/// Note: This is foreground tracking. For true background operation,
|
||||
/// additional platform-specific configuration is required.
|
||||
Future<bool> startTracking({double distanceThreshold = 10.0}) async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
debugPrint(
|
||||
@@ -80,6 +86,8 @@ class BackgroundLocationService {
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
|
||||
|
||||
await _startForegroundService(distanceThreshold);
|
||||
|
||||
// Start listening to position updates
|
||||
Position? lastPosition;
|
||||
try {
|
||||
@@ -171,12 +179,87 @@ class BackgroundLocationService {
|
||||
debugPrint('🛑 [BackgroundLocation] Stopping tracking');
|
||||
await _positionSubscription?.cancel();
|
||||
_positionSubscription = null;
|
||||
await _stopForegroundService();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
|
||||
debugPrint('✅ [BackgroundLocation] Tracking stopped');
|
||||
}
|
||||
|
||||
Future<void> _startForegroundService(double distanceThreshold) async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!_serviceConfigured) {
|
||||
await _configureForegroundService();
|
||||
}
|
||||
|
||||
final running = await _service.isRunning();
|
||||
if (!running) {
|
||||
await _service.startService();
|
||||
}
|
||||
|
||||
_service.invoke('trackingUpdate', {
|
||||
'distanceThreshold': distanceThreshold,
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Foreground service start failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopForegroundService() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await _service.isRunning()) {
|
||||
_service.invoke('stopService');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Foreground service stop failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _configureForegroundService() async {
|
||||
const channel = AndroidNotificationChannel(
|
||||
_notificationChannelId,
|
||||
'Background tracking',
|
||||
description:
|
||||
'Keeps MeshCore SAR location sharing active while the app is in the background.',
|
||||
importance: Importance.low,
|
||||
);
|
||||
|
||||
final notifications = FlutterLocalNotificationsPlugin();
|
||||
await notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
await _service.configure(
|
||||
androidConfiguration: AndroidConfiguration(
|
||||
onStart: meshCoreSarBackgroundServiceStart,
|
||||
autoStart: false,
|
||||
autoStartOnBoot: false,
|
||||
isForegroundMode: true,
|
||||
notificationChannelId: _notificationChannelId,
|
||||
initialNotificationTitle: 'MeshCore SAR',
|
||||
initialNotificationContent: 'Maintaining background tracking',
|
||||
foregroundServiceNotificationId: _notificationId,
|
||||
foregroundServiceTypes: [AndroidForegroundType.location],
|
||||
),
|
||||
iosConfiguration: IosConfiguration(
|
||||
autoStart: false,
|
||||
onForeground: meshCoreSarBackgroundServiceStart,
|
||||
onBackground: meshCoreSarBackgroundServiceIos,
|
||||
),
|
||||
);
|
||||
_serviceConfigured = true;
|
||||
}
|
||||
|
||||
/// Update the distance threshold for location updates
|
||||
/// Note: This will restart tracking with the new threshold
|
||||
Future<void> updateDistanceThreshold(double distance) async {
|
||||
@@ -212,3 +295,40 @@ class BackgroundLocationService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<bool> meshCoreSarBackgroundServiceIos(ServiceInstance service) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
return true;
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void meshCoreSarBackgroundServiceStart(ServiceInstance service) {
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
if (service is AndroidServiceInstance) {
|
||||
service.setAsForegroundService();
|
||||
service.setForegroundNotificationInfo(
|
||||
title: 'MeshCore SAR',
|
||||
content: 'Maintaining background tracking',
|
||||
);
|
||||
}
|
||||
|
||||
service.on('trackingUpdate').listen((event) {
|
||||
if (service is AndroidServiceInstance) {
|
||||
final threshold = event?['distanceThreshold'];
|
||||
final suffix = threshold is num
|
||||
? ' (${threshold.toStringAsFixed(0)} m updates)'
|
||||
: '';
|
||||
service.setForegroundNotificationInfo(
|
||||
title: 'MeshCore SAR',
|
||||
content: 'Maintaining background tracking$suffix',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
service.on('stopService').listen((event) {
|
||||
service.stopSelf();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,18 +5,23 @@ import 'package:nsd/nsd.dart';
|
||||
|
||||
/// Discovered MeshCore device on the network (TCP/WiFi)
|
||||
class DiscoveredServer {
|
||||
final String name;
|
||||
final String ipAddress;
|
||||
final int port;
|
||||
final int responseTime; // milliseconds
|
||||
|
||||
const DiscoveredServer({
|
||||
required this.name,
|
||||
required this.ipAddress,
|
||||
required this.port,
|
||||
required this.responseTime,
|
||||
});
|
||||
|
||||
String get displayName => name.trim().isNotEmpty ? name.trim() : ipAddress;
|
||||
|
||||
@override
|
||||
String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
|
||||
String toString() =>
|
||||
'DiscoveredServer($displayName @ $ipAddress:$port, ${responseTime}ms)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -46,6 +51,7 @@ class NetworkScannerService {
|
||||
|
||||
bool _isScanning = false;
|
||||
bool get isScanning => _isScanning;
|
||||
int _scanSession = 0;
|
||||
|
||||
List<DiscoveredServer> _cachedServers = [];
|
||||
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
|
||||
@@ -88,7 +94,11 @@ class NetworkScannerService {
|
||||
}
|
||||
|
||||
/// Try a raw TCP connect to check if the MeshCore TCP server is listening.
|
||||
Future<DiscoveredServer?> _checkDevice(String ip, int port) async {
|
||||
Future<DiscoveredServer?> _checkDevice(
|
||||
String ip,
|
||||
int port, {
|
||||
String? name,
|
||||
}) async {
|
||||
final sw = Stopwatch()..start();
|
||||
Socket? socket;
|
||||
try {
|
||||
@@ -101,6 +111,7 @@ class NetworkScannerService {
|
||||
debugPrint(
|
||||
'✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
|
||||
return DiscoveredServer(
|
||||
name: (name ?? ip).trim(),
|
||||
ipAddress: ip,
|
||||
port: port,
|
||||
responseTime: sw.elapsedMilliseconds,
|
||||
@@ -117,7 +128,10 @@ class NetworkScannerService {
|
||||
|
||||
// ── mDNS discovery ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async {
|
||||
Future<List<DiscoveredServer>> _discoverViaMdns({
|
||||
int? port,
|
||||
required int scanSession,
|
||||
}) async {
|
||||
final scanPort = port ?? defaultPort;
|
||||
final found = <DiscoveredServer>[];
|
||||
|
||||
@@ -131,12 +145,25 @@ class NetworkScannerService {
|
||||
);
|
||||
|
||||
await Future.delayed(bonjourTimeout);
|
||||
if (!_isScanSessionActive(scanSession)) {
|
||||
return found;
|
||||
}
|
||||
|
||||
for (final service in _activeDiscovery?.services ?? []) {
|
||||
if (!_isScanSessionActive(scanSession)) {
|
||||
break;
|
||||
}
|
||||
for (final addr in service.addresses ?? []) {
|
||||
if (!_isScanSessionActive(scanSession)) {
|
||||
break;
|
||||
}
|
||||
if (localIps.contains(addr.address)) continue;
|
||||
final result =
|
||||
await _checkDevice(addr.address, service.port ?? scanPort);
|
||||
await _checkDevice(
|
||||
addr.address,
|
||||
service.port ?? scanPort,
|
||||
name: service.name,
|
||||
);
|
||||
if (result != null) {
|
||||
found.add(result);
|
||||
onServerDiscovered?.call(result);
|
||||
@@ -163,7 +190,10 @@ class NetworkScannerService {
|
||||
|
||||
// ── Port scan fallback ─────────────────────────────────────────────────────
|
||||
|
||||
Future<List<DiscoveredServer>> _scanByPort({int? port}) async {
|
||||
Future<List<DiscoveredServer>> _scanByPort({
|
||||
int? port,
|
||||
required int scanSession,
|
||||
}) async {
|
||||
final scanPort = port ?? defaultPort;
|
||||
final found = <DiscoveredServer>[];
|
||||
|
||||
@@ -175,10 +205,12 @@ class NetworkScannerService {
|
||||
'🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
|
||||
|
||||
int scanned = 0;
|
||||
for (int i = 0; i < ips.length; i += parallelScans) {
|
||||
for (int i = 0; i < ips.length && _isScanSessionActive(scanSession); i += parallelScans) {
|
||||
final batch = ips.skip(i).take(parallelScans).toList();
|
||||
final results =
|
||||
await Future.wait(batch.map((ip) => _checkDevice(ip, scanPort)));
|
||||
await Future.wait(
|
||||
batch.map((ip) => _checkDevice(ip, scanPort, name: ip)),
|
||||
);
|
||||
|
||||
for (final result in results) {
|
||||
if (result != null && !localIps.contains(result.ipAddress)) {
|
||||
@@ -200,28 +232,51 @@ class NetworkScannerService {
|
||||
Future<List<DiscoveredServer>> scan({int? port}) async {
|
||||
if (_isScanning) return [];
|
||||
_isScanning = true;
|
||||
final scanSession = ++_scanSession;
|
||||
|
||||
try {
|
||||
var found = await _discoverViaMdns(port: port);
|
||||
if (found.isEmpty) {
|
||||
var found = await _discoverViaMdns(port: port, scanSession: scanSession);
|
||||
if (_isScanSessionActive(scanSession) && found.isEmpty) {
|
||||
debugPrint(
|
||||
'🔍 [NetworkScanner] mDNS found nothing, falling back to port scan');
|
||||
found = await _scanByPort(port: port);
|
||||
found = await _scanByPort(port: port, scanSession: scanSession);
|
||||
}
|
||||
if (_isScanSessionActive(scanSession)) {
|
||||
_cachedServers = found;
|
||||
}
|
||||
_cachedServers = found;
|
||||
return found;
|
||||
} finally {
|
||||
_isScanning = false;
|
||||
if (_scanSession == scanSession) {
|
||||
_isScanning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a previously discovered device is still reachable.
|
||||
Future<bool> verifyServer(DiscoveredServer server) async {
|
||||
final result = await _checkDevice(server.ipAddress, server.port);
|
||||
final result = await _checkDevice(
|
||||
server.ipAddress,
|
||||
server.port,
|
||||
name: server.name,
|
||||
);
|
||||
return result != null;
|
||||
}
|
||||
|
||||
void clearCache() => _cachedServers = [];
|
||||
|
||||
void stopScan() => _isScanning = false;
|
||||
bool _isScanSessionActive(int session) => _isScanning && _scanSession == session;
|
||||
|
||||
void stopScan() {
|
||||
_scanSession += 1;
|
||||
_isScanning = false;
|
||||
final activeDiscovery = _activeDiscovery;
|
||||
_activeDiscovery = null;
|
||||
if (activeDiscovery != null) {
|
||||
unawaited(
|
||||
stopDiscovery(activeDiscovery).catchError((Object error) {
|
||||
debugPrint('⚠️ [NetworkScanner] Failed to stop mDNS discovery: $error');
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
lib/services/recent_tcp_connections_service.dart
Normal file
102
lib/services/recent_tcp_connections_service.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class RecentTcpConnection {
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final DateTime lastUsedAt;
|
||||
|
||||
const RecentTcpConnection({
|
||||
required this.name,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.lastUsedAt,
|
||||
});
|
||||
|
||||
Map<String, Object> toJson() => <String, Object>{
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory RecentTcpConnection.fromJson(Map<String, dynamic> json) {
|
||||
return RecentTcpConnection(
|
||||
name: (json['name'] as String?)?.trim().isNotEmpty == true
|
||||
? (json['name'] as String).trim()
|
||||
: ((json['host'] as String?) ?? '').trim(),
|
||||
host: ((json['host'] as String?) ?? '').trim(),
|
||||
port: (json['port'] as num?)?.toInt() ?? 0,
|
||||
lastUsedAt:
|
||||
DateTime.tryParse((json['lastUsedAt'] as String?) ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecentTcpConnectionsService {
|
||||
static const String _prefsKey = 'recent_tcp_connections_v1';
|
||||
static const int _maxEntries = 5;
|
||||
|
||||
static Future<List<RecentTcpConnection>> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedEntries = prefs.getStringList(_prefsKey) ?? const <String>[];
|
||||
|
||||
final connections = <RecentTcpConnection>[];
|
||||
for (final entry in storedEntries) {
|
||||
try {
|
||||
final decoded = jsonDecode(entry);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
final connection = RecentTcpConnection.fromJson(decoded);
|
||||
if (connection.host.isEmpty || connection.port <= 0) {
|
||||
continue;
|
||||
}
|
||||
connections.add(connection);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
connections.sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
return connections.take(_maxEntries).toList(growable: false);
|
||||
}
|
||||
|
||||
static Future<List<RecentTcpConnection>> remember({
|
||||
required String name,
|
||||
required String host,
|
||||
required int port,
|
||||
}) async {
|
||||
final trimmedHost = host.trim();
|
||||
if (trimmedHost.isEmpty || port <= 0) {
|
||||
return load();
|
||||
}
|
||||
|
||||
final normalizedName = name.trim().isNotEmpty ? name.trim() : trimmedHost;
|
||||
final existing = await load();
|
||||
final updated = <RecentTcpConnection>[
|
||||
RecentTcpConnection(
|
||||
name: normalizedName,
|
||||
host: trimmedHost,
|
||||
port: port,
|
||||
lastUsedAt: DateTime.now(),
|
||||
),
|
||||
...existing.where(
|
||||
(connection) =>
|
||||
connection.host != trimmedHost || connection.port != port,
|
||||
),
|
||||
].take(_maxEntries).toList(growable: false);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_prefsKey,
|
||||
updated
|
||||
.map((connection) => jsonEncode(connection.toJson()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,169 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
enum CoordinateDisplayFormat { decimal, dms, utm }
|
||||
|
||||
String formatCoordinates(
|
||||
double latitude,
|
||||
double longitude,
|
||||
CoordinateDisplayFormat format,
|
||||
) {
|
||||
switch (format) {
|
||||
case CoordinateDisplayFormat.decimal:
|
||||
return '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}';
|
||||
case CoordinateDisplayFormat.dms:
|
||||
return '${formatDmsCoordinate(latitude, true)} ${formatDmsCoordinate(longitude, false)}';
|
||||
case CoordinateDisplayFormat.utm:
|
||||
return formatUtm(latitude, longitude);
|
||||
}
|
||||
}
|
||||
|
||||
String formatDmsCoordinate(double degrees, bool isLatitude) {
|
||||
final direction = isLatitude
|
||||
? (degrees >= 0 ? 'N' : 'S')
|
||||
: (degrees >= 0 ? 'E' : 'W');
|
||||
|
||||
final absolute = degrees.abs();
|
||||
final deg = absolute.floor();
|
||||
final minDecimal = (absolute - deg) * 60;
|
||||
final min = minDecimal.floor();
|
||||
final sec = (minDecimal - min) * 60;
|
||||
|
||||
return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction';
|
||||
}
|
||||
|
||||
String formatUtm(double latitude, double longitude) {
|
||||
if (latitude < -80 || latitude > 84) {
|
||||
return formatCoordinates(
|
||||
latitude,
|
||||
longitude,
|
||||
CoordinateDisplayFormat.decimal,
|
||||
);
|
||||
}
|
||||
|
||||
final zone = _utmZone(latitude, longitude);
|
||||
final band = _utmLatitudeBand(latitude);
|
||||
final latRad = _degreesToRadians(latitude);
|
||||
final lonRad = _degreesToRadians(longitude);
|
||||
final centralMeridianRad = _degreesToRadians((zone - 1) * 6 - 180 + 3);
|
||||
|
||||
const semiMajorAxis = 6378137.0;
|
||||
const flattening = 1 / 298.257223563;
|
||||
const scaleFactor = 0.9996;
|
||||
final eccentricitySquared = flattening * (2 - flattening);
|
||||
final secondEccentricitySquared =
|
||||
eccentricitySquared / (1 - eccentricitySquared);
|
||||
|
||||
final sinLat = math.sin(latRad);
|
||||
final cosLat = math.cos(latRad);
|
||||
final tanLat = math.tan(latRad);
|
||||
final n = semiMajorAxis /
|
||||
math.sqrt(1 - eccentricitySquared * sinLat * sinLat);
|
||||
final t = tanLat * tanLat;
|
||||
final c = secondEccentricitySquared * cosLat * cosLat;
|
||||
final a = cosLat * (lonRad - centralMeridianRad);
|
||||
|
||||
final meridianArc = semiMajorAxis *
|
||||
((1 -
|
||||
eccentricitySquared / 4 -
|
||||
3 * eccentricitySquared * eccentricitySquared / 64 -
|
||||
5 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
256) *
|
||||
latRad -
|
||||
(3 * eccentricitySquared / 8 +
|
||||
3 * eccentricitySquared * eccentricitySquared / 32 +
|
||||
45 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
1024) *
|
||||
math.sin(2 * latRad) +
|
||||
(15 * eccentricitySquared * eccentricitySquared / 256 +
|
||||
45 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
1024) *
|
||||
math.sin(4 * latRad) -
|
||||
(35 *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared *
|
||||
eccentricitySquared /
|
||||
3072) *
|
||||
math.sin(6 * latRad));
|
||||
|
||||
final easting = scaleFactor *
|
||||
n *
|
||||
(a +
|
||||
(1 - t + c) * a * a * a / 6 +
|
||||
(5 - 18 * t + t * t + 72 * c - 58 * secondEccentricitySquared) *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a /
|
||||
120) +
|
||||
500000;
|
||||
var northing = scaleFactor *
|
||||
(meridianArc +
|
||||
n *
|
||||
tanLat *
|
||||
(a * a / 2 +
|
||||
(5 - t + 9 * c + 4 * c * c) * a * a * a * a / 24 +
|
||||
(61 -
|
||||
58 * t +
|
||||
t * t +
|
||||
600 * c -
|
||||
330 * secondEccentricitySquared) *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a *
|
||||
a /
|
||||
720));
|
||||
if (latitude < 0) {
|
||||
northing += 10000000;
|
||||
}
|
||||
|
||||
return '$zone$band ${easting.round()}E ${northing.round()}N';
|
||||
}
|
||||
|
||||
int _utmZone(double latitude, double longitude) {
|
||||
var zone = ((longitude + 180) / 6).floor() + 1;
|
||||
if (longitude == 180) {
|
||||
zone = 60;
|
||||
}
|
||||
if (latitude >= 56 &&
|
||||
latitude < 64 &&
|
||||
longitude >= 3 &&
|
||||
longitude < 12) {
|
||||
zone = 32;
|
||||
}
|
||||
if (latitude >= 72 && latitude < 84) {
|
||||
if (longitude >= 0 && longitude < 9) {
|
||||
zone = 31;
|
||||
} else if (longitude >= 9 && longitude < 21) {
|
||||
zone = 33;
|
||||
} else if (longitude >= 21 && longitude < 33) {
|
||||
zone = 35;
|
||||
} else if (longitude >= 33 && longitude < 42) {
|
||||
zone = 37;
|
||||
}
|
||||
}
|
||||
return zone.clamp(1, 60);
|
||||
}
|
||||
|
||||
String _utmLatitudeBand(double latitude) {
|
||||
const bands = 'CDEFGHJKLMNPQRSTUVWX';
|
||||
final index = ((latitude + 80) / 8).floor().clamp(0, bands.length - 1);
|
||||
return bands[index];
|
||||
}
|
||||
|
||||
double _degreesToRadians(double degrees) => degrees * math.pi / 180;
|
||||
|
||||
String formatPlusCode(double lat, double lon) {
|
||||
const base = '23456789CFGHJMPQRVWX';
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class LogRxRouteDecoder {
|
||||
}
|
||||
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
|
||||
final hashSize = pathMode == 0
|
||||
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
|
||||
? 1
|
||||
: (descriptorHashSize(pathDescriptor) ??
|
||||
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_route_metadata.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
|
||||
@@ -14,7 +15,7 @@ extension MessageLocalization on Message {
|
||||
|
||||
// For channel messages, show echo count instead of delivery status
|
||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||
final latestMeta = _formatEchoMeta(context);
|
||||
final latestMeta = _formatChannelStatusMeta(context, routeMetadata);
|
||||
if (echoCount == 0) {
|
||||
if (messagesProvider.hasChannelSendWarning(id)) {
|
||||
return 'Broadcast may have failed';
|
||||
@@ -127,6 +128,41 @@ extension MessageLocalization on Message {
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
String? _formatChannelStatusMeta(
|
||||
BuildContext context,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
final parts = <String>[];
|
||||
final hopLabel = _formatChannelHopMeta(routeMetadata);
|
||||
if (hopLabel != null) {
|
||||
parts.add(hopLabel);
|
||||
}
|
||||
|
||||
final echoMeta = _formatEchoMeta(context);
|
||||
if (echoMeta != null) {
|
||||
parts.add(echoMeta);
|
||||
}
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
String? _formatChannelHopMeta(MessageRouteMetadata? routeMetadata) {
|
||||
if (routeMetadata?.mode.name == 'flood') {
|
||||
return routeMetadata!.modeLabel;
|
||||
}
|
||||
|
||||
final effectivePathLen = routeMetadata?.hopCount ?? pathLen;
|
||||
if (effectivePathLen <= 0 || effectivePathLen >= 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
String _barsForRssi(int rssiDbm) {
|
||||
// Approximate useful RSSI range: -120..-70 dBm
|
||||
final score = ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
||||
|
||||
@@ -10,10 +10,18 @@ import '../providers/contacts_provider.dart';
|
||||
import '../screens/discovery_screen.dart';
|
||||
import '../services/network_scanner_service.dart';
|
||||
import '../services/profile_workspace_coordinator.dart';
|
||||
import '../services/recent_tcp_connections_service.dart';
|
||||
import '../services/serial/serial_transport.dart';
|
||||
|
||||
enum _ConnectionDialogResult { connected }
|
||||
|
||||
class _ManualTcpEndpoint {
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
const _ManualTcpEndpoint({required this.host, required this.port});
|
||||
}
|
||||
|
||||
Future<void> _initializeConnectedWorkspace({
|
||||
required ProfileWorkspaceCoordinator profileWorkspaceCoordinator,
|
||||
required AppProvider appProvider,
|
||||
@@ -135,6 +143,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
late final ConnectionProvider _connectionProvider;
|
||||
late final NetworkScannerService _networkScanner;
|
||||
final List<DiscoveredServer> _discoveredServers = [];
|
||||
List<RecentTcpConnection> _recentServers = const <RecentTcpConnection>[];
|
||||
int _scannedCount = 0;
|
||||
int _totalToScan = 0;
|
||||
int _lastTabIndex = 0;
|
||||
@@ -158,6 +167,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRecentServers() async {
|
||||
final recentServers = await RecentTcpConnectionsService.load();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_recentServers = recentServers;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -186,6 +205,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
};
|
||||
|
||||
_tabController.addListener(_onTabChanged);
|
||||
_loadRecentServers();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -198,6 +218,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
}
|
||||
|
||||
void _startNetworkScan() {
|
||||
_networkScanner.stopScan();
|
||||
setState(() {
|
||||
_discoveredServers.clear();
|
||||
_scannedCount = 0;
|
||||
@@ -236,6 +257,56 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
_showConnectionErrorSnackBar(context, error);
|
||||
}
|
||||
|
||||
Future<void> _rememberRecentServer({
|
||||
required String name,
|
||||
required String host,
|
||||
required int port,
|
||||
}) async {
|
||||
final recentServers = await RecentTcpConnectionsService.remember(
|
||||
name: name,
|
||||
host: host,
|
||||
port: port,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_recentServers = recentServers;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _connectTcpEndpoint({
|
||||
required String host,
|
||||
required int port,
|
||||
required String name,
|
||||
required String serverKey,
|
||||
}) async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
setState(() {
|
||||
_connectingToServerKey = serverKey;
|
||||
});
|
||||
|
||||
try {
|
||||
final success = await connectionProvider.connectTcp(host, port);
|
||||
if (!success) {
|
||||
throw Exception(
|
||||
connectionProvider.error ?? 'Failed to connect to $host:$port',
|
||||
);
|
||||
}
|
||||
await _rememberRecentServer(name: name, host: host, port: port);
|
||||
_closeOnSuccessfulConnection();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_connectingToServerKey = null;
|
||||
});
|
||||
_showConnectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectionProvider = context.watch<ConnectionProvider>();
|
||||
@@ -389,49 +460,35 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _promptForManualTcpHost() async {
|
||||
return showDialog<String>(
|
||||
Future<_ManualTcpEndpoint?> _promptForManualTcpHost() async {
|
||||
return showDialog<_ManualTcpEndpoint>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _ManualTcpHostDialog(
|
||||
initialHost: _connectionProvider.tcpHost,
|
||||
initialPort: NetworkScannerService.defaultPort,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connectManualTcpHost() async {
|
||||
final host = await _promptForManualTcpHost();
|
||||
if (host == null || !mounted) {
|
||||
if (_networkScanner.isScanning) {
|
||||
_networkScanner.stopScan();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
final endpoint = await _promptForManualTcpHost();
|
||||
if (endpoint == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final serverKey = '$host:${NetworkScannerService.defaultPort}';
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
setState(() {
|
||||
_connectingToServerKey = serverKey;
|
||||
});
|
||||
|
||||
try {
|
||||
final success = await connectionProvider.connectTcp(
|
||||
host,
|
||||
NetworkScannerService.defaultPort,
|
||||
);
|
||||
if (!success) {
|
||||
throw Exception(
|
||||
connectionProvider.error ??
|
||||
'Failed to connect to $host:${NetworkScannerService.defaultPort}',
|
||||
);
|
||||
}
|
||||
_closeOnSuccessfulConnection();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_connectingToServerKey = null;
|
||||
});
|
||||
_showConnectionError(error);
|
||||
}
|
||||
await _connectTcpEndpoint(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
name: endpoint.host,
|
||||
serverKey: '${endpoint.host}:${endpoint.port}',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorBanner(String message) {
|
||||
@@ -582,6 +639,23 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNetworkSectionHeader(String label) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 6),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
@@ -682,6 +756,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
!_networkScanner.isScanning &&
|
||||
_networkScanner.hasCachedResults &&
|
||||
_discoveredServers.isNotEmpty;
|
||||
final bool hasRecentServers = _recentServers.isNotEmpty;
|
||||
final bool hasDiscoveredServers = _discoveredServers.isNotEmpty;
|
||||
final bool showEmptyState =
|
||||
!_networkScanner.isScanning &&
|
||||
!hasRecentServers &&
|
||||
!hasDiscoveredServers;
|
||||
final bool isAnyConnectionInProgress = _connectingToServerKey != null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -693,8 +774,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
? 'Showing cached results. Tap refresh to rescan.'
|
||||
: 'Scanning local network for MeshCore WiFi devices on port 5000',
|
||||
secondaryActionIcon: Icons.add_rounded,
|
||||
secondaryActionTooltip: 'Add IP address',
|
||||
onSecondaryAction: _connectingToServerKey != null
|
||||
secondaryActionTooltip: _networkScanner.isScanning
|
||||
? 'Cancel scan and add server'
|
||||
: 'Add server',
|
||||
onSecondaryAction: isAnyConnectionInProgress
|
||||
? null
|
||||
: _connectManualTcpHost,
|
||||
onRefresh: _startNetworkScan,
|
||||
@@ -712,92 +795,132 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
'Scanning... $_scannedCount/${_totalToScan > 0 ? _totalToScan : "?"} IPs',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _connectManualTcpHost,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('Cancel and enter manually'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _networkScanner.isScanning && _discoveredServers.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _discoveredServers.isEmpty
|
||||
child: showEmptyState
|
||||
? _buildEmptyState(
|
||||
icon: Icons.wifi_off_rounded,
|
||||
title: AppLocalizations.of(context)!.noServersFound,
|
||||
title: 'No recent or discovered servers yet',
|
||||
actionLabel: 'Scan Again',
|
||||
onAction: _startNetworkScan,
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _discoveredServers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final server = _discoveredServers[index];
|
||||
final serverKey = '${server.ipAddress}:${server.port}';
|
||||
final isConnectingToThisServer =
|
||||
_connectingToServerKey == serverKey;
|
||||
final isAnyConnectionInProgress =
|
||||
_connectingToServerKey != null;
|
||||
|
||||
Future<void> connectServer() async {
|
||||
final connectionProvider = context
|
||||
.read<ConnectionProvider>();
|
||||
|
||||
setState(() {
|
||||
_connectingToServerKey = serverKey;
|
||||
});
|
||||
|
||||
try {
|
||||
final isAvailable = await _networkScanner.verifyServer(
|
||||
server,
|
||||
);
|
||||
if (!isAvailable) {
|
||||
throw Exception(
|
||||
'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.',
|
||||
);
|
||||
}
|
||||
|
||||
final success = await connectionProvider.connectTcp(
|
||||
server.ipAddress,
|
||||
server.port,
|
||||
);
|
||||
if (!success) {
|
||||
throw Exception(
|
||||
connectionProvider.error ??
|
||||
'Failed to connect to ${server.ipAddress}:${server.port}',
|
||||
);
|
||||
}
|
||||
_closeOnSuccessfulConnection();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connectingToServerKey = null;
|
||||
});
|
||||
_showConnectionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return _buildTransportCard(
|
||||
icon: Icons.wifi_rounded,
|
||||
iconColor: Colors.green,
|
||||
title: server.ipAddress,
|
||||
subtitle: isConnectingToThisServer
|
||||
? 'Connecting...'
|
||||
: 'Port ${server.port} • ${server.responseTime}ms',
|
||||
trailing: isConnectingToThisServer
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
: ListView(
|
||||
children: [
|
||||
if (hasRecentServers)
|
||||
_buildNetworkSectionHeader('Recently used'),
|
||||
for (final server in _recentServers)
|
||||
_buildTransportCard(
|
||||
icon: Icons.history_rounded,
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
title: server.name,
|
||||
subtitle:
|
||||
_connectingToServerKey == '${server.host}:${server.port}'
|
||||
? 'Connecting...'
|
||||
: '${server.host}:${server.port}',
|
||||
trailing:
|
||||
_connectingToServerKey == '${server.host}:${server.port}'
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: isAnyConnectionInProgress
|
||||
? null
|
||||
: () => _connectTcpEndpoint(
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
name: server.name,
|
||||
serverKey: '${server.host}:${server.port}',
|
||||
),
|
||||
child: Text(AppLocalizations.of(context)!.connect),
|
||||
),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: isAnyConnectionInProgress
|
||||
? null
|
||||
: connectServer,
|
||||
child: Text(AppLocalizations.of(context)!.connect),
|
||||
),
|
||||
enabled: !isAnyConnectionInProgress,
|
||||
onTap: isAnyConnectionInProgress ? null : connectServer,
|
||||
);
|
||||
},
|
||||
enabled: !isAnyConnectionInProgress,
|
||||
onTap: isAnyConnectionInProgress
|
||||
? null
|
||||
: () => _connectTcpEndpoint(
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
name: server.name,
|
||||
serverKey: '${server.host}:${server.port}',
|
||||
),
|
||||
),
|
||||
if (hasDiscoveredServers)
|
||||
_buildNetworkSectionHeader('Discovered on this network'),
|
||||
for (final server in _discoveredServers)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final serverKey = '${server.ipAddress}:${server.port}';
|
||||
final isConnectingToThisServer =
|
||||
_connectingToServerKey == serverKey;
|
||||
|
||||
Future<void> connectServer() async {
|
||||
try {
|
||||
final isAvailable = await _networkScanner.verifyServer(
|
||||
server,
|
||||
);
|
||||
if (!isAvailable) {
|
||||
throw Exception(
|
||||
'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.',
|
||||
);
|
||||
}
|
||||
|
||||
await _connectTcpEndpoint(
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
name: server.displayName,
|
||||
serverKey: serverKey,
|
||||
);
|
||||
} catch (e) {
|
||||
_showConnectionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return _buildTransportCard(
|
||||
icon: Icons.wifi_rounded,
|
||||
iconColor: Colors.green,
|
||||
title: server.displayName,
|
||||
subtitle: isConnectingToThisServer
|
||||
? 'Connecting...'
|
||||
: '${server.ipAddress}:${server.port} • ${server.responseTime}ms',
|
||||
trailing: isConnectingToThisServer
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: isAnyConnectionInProgress
|
||||
? null
|
||||
: connectServer,
|
||||
child: Text(AppLocalizations.of(context)!.connect),
|
||||
),
|
||||
enabled: !isAnyConnectionInProgress,
|
||||
onTap: isAnyConnectionInProgress ? null : connectServer,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_networkScanner.isScanning && !hasDiscoveredServers)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -847,65 +970,112 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
|
||||
class _ManualTcpHostDialog extends StatefulWidget {
|
||||
final String? initialHost;
|
||||
final int initialPort;
|
||||
|
||||
const _ManualTcpHostDialog({this.initialHost});
|
||||
const _ManualTcpHostDialog({
|
||||
this.initialHost,
|
||||
required this.initialPort,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState();
|
||||
}
|
||||
|
||||
class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
|
||||
late final TextEditingController _controller;
|
||||
String? _errorText;
|
||||
late final TextEditingController _hostController;
|
||||
late final TextEditingController _portController;
|
||||
String? _hostErrorText;
|
||||
String? _portErrorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialHost);
|
||||
_hostController = TextEditingController(text: widget.initialHost);
|
||||
_portController = TextEditingController(text: widget.initialPort.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_hostController.dispose();
|
||||
_portController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final host = _controller.text.trim();
|
||||
final host = _hostController.text.trim();
|
||||
final portText = _portController.text.trim();
|
||||
final parsedAddress = InternetAddress.tryParse(host);
|
||||
final parsedPort = int.tryParse(portText);
|
||||
String? hostErrorText;
|
||||
String? portErrorText;
|
||||
|
||||
if (parsedAddress == null) {
|
||||
hostErrorText = 'Enter a valid IP address';
|
||||
}
|
||||
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
|
||||
portErrorText = 'Enter a valid TCP port';
|
||||
}
|
||||
if (hostErrorText != null || portErrorText != null) {
|
||||
setState(() {
|
||||
_errorText = 'Enter a valid IP address';
|
||||
_hostErrorText = hostErrorText;
|
||||
_portErrorText = portErrorText;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(parsedAddress.address);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(_ManualTcpEndpoint(host: parsedAddress!.address, port: parsedPort!));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.connectByIpAddress),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'IP address',
|
||||
hintText: '192.168.1.42',
|
||||
helperText: 'Uses TCP port 5000',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _errorText,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_errorText == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) => _submit(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _hostController,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'IP address',
|
||||
hintText: '192.168.1.42',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _hostErrorText,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_hostErrorText == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_hostErrorText = null;
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _portController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'TCP port',
|
||||
hintText: NetworkScannerService.defaultPort.toString(),
|
||||
helperText: 'Custom server port',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _portErrorText,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_portErrorText == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_portErrorText = null;
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../models/contact.dart';
|
||||
import '../../../models/sar_marker.dart';
|
||||
import '../../../utils/location_formats.dart';
|
||||
import 'compass_math.dart';
|
||||
|
||||
/// Header component for the compass dialog showing compass rose,
|
||||
@@ -563,21 +564,7 @@ class _LocationFormatToggle extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LocationFormatToggleState extends State<_LocationFormatToggle> {
|
||||
bool _showDMS = false;
|
||||
|
||||
String _formatDMS(double degrees, bool isLatitude) {
|
||||
final direction = isLatitude
|
||||
? (degrees >= 0 ? 'N' : 'S')
|
||||
: (degrees >= 0 ? 'E' : 'W');
|
||||
|
||||
final absolute = degrees.abs();
|
||||
final deg = absolute.floor();
|
||||
final minDecimal = (absolute - deg) * 60;
|
||||
final min = minDecimal.floor();
|
||||
final sec = (minDecimal - min) * 60;
|
||||
|
||||
return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction';
|
||||
}
|
||||
CoordinateDisplayFormat _format = CoordinateDisplayFormat.decimal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -589,20 +576,24 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final String displayText;
|
||||
|
||||
if (_showDMS) {
|
||||
displayText =
|
||||
'${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
|
||||
} else {
|
||||
if (_format == CoordinateDisplayFormat.decimal) {
|
||||
displayText = l10n.latLonFormat(
|
||||
position.latitude.toStringAsFixed(5),
|
||||
position.longitude.toStringAsFixed(5),
|
||||
);
|
||||
} else {
|
||||
displayText = formatCoordinates(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
_format,
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showDMS = !_showDMS;
|
||||
_format = CoordinateDisplayFormat.values[
|
||||
(_format.index + 1) % CoordinateDisplayFormat.values.length];
|
||||
});
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
|
||||
@@ -39,14 +39,33 @@ class TrailControls extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Trail recording toggle
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.fiber_manual_record),
|
||||
title: Text('Record location trail'),
|
||||
subtitle: Text(
|
||||
mapProvider.isTrailRecordingEnabled
|
||||
? 'Trail recording is on'
|
||||
: 'Trail recording is stopped',
|
||||
),
|
||||
value: mapProvider.isTrailRecordingEnabled,
|
||||
onChanged: (value) {
|
||||
mapProvider.setTrailRecordingEnabled(value);
|
||||
setModalState(() {});
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Trail visibility toggle
|
||||
SwitchListTile(
|
||||
secondary: Icon(Icons.visibility),
|
||||
title: Text(l10n.showTrailOnMap),
|
||||
subtitle: Text(
|
||||
mapProvider.isTrailVisible
|
||||
? l10n.trailVisible
|
||||
: l10n.trailHiddenRecording,
|
||||
!mapProvider.isTrailRecordingEnabled
|
||||
? 'Trail recording is stopped'
|
||||
: (mapProvider.isTrailVisible
|
||||
? l10n.trailVisible
|
||||
: l10n.trailHiddenRecording),
|
||||
),
|
||||
value: mapProvider.isTrailVisible,
|
||||
onChanged: (value) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/sar_template.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/location_formats.dart';
|
||||
|
||||
class MapMarkers {
|
||||
static List<Marker> createTeamMemberMarkers(
|
||||
@@ -253,7 +254,11 @@ class MapMarkers {
|
||||
if (contact.displayLocation != null) ...[
|
||||
_InfoRow(
|
||||
'Location',
|
||||
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
|
||||
formatCoordinates(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
CoordinateDisplayFormat.utm,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (contact.telemetry?.batteryMilliVolts != null)
|
||||
@@ -313,7 +318,11 @@ class MapMarkers {
|
||||
children: [
|
||||
_InfoRow(
|
||||
'Location',
|
||||
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
|
||||
formatCoordinates(
|
||||
marker.location.latitude,
|
||||
marker.location.longitude,
|
||||
CoordinateDisplayFormat.utm,
|
||||
),
|
||||
),
|
||||
_InfoRow('Reported', marker.timeAgo),
|
||||
if (marker.senderName != null)
|
||||
|
||||
@@ -534,7 +534,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_hideDrawingFromMap(parentContext);
|
||||
},
|
||||
),
|
||||
// Technical details option
|
||||
// Details option
|
||||
ListTile(
|
||||
leading: Icon(Icons.data_object),
|
||||
title: Text(l10n.technicalDetails),
|
||||
@@ -676,6 +676,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes)
|
||||
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
final lastEchoRelayHash = receptionDetails?.pathBytes?.isNotEmpty == true
|
||||
? receptionDetails!.pathBytes!.last
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase()
|
||||
: null;
|
||||
final lastEchoBytesReport = _formatPathBytesReport(
|
||||
receptionDetails?.pathBytes,
|
||||
);
|
||||
final snrDb =
|
||||
receptionDetails?.snrDb ??
|
||||
matchedRxLog?.logRxDataInfo?.snrDb ??
|
||||
@@ -712,6 +721,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
|
||||
'Channel index: ${widget.message.channelIdx ?? '-'}',
|
||||
'Echo count: ${widget.message.echoCount}',
|
||||
'Last echo relay hash: ${lastEchoRelayHash ?? '-'}',
|
||||
'Last echo path bytes: ${receptionDetails?.pathBytesHex ?? '-'}',
|
||||
'Last echo bytes report: ${lastEchoBytesReport ?? '-'}',
|
||||
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
|
||||
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
|
||||
'Matched RX RSSI: ${rssiDbm ?? '-'}',
|
||||
@@ -1044,6 +1056,33 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
label: l10n.receivedCopies,
|
||||
value: '${widget.receivedCopies}',
|
||||
),
|
||||
if (lastEchoRelayHash != null)
|
||||
_detailRow(
|
||||
sheetContext,
|
||||
label: 'Last echo relay',
|
||||
value: lastEchoRelayHash,
|
||||
onCopy: () =>
|
||||
copyField(sheetContext, lastEchoRelayHash),
|
||||
),
|
||||
if (receptionDetails?.pathBytesHex
|
||||
case final echoPath?)
|
||||
_detailRow(
|
||||
sheetContext,
|
||||
label: 'Last echo path',
|
||||
value: echoPath,
|
||||
onCopy: () =>
|
||||
copyField(sheetContext, echoPath),
|
||||
),
|
||||
if (lastEchoBytesReport != null)
|
||||
_detailRow(
|
||||
sheetContext,
|
||||
label: 'Last echo bytes report',
|
||||
value: lastEchoBytesReport,
|
||||
onCopy: () => copyField(
|
||||
sheetContext,
|
||||
lastEchoBytesReport,
|
||||
),
|
||||
),
|
||||
if (widget.message.suggestedTimeoutMs != null)
|
||||
_detailRow(
|
||||
sheetContext,
|
||||
@@ -1624,6 +1663,23 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
return '$durationMs ms';
|
||||
}
|
||||
|
||||
String? _formatPathBytesReport(List<int>? pathBytes) {
|
||||
if (pathBytes == null || pathBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final byteHex = pathBytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.toList();
|
||||
final indexedHops = byteHex
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) => '#${entry.key + 1}=${entry.value}')
|
||||
.join(', ');
|
||||
final byteLabel = pathBytes.length == 1 ? 'byte' : 'bytes';
|
||||
return '${pathBytes.length} $byteLabel [${byteHex.join(' ')}] • hops $indexedHops';
|
||||
}
|
||||
|
||||
BlePacketLog? _findBestMatchingRxLog(
|
||||
List<BlePacketLog> logs,
|
||||
Message message,
|
||||
|
||||
@@ -328,9 +328,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
MessagesProvider? messagesProvider,
|
||||
) {
|
||||
final previewData = _channelPreviewData(context, channel, messagesProvider);
|
||||
final sharingMode = context.watch<AppProvider>().channelLocationSharingModeForChannel(
|
||||
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
|
||||
);
|
||||
ChannelLocationSharingMode? sharingMode;
|
||||
try {
|
||||
sharingMode = context
|
||||
.watch<AppProvider>()
|
||||
.channelLocationSharingModeForChannel(
|
||||
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
|
||||
);
|
||||
} on ProviderNotFoundException {
|
||||
sharingMode = null;
|
||||
}
|
||||
|
||||
return _buildRecipientCard(
|
||||
context: context,
|
||||
|
||||
@@ -9,24 +9,26 @@ import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/sensors_provider.dart';
|
||||
import 'sensor_telemetry_card.dart';
|
||||
|
||||
enum SensorHistoryRange { day, week, month, all }
|
||||
|
||||
Future<void> showSensorHistorySheet(
|
||||
BuildContext context, {
|
||||
required String publicKeyHex,
|
||||
String? initialFieldKey,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => _SensorHistorySheet(
|
||||
publicKeyHex: publicKeyHex,
|
||||
initialFieldKey: initialFieldKey,
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (pageContext) => SensorHistoryScreen(
|
||||
publicKeyHex: publicKeyHex,
|
||||
initialFieldKey: initialFieldKey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SensorHistorySheet extends StatefulWidget {
|
||||
const _SensorHistorySheet({
|
||||
class SensorHistoryScreen extends StatefulWidget {
|
||||
const SensorHistoryScreen({
|
||||
super.key,
|
||||
required this.publicKeyHex,
|
||||
this.initialFieldKey,
|
||||
});
|
||||
@@ -35,230 +37,165 @@ class _SensorHistorySheet extends StatefulWidget {
|
||||
final String? initialFieldKey;
|
||||
|
||||
@override
|
||||
State<_SensorHistorySheet> createState() => _SensorHistorySheetState();
|
||||
State<SensorHistoryScreen> createState() => _SensorHistoryScreenState();
|
||||
}
|
||||
|
||||
class _SensorHistorySheetState extends State<_SensorHistorySheet> {
|
||||
String? _selectedFieldKey;
|
||||
class _SensorHistoryScreenState extends State<SensorHistoryScreen> {
|
||||
late SensorHistoryRange _selectedRange;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedFieldKey = widget.initialFieldKey;
|
||||
_selectedRange = SensorHistoryRange.day;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.of(context).size.height * 0.84;
|
||||
return Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
|
||||
builder:
|
||||
(
|
||||
context,
|
||||
sensorsProvider,
|
||||
contactsProvider,
|
||||
connectionProvider,
|
||||
child,
|
||||
) {
|
||||
final contact = sensorsProvider.contactForDisplay(
|
||||
widget.publicKeyHex,
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
final history = sensorsProvider.historyFor(widget.publicKeyHex);
|
||||
final options = sensorMetricOptionsFor(
|
||||
contact,
|
||||
labelOverrides: sensorsProvider.labelOverridesFor(
|
||||
widget.publicKeyHex,
|
||||
),
|
||||
);
|
||||
final optionByKey = <String, SensorMetricOption>{
|
||||
for (final option in options) option.key: option,
|
||||
};
|
||||
final availableFieldKeys = <String>{
|
||||
for (final sample in history) ...sample.values.keys,
|
||||
}.toList()
|
||||
..sort((a, b) {
|
||||
final aIndex = options.indexWhere((option) => option.key == a);
|
||||
final bIndex = options.indexWhere((option) => option.key == b);
|
||||
if (aIndex == -1 && bIndex == -1) {
|
||||
return a.compareTo(b);
|
||||
}
|
||||
if (aIndex == -1) {
|
||||
return 1;
|
||||
}
|
||||
if (bIndex == -1) {
|
||||
return -1;
|
||||
}
|
||||
return aIndex.compareTo(bIndex);
|
||||
});
|
||||
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
|
||||
builder:
|
||||
(
|
||||
context,
|
||||
sensorsProvider,
|
||||
contactsProvider,
|
||||
connectionProvider,
|
||||
child,
|
||||
) {
|
||||
final contact = sensorsProvider.contactForDisplay(
|
||||
widget.publicKeyHex,
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
final history = sensorsProvider.historyFor(widget.publicKeyHex);
|
||||
final options = sensorMetricOptionsFor(
|
||||
contact,
|
||||
labelOverrides: sensorsProvider.labelOverridesFor(
|
||||
widget.publicKeyHex,
|
||||
),
|
||||
);
|
||||
final optionByKey = <String, SensorMetricOption>{
|
||||
for (final option in options) option.key: option,
|
||||
};
|
||||
final availableFieldKeys = <String>{
|
||||
for (final sample in history) ...sample.values.keys,
|
||||
}.toList()
|
||||
..sort((a, b) {
|
||||
final aIndex = options.indexWhere(
|
||||
(option) => option.key == a,
|
||||
);
|
||||
final bIndex = options.indexWhere(
|
||||
(option) => option.key == b,
|
||||
);
|
||||
if (aIndex == -1 && bIndex == -1) {
|
||||
return a.compareTo(b);
|
||||
}
|
||||
if (aIndex == -1) {
|
||||
return 1;
|
||||
}
|
||||
if (bIndex == -1) {
|
||||
return -1;
|
||||
}
|
||||
return aIndex.compareTo(bIndex);
|
||||
});
|
||||
final selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: widget.initialFieldKey,
|
||||
availableFieldKeys: availableFieldKeys,
|
||||
);
|
||||
final selectedOption = selectedFieldKey == null
|
||||
? null
|
||||
: optionByKey[selectedFieldKey];
|
||||
final selectedCardData = selectedOption?.previewCardData;
|
||||
final selectedSamples = selectedFieldKey == null
|
||||
? const <SensorHistorySample>[]
|
||||
: history
|
||||
.where(
|
||||
(sample) =>
|
||||
sample.values.containsKey(selectedFieldKey),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final rangeSamples = filterSensorHistorySamples(
|
||||
samples: selectedSamples,
|
||||
range: _selectedRange,
|
||||
);
|
||||
final title =
|
||||
selectedCardData?.label ??
|
||||
selectedOption?.defaultLabel ??
|
||||
'Sensor history';
|
||||
|
||||
_selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: _selectedFieldKey,
|
||||
availableFieldKeys: availableFieldKeys,
|
||||
);
|
||||
|
||||
final selectedFieldKey = _selectedFieldKey;
|
||||
final selectedSamples = selectedFieldKey == null
|
||||
? const <SensorHistorySample>[]
|
||||
: history
|
||||
.where(
|
||||
(sample) =>
|
||||
sample.values.containsKey(selectedFieldKey),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final selectedOption = selectedFieldKey == null
|
||||
? null
|
||||
: optionByKey[selectedFieldKey];
|
||||
final selectedCardData = selectedOption?.previewCardData;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
Text(title),
|
||||
Text(
|
||||
contact?.displayName ?? 'Unavailable node',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Graph'),
|
||||
Tab(text: 'Values'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: selectedFieldKey == null
|
||||
? _SensorHistoryEmptyState(
|
||||
message:
|
||||
'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.',
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: SensorHistoryRange.values
|
||||
.map(
|
||||
(range) => ChoiceChip(
|
||||
label: Text(
|
||||
sensorHistoryRangeLabel(range),
|
||||
),
|
||||
selected: _selectedRange == range,
|
||||
onSelected: (selected) {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedRange = range;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: TabBarView(
|
||||
children: [
|
||||
Text(
|
||||
'Sensor history',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
_SensorHistoryGraphTab(
|
||||
samples: rangeSamples,
|
||||
fieldKey: selectedFieldKey,
|
||||
cardData: selectedCardData,
|
||||
totalCount: selectedSamples.length,
|
||||
range: _selectedRange,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
contact?.displayName ?? 'Unavailable node',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
_SensorHistoryValuesTab(
|
||||
samples: rangeSamples,
|
||||
fieldKey: selectedFieldKey,
|
||||
cardData: selectedCardData,
|
||||
range: _selectedRange,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (availableFieldKeys.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: availableFieldKeys
|
||||
.map((fieldKey) {
|
||||
final option = optionByKey[fieldKey];
|
||||
return ChoiceChip(
|
||||
label: Text(
|
||||
option?.defaultLabel ?? fieldKey,
|
||||
),
|
||||
selected: fieldKey == selectedFieldKey,
|
||||
onSelected: (selected) {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedFieldKey = fieldKey;
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SensorHistorySummaryCard(
|
||||
historyCount: history.length,
|
||||
samples: selectedSamples,
|
||||
cardData: selectedCardData,
|
||||
fieldKey: selectedFieldKey!,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedCardData?.label ??
|
||||
selectedOption?.defaultLabel ??
|
||||
selectedFieldKey,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: LineChart(
|
||||
_historyLineChartData(
|
||||
context,
|
||||
samples: selectedSamples,
|
||||
fieldKey: selectedFieldKey,
|
||||
color:
|
||||
selectedCardData?.accent ??
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: _SensorHistoryLogList(
|
||||
history: history.reversed.toList(growable: false),
|
||||
selectedFieldKey: selectedFieldKey,
|
||||
optionByKey: optionByKey,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -277,69 +214,139 @@ String? resolveInitialSensorHistoryField({
|
||||
return availableFieldKeys.first;
|
||||
}
|
||||
|
||||
class _SensorHistorySummaryCard extends StatelessWidget {
|
||||
const _SensorHistorySummaryCard({
|
||||
required this.historyCount,
|
||||
List<SensorHistorySample> filterSensorHistorySamples({
|
||||
required List<SensorHistorySample> samples,
|
||||
required SensorHistoryRange range,
|
||||
}) {
|
||||
if (samples.isEmpty || range == SensorHistoryRange.all) {
|
||||
return List<SensorHistorySample>.from(samples);
|
||||
}
|
||||
|
||||
final latestTimestamp = samples.last.timestamp;
|
||||
final cutoff = switch (range) {
|
||||
SensorHistoryRange.day => latestTimestamp.subtract(const Duration(days: 1)),
|
||||
SensorHistoryRange.week => latestTimestamp.subtract(const Duration(days: 7)),
|
||||
SensorHistoryRange.month => latestTimestamp.subtract(
|
||||
const Duration(days: 30),
|
||||
),
|
||||
SensorHistoryRange.all => DateTime.fromMillisecondsSinceEpoch(0),
|
||||
};
|
||||
|
||||
return samples
|
||||
.where((sample) => !sample.timestamp.isBefore(cutoff))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String sensorHistoryRangeLabel(SensorHistoryRange range) {
|
||||
return switch (range) {
|
||||
SensorHistoryRange.day => '24h',
|
||||
SensorHistoryRange.week => '7d',
|
||||
SensorHistoryRange.month => '30d',
|
||||
SensorHistoryRange.all => 'All',
|
||||
};
|
||||
}
|
||||
|
||||
class _SensorHistoryGraphTab extends StatelessWidget {
|
||||
const _SensorHistoryGraphTab({
|
||||
required this.samples,
|
||||
required this.cardData,
|
||||
required this.fieldKey,
|
||||
required this.cardData,
|
||||
required this.totalCount,
|
||||
required this.range,
|
||||
});
|
||||
|
||||
final int historyCount;
|
||||
final List<SensorHistorySample> samples;
|
||||
final SensorMetricCardData? cardData;
|
||||
final String fieldKey;
|
||||
final SensorMetricCardData? cardData;
|
||||
final int totalCount;
|
||||
final SensorHistoryRange range;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latestValue = samples.isEmpty ? null : samples.last.values[fieldKey];
|
||||
final minValue = samples.isEmpty
|
||||
? null
|
||||
: samples
|
||||
.map((sample) => sample.values[fieldKey]!)
|
||||
.reduce(math.min);
|
||||
final maxValue = samples.isEmpty
|
||||
? null
|
||||
: samples
|
||||
.map((sample) => sample.values[fieldKey]!)
|
||||
.reduce(math.max);
|
||||
if (samples.isEmpty) {
|
||||
return _SensorHistoryEmptyState(
|
||||
message:
|
||||
'No samples are available for ${sensorHistoryRangeLabel(range)}.',
|
||||
);
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final accent = cardData?.accent ?? theme.colorScheme.primary;
|
||||
final latestValue = samples.last.values[fieldKey]!;
|
||||
final minValue = samples
|
||||
.map((sample) => sample.values[fieldKey]!)
|
||||
.reduce(math.min);
|
||||
final maxValue = samples
|
||||
.map((sample) => sample.values[fieldKey]!)
|
||||
.reduce(math.max);
|
||||
final accent = cardData?.accent ?? Theme.of(context).colorScheme.primary;
|
||||
|
||||
return Row(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Total',
|
||||
value: historyCount.toString(),
|
||||
accent: accent,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Visible',
|
||||
value: samples.length.toString(),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Latest',
|
||||
value: _formatHistoryValue(cardData, latestValue),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Min',
|
||||
value: _formatHistoryValue(cardData, minValue),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Max',
|
||||
value: _formatHistoryValue(cardData, maxValue),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$totalCount total readings',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Latest',
|
||||
value: latestValue == null
|
||||
? '--'
|
||||
: _formatHistoryValue(cardData, latestValue),
|
||||
accent: accent,
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Min',
|
||||
value: minValue == null ? '--' : _formatHistoryValue(cardData, minValue),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SensorHistoryStatTile(
|
||||
label: 'Max',
|
||||
value: maxValue == null ? '--' : _formatHistoryValue(cardData, maxValue),
|
||||
accent: accent,
|
||||
child: SizedBox(
|
||||
height: 280,
|
||||
child: LineChart(
|
||||
_historyLineChartData(
|
||||
context,
|
||||
samples: samples,
|
||||
fieldKey: fieldKey,
|
||||
color: accent,
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -347,6 +354,93 @@ class _SensorHistorySummaryCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorHistoryValuesTab extends StatelessWidget {
|
||||
const _SensorHistoryValuesTab({
|
||||
required this.samples,
|
||||
required this.fieldKey,
|
||||
required this.cardData,
|
||||
required this.range,
|
||||
});
|
||||
|
||||
final List<SensorHistorySample> samples;
|
||||
final String fieldKey;
|
||||
final SensorMetricCardData? cardData;
|
||||
final SensorHistoryRange range;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (samples.isEmpty) {
|
||||
return _SensorHistoryEmptyState(
|
||||
message:
|
||||
'No values are available for ${sensorHistoryRangeLabel(range)}.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: samples.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final sample = samples[samples.length - index - 1];
|
||||
final value = sample.values[fieldKey]!;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_formatSampleTimestamp(sample.timestamp),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatHistoryValue(cardData, value),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
cardData?.accent ?? Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorHistoryEmptyState extends StatelessWidget {
|
||||
const _SensorHistoryEmptyState({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorHistoryStatTile extends StatelessWidget {
|
||||
const _SensorHistoryStatTile({
|
||||
required this.label,
|
||||
@@ -390,86 +484,6 @@ class _SensorHistoryStatTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorHistoryLogList extends StatelessWidget {
|
||||
const _SensorHistoryLogList({
|
||||
required this.history,
|
||||
required this.selectedFieldKey,
|
||||
required this.optionByKey,
|
||||
});
|
||||
|
||||
final List<SensorHistorySample> history;
|
||||
final String selectedFieldKey;
|
||||
final Map<String, SensorMetricOption> optionByKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
itemCount: history.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final sample = history[index];
|
||||
final selectedValue = sample.values[selectedFieldKey];
|
||||
final selectedOption = optionByKey[selectedFieldKey];
|
||||
|
||||
final secondaryMetrics = sample.values.entries
|
||||
.where((entry) => entry.key != selectedFieldKey)
|
||||
.take(3)
|
||||
.map((entry) {
|
||||
final option = optionByKey[entry.key];
|
||||
final cardData = option?.previewCardData;
|
||||
return TextSpan(
|
||||
text:
|
||||
'${option?.defaultLabel ?? entry.key} ${_formatHistoryValue(cardData, entry.value)} ',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: cardData?.accent ?? Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_formatSampleTimestamp(sample.timestamp),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${selectedOption?.defaultLabel ?? selectedFieldKey} ${selectedValue == null ? '--' : _formatHistoryValue(selectedOption?.previewCardData, selectedValue)}',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
selectedOption?.previewCardData?.accent ??
|
||||
Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (secondaryMetrics.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
RichText(
|
||||
text: TextSpan(children: secondaryMetrics),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LineChartData _historyLineChartData(
|
||||
BuildContext context, {
|
||||
required List<SensorHistorySample> samples,
|
||||
@@ -488,10 +502,14 @@ LineChartData _historyLineChartData(
|
||||
final minValue = values.reduce(math.min);
|
||||
final maxValue = values.reduce(math.max);
|
||||
final spread = maxValue - minValue;
|
||||
final padding = spread == 0 ? math.max(maxValue.abs() * 0.1, 1.0) : spread * 0.15;
|
||||
final padding = spread == 0
|
||||
? math.max(maxValue.abs() * 0.1, 1.0)
|
||||
: spread * 0.15;
|
||||
final minY = minValue - padding;
|
||||
final maxY = maxValue + padding;
|
||||
final interval = spread <= 0 ? math.max(maxValue.abs() / 3, 1.0) : spread / 3;
|
||||
final interval = spread <= 0
|
||||
? math.max(maxValue.abs() / 3, 1.0)
|
||||
: spread / 3;
|
||||
|
||||
return LineChartData(
|
||||
minX: 0,
|
||||
@@ -622,7 +640,7 @@ String _formatSampleTimestamp(DateTime timestamp) {
|
||||
|
||||
String _formatChartTimestamp(DateTime timestamp) {
|
||||
final local = timestamp.toLocal();
|
||||
final hour = local.hour.toString().padLeft(2, '0');
|
||||
final minute = local.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
return '$month/$day';
|
||||
}
|
||||
|
||||
@@ -238,15 +238,27 @@ List<SensorMetricOption> sensorMetricOptionsFor(
|
||||
final coreFieldKeys = <String>{
|
||||
if (batteryMilliVolts != null || batteryPercentage != null)
|
||||
...extraSensorData?.keys.where(
|
||||
(k) => k.startsWith('voltage_') || k.startsWith('analog_input_'),
|
||||
(k) =>
|
||||
_isSourceChannelMetric(extraSensorData, 'voltage', k) ||
|
||||
_isSourceChannelMetric(extraSensorData, 'battery', k) ||
|
||||
k.startsWith('analog_input_'),
|
||||
) ??
|
||||
[],
|
||||
if (temperature != null)
|
||||
...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [],
|
||||
...extraSensorData?.keys.where(
|
||||
(k) => _isSourceChannelMetric(extraSensorData, 'temperature', k),
|
||||
) ??
|
||||
[],
|
||||
if (humidity != null)
|
||||
...extraSensorData?.keys.where((k) => k.startsWith('humidity_')) ?? [],
|
||||
...extraSensorData?.keys.where(
|
||||
(k) => _isSourceChannelMetric(extraSensorData, 'humidity', k),
|
||||
) ??
|
||||
[],
|
||||
if (pressure != null)
|
||||
...extraSensorData?.keys.where((k) => k.startsWith('pressure_')) ?? [],
|
||||
...extraSensorData?.keys.where(
|
||||
(k) => _isSourceChannelMetric(extraSensorData, 'pressure', k),
|
||||
) ??
|
||||
[],
|
||||
};
|
||||
|
||||
if (extraSensorData != null) {
|
||||
@@ -3117,6 +3129,18 @@ int? _sourceChannelForField(
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isSourceChannelMetric(
|
||||
Map<String, dynamic>? extraSensorData,
|
||||
String fieldKey,
|
||||
String metricKey,
|
||||
) {
|
||||
final sourceChannel = _sourceChannelForField(extraSensorData, fieldKey);
|
||||
if (sourceChannel == null) {
|
||||
return false;
|
||||
}
|
||||
return metricKey == '${fieldKey}_$sourceChannel';
|
||||
}
|
||||
|
||||
String _resolvedMetricLabel(
|
||||
String fieldKey,
|
||||
String defaultLabel, {
|
||||
|
||||
@@ -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.0410.1+52
|
||||
version: 2026.0420.1+55
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||
@@ -254,6 +254,47 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('repeated echo callbacks accumulate when radio reports one each time', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.addSentMessage(
|
||||
_buildSentChannelMessage(
|
||||
id: 'c-echo-callbacks',
|
||||
senderTimestamp: 1700000002,
|
||||
),
|
||||
);
|
||||
provider.markMessageSent('c-echo-callbacks', 0, 0);
|
||||
|
||||
provider.handleMessageEcho(
|
||||
'c-echo-callbacks',
|
||||
1,
|
||||
4,
|
||||
-90,
|
||||
pathBytes: Uint8List.fromList([0xAA]),
|
||||
);
|
||||
provider.handleMessageEcho(
|
||||
'c-echo-callbacks',
|
||||
1,
|
||||
5,
|
||||
-89,
|
||||
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
|
||||
);
|
||||
provider.handleMessageEcho(
|
||||
'c-echo-callbacks',
|
||||
1,
|
||||
6,
|
||||
-88,
|
||||
pathBytes: Uint8List.fromList([0xAA, 0xBB, 0xCC]),
|
||||
);
|
||||
|
||||
expect(provider.messages.single.echoCount, equals(3));
|
||||
expect(provider.messages.single.lastEchoSnrRaw, equals(6));
|
||||
expect(provider.messages.single.lastEchoRssiDbm, equals(-88));
|
||||
expect(
|
||||
provider.getMessageReceptionDetails('c-echo-callbacks')?.pathBytes,
|
||||
[0xAA, 0xBB, 0xCC],
|
||||
);
|
||||
});
|
||||
|
||||
test('channel warning clears when replay arrives after send', () {
|
||||
fakeAsync((async) {
|
||||
final provider = MessagesProvider();
|
||||
@@ -302,9 +343,110 @@ void main() {
|
||||
|
||||
expect(provider.messages, hasLength(1));
|
||||
expect(provider.messages.single.id, equals('c-echo'));
|
||||
expect(provider.messages.single.echoCount, equals(1));
|
||||
expect(provider.messages.single.pathLen, equals(1));
|
||||
});
|
||||
|
||||
test('channel replay count reflects how many times a sent message was heard', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||
provider.addSentMessage(
|
||||
_buildSentChannelMessage(
|
||||
id: 'c-repeat-count',
|
||||
senderTimestamp: 1700000110,
|
||||
),
|
||||
);
|
||||
provider.markMessageSent('c-repeat-count', 0, 0);
|
||||
|
||||
provider.addMessage(
|
||||
_buildReceivedChannelReplay(
|
||||
id: 'c-repeat-count-1',
|
||||
senderTimestamp: 1700000111,
|
||||
senderName: 'dz0ny (SI)',
|
||||
),
|
||||
);
|
||||
provider.addMessage(
|
||||
_buildReceivedChannelReplay(
|
||||
id: 'c-repeat-count-2',
|
||||
senderTimestamp: 1700000112,
|
||||
senderName: 'dz0ny (SI)',
|
||||
),
|
||||
);
|
||||
|
||||
expect(provider.messages, hasLength(1));
|
||||
expect(provider.messages.single.echoCount, equals(2));
|
||||
expect(provider.getMessageReceptionDetails('c-repeat-count')?.receivedCopies, equals(3));
|
||||
});
|
||||
|
||||
test('channel replay keeps flood mode hop count untouched', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||
provider.addSentMessage(
|
||||
_buildSentChannelMessage(
|
||||
id: 'c-flood-path',
|
||||
senderTimestamp: 1700000120,
|
||||
),
|
||||
);
|
||||
provider.updateMessageRouteSelection(
|
||||
'c-flood-path',
|
||||
PathSelection.flood(),
|
||||
routerFallbackAttempted: false,
|
||||
);
|
||||
provider.markMessageSent('c-flood-path', 0, 0);
|
||||
|
||||
provider.addMessage(
|
||||
_buildReceivedChannelReplay(
|
||||
id: 'c-flood-path-incoming',
|
||||
senderTimestamp: 1700000121,
|
||||
senderName: 'dz0ny (SI)',
|
||||
).copyWith(pathBytes: Uint8List.fromList([0xAA, 0xBB])),
|
||||
);
|
||||
|
||||
expect(provider.messages, hasLength(1));
|
||||
expect(provider.messages.single.echoCount, equals(1));
|
||||
expect(provider.messages.single.pathLen, equals(0));
|
||||
expect(provider.messages.single.pathBytes, isEmpty);
|
||||
expect(
|
||||
provider.getMessageRouteMetadata('c-flood-path')?.mode,
|
||||
PathSelectionMode.flood,
|
||||
);
|
||||
});
|
||||
|
||||
test('channel replay preserves sent direct path bytes', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||
provider.addSentMessage(
|
||||
_buildSentChannelMessage(
|
||||
id: 'c-direct-path',
|
||||
senderTimestamp: 1700000130,
|
||||
),
|
||||
);
|
||||
provider.updateMessageRouteSelection(
|
||||
'c-direct-path',
|
||||
PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList([0x01, 0x02]),
|
||||
hopCount: 2,
|
||||
hashSize: 1,
|
||||
),
|
||||
routerFallbackAttempted: false,
|
||||
);
|
||||
provider.markMessageSent('c-direct-path', 0, 0);
|
||||
|
||||
provider.addMessage(
|
||||
_buildReceivedChannelReplay(
|
||||
id: 'c-direct-path-incoming',
|
||||
senderTimestamp: 1700000131,
|
||||
senderName: 'dz0ny (SI)',
|
||||
).copyWith(pathBytes: Uint8List.fromList([0xAA, 0xBB])),
|
||||
);
|
||||
|
||||
expect(provider.messages, hasLength(1));
|
||||
expect(provider.messages.single.echoCount, equals(1));
|
||||
expect(provider.messages.single.pathLen, equals(2));
|
||||
expect(provider.messages.single.pathBytes, [0x01, 0x02]);
|
||||
});
|
||||
|
||||
test(
|
||||
'channel replay is not deduped for different sender with same text',
|
||||
() {
|
||||
@@ -784,7 +926,9 @@ void main() {
|
||||
);
|
||||
provider.markMessageSent('m2', 88, 10);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 11));
|
||||
final timeoutMs = provider.messages.single.suggestedTimeoutMs!;
|
||||
|
||||
async.elapse(Duration(milliseconds: timeoutMs + 1));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(provider.messages.single.retryAttempt, 1);
|
||||
|
||||
31
test/utils/location_formats_test.dart
Normal file
31
test/utils/location_formats_test.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/location_formats.dart';
|
||||
|
||||
void main() {
|
||||
group('formatUtm', () {
|
||||
test('formats northern hemisphere UTM coordinates', () {
|
||||
expect(formatUtm(37.7749, -122.4194), '10S 551131E 4180999N');
|
||||
});
|
||||
|
||||
test('formats southern hemisphere UTM coordinates', () {
|
||||
expect(formatUtm(-33.8688, 151.2093), '56H 334369E 6250948N');
|
||||
});
|
||||
|
||||
test('falls back to decimal outside the UTM latitude range', () {
|
||||
expect(formatUtm(85, 14.5), '85.00000, 14.50000');
|
||||
});
|
||||
});
|
||||
|
||||
group('formatCoordinates', () {
|
||||
test('formats DMS coordinates', () {
|
||||
expect(
|
||||
formatCoordinates(
|
||||
46.0569,
|
||||
14.5058,
|
||||
CoordinateDisplayFormat.dms,
|
||||
),
|
||||
'46°03\'24.84"N 14°30\'20.88"E',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -26,9 +26,30 @@ void main() {
|
||||
expect(decoded!.payloadType, 0x01);
|
||||
expect(decoded.pathDescriptor, 0x04);
|
||||
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
|
||||
expect(decoded.hashSize, 2);
|
||||
expect(decoded.hopHashes, ['c2ba', '5fde']);
|
||||
expect(decoded.originalSenderHashHex, 'c2ba');
|
||||
expect(decoded.hashSize, 1);
|
||||
expect(decoded.hopHashes, ['c2', 'ba', '5f', 'de']);
|
||||
expect(decoded.originalSenderHashHex, 'c2');
|
||||
});
|
||||
|
||||
test('parses legacy two byte paths as two one-byte hops', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
0x37,
|
||||
0xae,
|
||||
0x05,
|
||||
0x02,
|
||||
0xc2,
|
||||
0xba,
|
||||
]);
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(packet);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.pathDescriptor, 0x02);
|
||||
expect(decoded.pathBytes, [0xc2, 0xba]);
|
||||
expect(decoded.hashSize, 1);
|
||||
expect(decoded.hopCount, 2);
|
||||
expect(decoded.hopHashes, ['c2', 'ba']);
|
||||
});
|
||||
|
||||
test('parses encoded descriptor with 2-byte hashes', () {
|
||||
@@ -55,7 +76,7 @@ void main() {
|
||||
expect(decoded.hopHashes, ['c2ba', '5fde']);
|
||||
});
|
||||
|
||||
test('uses preferred hash size when packet length is ambiguous', () {
|
||||
test('uses one byte hashes for legacy packet lengths', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
0x37,
|
||||
@@ -70,7 +91,7 @@ void main() {
|
||||
0xff,
|
||||
]);
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 1);
|
||||
final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 2);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.hashSize, 1);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/network_scanner_service.dart';
|
||||
import 'package:meshcore_sar_app/widgets/connection_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakeConnectionProvider extends ConnectionProvider {
|
||||
int startScanCalls = 0;
|
||||
@@ -75,10 +76,18 @@ class _TcpConnectableFakeConnectionProvider extends ConnectionProvider {
|
||||
}
|
||||
|
||||
class _FakeNetworkScannerService extends NetworkScannerService {
|
||||
_FakeNetworkScannerService({
|
||||
this.keepScanning = false,
|
||||
bool initiallyScanning = false,
|
||||
}) : _isScanning = initiallyScanning;
|
||||
|
||||
final bool keepScanning;
|
||||
int scanCalls = 0;
|
||||
int stopScanCalls = 0;
|
||||
bool _isScanning;
|
||||
|
||||
@override
|
||||
bool get isScanning => false;
|
||||
bool get isScanning => _isScanning;
|
||||
|
||||
@override
|
||||
bool get hasCachedResults => false;
|
||||
@@ -89,6 +98,10 @@ class _FakeNetworkScannerService extends NetworkScannerService {
|
||||
@override
|
||||
Future<List<DiscoveredServer>> scan({int? port}) async {
|
||||
scanCalls += 1;
|
||||
_isScanning = keepScanning;
|
||||
if (keepScanning) {
|
||||
onProgressUpdate?.call(1, 10);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@@ -96,10 +109,17 @@ class _FakeNetworkScannerService extends NetworkScannerService {
|
||||
void clearCache() {}
|
||||
|
||||
@override
|
||||
void stopScan() {}
|
||||
void stopScan() {
|
||||
stopScanCalls += 1;
|
||||
_isScanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('BLE scan waits for explicit user action', (tester) async {
|
||||
final connectionProvider = _FakeConnectionProvider();
|
||||
|
||||
@@ -173,9 +193,85 @@ void main() {
|
||||
expect(find.byType(ConnectionDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('manual TCP connect accepts an IP address from the network tab', (
|
||||
testWidgets('manual TCP connect can cancel discovery and use a custom port', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final connectionProvider = _TcpConnectableFakeConnectionProvider();
|
||||
final networkScanner = _FakeNetworkScannerService(
|
||||
initiallyScanning: true,
|
||||
keepScanning: true,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<ConnectionProvider>.value(
|
||||
value: connectionProvider,
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet<Object?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => ConnectionDialog(
|
||||
networkScanner: networkScanner,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Network'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final manualEntryButton = find.widgetWithText(
|
||||
TextButton,
|
||||
'Cancel and enter manually',
|
||||
);
|
||||
final onPressed = tester.widget<TextButton>(manualEntryButton).onPressed;
|
||||
expect(onPressed, isNotNull);
|
||||
onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(networkScanner.stopScanCalls, greaterThanOrEqualTo(1));
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'IP address'), '192.168.1.42');
|
||||
await tester.enterText(find.widgetWithText(TextField, 'TCP port'), '6001');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(connectionProvider.connectTcpCalls, 1);
|
||||
expect(connectionProvider.connectedHost, '192.168.1.42');
|
||||
expect(connectionProvider.connectedPort, 6001);
|
||||
expect(find.byType(ConnectionDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('recent servers show saved metadata and can reconnect', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'recent_tcp_connections_v1': [
|
||||
'{"name":"Mesh Node Alpha","host":"10.0.0.5","port":5001,"lastUsedAt":"2026-04-18T12:00:00.000Z"}',
|
||||
],
|
||||
});
|
||||
|
||||
final connectionProvider = _TcpConnectableFakeConnectionProvider();
|
||||
final networkScanner = _FakeNetworkScannerService();
|
||||
|
||||
@@ -213,18 +309,15 @@ void main() {
|
||||
await tester.tap(find.text('Network'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(networkScanner.scanCalls, 1);
|
||||
expect(find.text('Recently used'), findsOneWidget);
|
||||
expect(find.text('Mesh Node Alpha'), findsOneWidget);
|
||||
expect(find.text('10.0.0.5:5001'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Add IP address'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), '192.168.1.42');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Connect').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(connectionProvider.connectTcpCalls, 1);
|
||||
expect(connectionProvider.connectedHost, '192.168.1.42');
|
||||
expect(connectionProvider.connectedPort, NetworkScannerService.defaultPort);
|
||||
expect(find.byType(ConnectionDialog), findsNothing);
|
||||
expect(connectionProvider.connectedHost, '10.0.0.5');
|
||||
expect(connectionProvider.connectedPort, 5001);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:geolocator/geolocator.dart';
|
||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||
import 'package:meshcore_sar_app/models/path_selection.dart';
|
||||
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';
|
||||
@@ -18,6 +19,7 @@ import 'package:meshcore_sar_app/providers/voice_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/location_tracking_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/message_extensions.dart';
|
||||
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -187,6 +189,103 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('sent channel status shows heard count and flood route', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = await _TestHarness.create();
|
||||
try {
|
||||
final message = Message(
|
||||
id: 'sent-channel-status',
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: _prefix(22),
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000001,
|
||||
text: 'Flood status',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000001500),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
echoCount: 2,
|
||||
);
|
||||
harness.messagesProvider.updateMessageRouteSelection(
|
||||
'sent-channel-status',
|
||||
PathSelection.flood(),
|
||||
routerFallbackAttempted: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: harness.messagesProvider),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) =>
|
||||
Text(message.getLocalizedDeliveryStatus(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2 nodes • Flood route'), findsOneWidget);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('details show sent channel echo relay path', (tester) async {
|
||||
final harness = await _TestHarness.create();
|
||||
try {
|
||||
final message = Message(
|
||||
id: 'sent-channel-echo-details',
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: _prefix(23),
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000002,
|
||||
text: 'Echo detail path',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000002500),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
echoCount: 1,
|
||||
lastEchoRssiDbm: -88,
|
||||
lastEchoSnrRaw: 6,
|
||||
);
|
||||
harness.messagesProvider.addSentMessage(message);
|
||||
harness.messagesProvider.handleMessageEcho(
|
||||
'sent-channel-echo-details',
|
||||
1,
|
||||
6,
|
||||
-88,
|
||||
pathBytes: Uint8List.fromList([0x10, 0x20, 0xAA]),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(harness, message));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.text('Echo detail path'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Details'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Last echo relay'), findsOneWidget);
|
||||
expect(find.text('AA'), findsOneWidget);
|
||||
expect(find.text('Last echo path'), findsOneWidget);
|
||||
expect(find.text('10:20:aa'), findsWidgets);
|
||||
expect(find.text('Last echo bytes report'), findsOneWidget);
|
||||
expect(
|
||||
find.text('3 bytes [10 20 AA] • hops #1=10, #2=20, #3=AA'),
|
||||
findsOneWidget,
|
||||
);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('channel bubbles refresh to synced channel names', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -403,7 +502,7 @@ void main() {
|
||||
await tester.longPress(find.text('Location details'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Technical details'));
|
||||
await tester.tap(find.text('Details'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
|
||||
@@ -451,7 +550,7 @@ void main() {
|
||||
await tester.longPress(find.text('Channel fallback'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Technical details'));
|
||||
await tester.tap(find.text('Details'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
||||
import 'package:meshcore_sar_app/widgets/sensors/sensor_history_sheet.dart';
|
||||
|
||||
void main() {
|
||||
test('history sheet honors initial field key when available', () {
|
||||
test('history screen honors initial field key when available', () {
|
||||
final selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: 'extra:illuminance_2',
|
||||
availableFieldKeys: const <String>[
|
||||
@@ -14,7 +15,7 @@ void main() {
|
||||
expect(selectedFieldKey, 'extra:illuminance_2');
|
||||
});
|
||||
|
||||
test('history sheet falls back to first available field', () {
|
||||
test('history screen falls back to first available field', () {
|
||||
final selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: 'extra:missing',
|
||||
availableFieldKeys: const <String>[
|
||||
@@ -26,7 +27,7 @@ void main() {
|
||||
expect(selectedFieldKey, 'temperature');
|
||||
});
|
||||
|
||||
test('history sheet returns null when no fields are available', () {
|
||||
test('history screen returns null when no fields are available', () {
|
||||
final selectedFieldKey = resolveInitialSensorHistoryField(
|
||||
requestedFieldKey: 'temperature',
|
||||
availableFieldKeys: const <String>[],
|
||||
@@ -34,4 +35,48 @@ void main() {
|
||||
|
||||
expect(selectedFieldKey, isNull);
|
||||
});
|
||||
|
||||
test('history range filters to the latest 24 hours', () {
|
||||
final samples = <SensorHistorySample>[
|
||||
SensorHistorySample(
|
||||
timestamp: DateTime(2026, 4, 1, 8),
|
||||
values: const {'temperature': 10},
|
||||
),
|
||||
SensorHistorySample(
|
||||
timestamp: DateTime(2026, 4, 2, 7),
|
||||
values: const {'temperature': 11},
|
||||
),
|
||||
SensorHistorySample(
|
||||
timestamp: DateTime(2026, 4, 2, 8),
|
||||
values: const {'temperature': 12},
|
||||
),
|
||||
];
|
||||
|
||||
final filtered = filterSensorHistorySamples(
|
||||
samples: samples,
|
||||
range: SensorHistoryRange.day,
|
||||
);
|
||||
|
||||
expect(filtered.map((sample) => sample.values['temperature']), [10, 11, 12]);
|
||||
});
|
||||
|
||||
test('history range all keeps every sample', () {
|
||||
final samples = <SensorHistorySample>[
|
||||
SensorHistorySample(
|
||||
timestamp: DateTime(2026, 4, 1, 8),
|
||||
values: const {'temperature': 10},
|
||||
),
|
||||
SensorHistorySample(
|
||||
timestamp: DateTime(2026, 4, 2, 8),
|
||||
values: const {'temperature': 12},
|
||||
),
|
||||
];
|
||||
|
||||
final filtered = filterSensorHistorySamples(
|
||||
samples: samples,
|
||||
range: SensorHistoryRange.all,
|
||||
);
|
||||
|
||||
expect(filtered, hasLength(2));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart';
|
||||
import 'package:meshcore_sar_app/widgets/settings/traffic_stats_reporting_section.dart';
|
||||
|
||||
@@ -57,6 +58,8 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ListenableBuilder(
|
||||
listenable: service,
|
||||
@@ -68,27 +71,22 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Anonymous RX stats reporting'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Upload RX live-traffic packet type and path mode totals to the fixed Cloudflare worker every 5 minutes.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Anonymous RX stats'), findsOneWidget);
|
||||
expect(find.text('Upload packet totals every 5 min'), findsOneWidget);
|
||||
expect(find.text('Reporting interval'), findsNothing);
|
||||
expect(service.isEnabled, isTrue);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'View'));
|
||||
await tester.pump();
|
||||
|
||||
expect(launchedUrls, ['https://mcstats.dz0ny.dev']);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(service.isEnabled, isFalse);
|
||||
expect(service.intervalMinutes, 5);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'View public stats'));
|
||||
await tester.pump();
|
||||
|
||||
expect(launchedUrls, ['https://mcstats.dz0ny.dev']);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user