Compare commits

..

9 Commits

Author SHA1 Message Date
Janez T
5834454087 feat: Add UTM coordinates #38
ref:
2026-04-26 08:28:41 +02:00
Janez T
73e78691ed fix: Split trail recording #32
ref:
2026-04-26 08:24:11 +02:00
Janez T
d067095345 fix: Add background service #37
ref: #31
2026-04-26 08:24:09 +02:00
Janez T
0b463f6454 fix: Stabilize reported tests #31
ref: #32 #37
2026-04-26 08:24:07 +02:00
Janez T
a472b0c05c chore: Add Play release cycle #31
ref:
2026-04-26 08:24:04 +02:00
Janez T
4181b33da1 Fix echo metadata and bump build number #123 2026-04-26 08:02:53 +02:00
Janez T
f9dfdc2340 fix: Add manual TCP connect flow 2026-04-18 08:59:36 +02:00
Janez T
b333b3737e feat: Open per-metric sensor history 2026-04-12 21:07:53 +02:00
Janez T
7245d76e17 chore: Bump iOS build number 2026-04-08 18:45:52 +02:00
54 changed files with 3093 additions and 2297 deletions

View File

@@ -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
View File

@@ -0,0 +1,4 @@
source "https://rubygems.org"
gem "fastlane"
gem "ostruct"

View File

@@ -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

View File

@@ -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
View 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.

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 135;
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 = 135;
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 = 135;
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 = 135;
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 = 135;
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 = 135;
CURRENT_PROJECT_VERSION = 141;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

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

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000252">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408391">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.48394">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="120.175289">
<testcase classname="fastlane.lanes" name="2: build_app" time="153.436107">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="1035.867128">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="6642.682605">
</testcase>

View File

@@ -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": {},

View File

@@ -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.

View File

@@ -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';

View File

@@ -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

View File

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

View File

@@ -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 =

View File

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

View File

@@ -12,6 +12,30 @@ import 'contacts_provider.dart';
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorHistorySample {
final DateTime timestamp;
final Map<String, double> values;
const SensorHistorySample({required this.timestamp, required this.values});
factory SensorHistorySample.fromJson(Map<String, dynamic> json) {
final rawValues = json['values'] as Map<String, dynamic>? ?? const {};
return SensorHistorySample(
timestamp: DateTime.fromMillisecondsSinceEpoch(
(json['timestampMillis'] as num?)?.toInt() ?? 0,
),
values: rawValues.map(
(key, value) => MapEntry(key, (value as num).toDouble()),
),
);
}
Map<String, dynamic> toJson() => {
'timestampMillis': timestamp.millisecondsSinceEpoch,
'values': values,
};
}
class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1);
static const Duration selfAutoRefreshInterval = Duration(seconds: 30);
@@ -21,6 +45,10 @@ class SensorsProvider with ChangeNotifier {
static const String _metricLabelKey = 'sensor_metric_labels';
static const String _metricOrderKey = 'sensor_metric_order';
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
static const String _historyKey = 'sensor_history_v1';
static const String _telemetrySourceChannelPrefix = '__source_channel:';
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
static const int _maxHistorySamplesPerSensor = 288;
static const List<int> supportedAutoRefreshIntervals = <int>[
0,
5,
@@ -62,6 +90,8 @@ class SensorsProvider with ChangeNotifier {
<String, List<String>>{};
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
final Map<String, List<SensorHistorySample>> _historyBySensor =
<String, List<SensorHistorySample>>{};
bool _isLoaded = false;
bool _isRefreshingAll = false;
bool _isRunningAutoRefreshTick = false;
@@ -91,6 +121,7 @@ class SensorsProvider with ChangeNotifier {
final storedAutoRefreshJson = prefs.getString(
_key(_autoRefreshMinutesKey),
);
final storedHistoryJson = prefs.getString(_key(_historyKey));
_watchedSensorKeys
..clear()
..addAll(stored);
@@ -102,6 +133,7 @@ class SensorsProvider with ChangeNotifier {
_metricOrderBySensor.clear();
_autoRefreshMinutesBySensor.clear();
_lastRefreshAttemptAt.clear();
_historyBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
@@ -146,6 +178,21 @@ class SensorsProvider with ChangeNotifier {
}
}
}
if (storedHistoryJson != null && storedHistoryJson.isNotEmpty) {
final decoded = jsonDecode(storedHistoryJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final rawSamples = entry.value as List<dynamic>? ?? const [];
final samples = rawSamples
.whereType<Map<String, dynamic>>()
.map(SensorHistorySample.fromJson)
.where((sample) => sample.values.isNotEmpty)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
if (samples.isNotEmpty) {
_historyBySensor[entry.key] = samples;
}
}
}
_autoRefreshMinutesBySensor.removeWhere(
(key, _) => !_watchedSensorKeys.contains(key),
);
@@ -247,6 +294,24 @@ class SensorsProvider with ChangeNotifier {
}
}
Future<void> _persistHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = <String, dynamic>{};
for (final entry in _historyBySensor.entries) {
if (entry.value.isEmpty) {
continue;
}
encoded[entry.key] = entry.value
.map((sample) => sample.toJson())
.toList(growable: false);
}
await prefs.setString(_key(_historyKey), jsonEncode(encoded));
} catch (e) {
debugPrint('Error saving sensor history: $e');
}
}
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
);
@@ -312,6 +377,11 @@ class SensorsProvider with ChangeNotifier {
int autoRefreshMinutesFor(String publicKeyHex) =>
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
List<SensorHistorySample> historyFor(String publicKeyHex) =>
List<SensorHistorySample>.unmodifiable(
_historyBySensor[publicKeyHex] ?? const <SensorHistorySample>[],
);
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
@@ -577,12 +647,14 @@ class SensorsProvider with ChangeNotifier {
_metricOrderBySensor.remove(publicKeyHex);
_autoRefreshMinutesBySensor.remove(publicKeyHex);
_lastRefreshAttemptAt.remove(publicKeyHex);
_historyBySensor.remove(publicKeyHex);
await _persistWatchedSensors();
await _persistVisibleMetrics();
await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
await _persistAutoRefreshMinutes();
await _persistHistory();
notifyListeners();
}
@@ -795,6 +867,42 @@ class SensorsProvider with ChangeNotifier {
}
}
Future<void> captureTrackedTelemetryHistory({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
final trackedKeys = <String>{
..._watchedSensorKeys.where((key) => autoRefreshMinutesFor(key) > 0),
};
final self = selfContact(contactsProvider, connectionProvider);
if (self != null && _lastRefreshAttemptAt.containsKey(self.publicKeyHex)) {
trackedKeys.add(self.publicKeyHex);
}
if (trackedKeys.isEmpty) {
return;
}
var changed = false;
for (final key in trackedKeys) {
final contact = contactForDisplay(
key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
if (contact == null) {
continue;
}
changed = _captureTelemetryHistory(contact) || changed;
}
if (!changed) {
return;
}
await _persistHistory();
notifyListeners();
}
void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];
@@ -851,4 +959,100 @@ class SensorsProvider with ChangeNotifier {
telemetry: telemetry,
);
}
bool _captureTelemetryHistory(Contact contact) {
final telemetry = contact.telemetry;
if (telemetry == null) {
return false;
}
final values = _historyValuesForTelemetry(telemetry);
if (values.isEmpty) {
return false;
}
final samples = _historyBySensor.putIfAbsent(
contact.publicKeyHex,
() => <SensorHistorySample>[],
);
final timestamp = telemetry.timestamp;
final existingIndex = samples.lastIndexWhere(
(sample) => sample.timestamp.millisecondsSinceEpoch ==
timestamp.millisecondsSinceEpoch,
);
final nextSample = SensorHistorySample(timestamp: timestamp, values: values);
if (existingIndex >= 0) {
final current = samples[existingIndex];
if (mapEquals(current.values, values)) {
return false;
}
samples[existingIndex] = nextSample;
return true;
}
samples.add(nextSample);
samples.sort((a, b) => a.timestamp.compareTo(b.timestamp));
if (samples.length > _maxHistorySamplesPerSensor) {
samples.removeRange(0, samples.length - _maxHistorySamplesPerSensor);
}
return true;
}
Map<String, double> _historyValuesForTelemetry(ContactTelemetry telemetry) {
final values = <String, double>{};
if (telemetry.batteryMilliVolts != null) {
values['voltage'] = telemetry.batteryMilliVolts! / 1000;
}
if (telemetry.batteryPercentage != null) {
values['battery'] = telemetry.batteryPercentage!;
}
if (telemetry.temperature != null) {
values['temperature'] = telemetry.temperature!;
}
if (telemetry.humidity != null) {
values['humidity'] = telemetry.humidity!;
}
if (telemetry.pressure != null) {
values['pressure'] = telemetry.pressure!;
}
final extraSensorData = telemetry.extraSensorData;
if (extraSensorData != null) {
for (final entry in extraSensorData.entries) {
if (_isTelemetryMetadataKey(entry.key)) {
continue;
}
final numericValue = _historyNumericValue(entry.value);
if (numericValue == null) {
continue;
}
values[_extraFieldKey(entry.key)] = numericValue;
}
}
return values;
}
double? _historyNumericValue(dynamic value) {
if (value is num) {
return value.toDouble();
}
if (value is bool) {
return value ? 1 : 0;
}
return null;
}
bool _isTelemetryMetadataKey(String key) {
return key.startsWith(_telemetrySourceChannelPrefix) ||
key == _rawTelemetryHexKey;
}
String _extraFieldKey(String key) {
return 'extra:$key';
}
}

View File

@@ -35,9 +35,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
@override
void initState() {
super.initState();
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes();
if (widget.autoDiscoverRepeatersOnOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {

View File

@@ -11,6 +11,7 @@ import '../providers/app_provider.dart';
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/sensors_provider.dart';
import '../theme/app_theme.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
@@ -71,6 +72,7 @@ class _HomeScreenState extends State<HomeScreen>
bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _lastProfileDeviceKey;
Timer? _sensorAutoRefreshTicker;
List<_HomeTab> get _enabledTabs {
return [
@@ -116,6 +118,7 @@ class _HomeScreenState extends State<HomeScreen>
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleConnectionProviderChanged();
});
_configureSensorAutoRefreshTicker();
}
void _initTabController() {
@@ -182,6 +185,7 @@ class _HomeScreenState extends State<HomeScreen>
if (!_isMapEnabled) {
_isMapFullscreen = false;
}
_configureSensorAutoRefreshTicker();
final newTabs = _enabledTabs;
final newIndex = newTabs.indexOf(oldTab);
@@ -252,6 +256,7 @@ class _HomeScreenState extends State<HomeScreen>
@override
void dispose() {
_sensorAutoRefreshTicker?.cancel();
_connectionProvider.removeListener(_handleConnectionProviderChanged);
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
@@ -269,11 +274,50 @@ class _HomeScreenState extends State<HomeScreen>
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
_configureSensorAutoRefreshTicker();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
}
void _configureSensorAutoRefreshTicker() {
_sensorAutoRefreshTicker?.cancel();
if (!_isSensorsEnabled || _lifecycleState != AppLifecycleState.resumed) {
_sensorAutoRefreshTicker = null;
return;
}
unawaited(_runSensorAutoRefreshTick());
_sensorAutoRefreshTicker = Timer.periodic(
SensorsProvider.selfAutoRefreshInterval,
(_) {
unawaited(_runSensorAutoRefreshTick());
},
);
}
Future<void> _runSensorAutoRefreshTick() async {
if (!mounted ||
!_isSensorsEnabled ||
_lifecycleState != AppLifecycleState.resumed) {
return;
}
final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>();
sensorsProvider.clearExpiredRefreshStates();
await sensorsProvider.refreshDueSensors(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
now: DateTime.now(),
);
await sensorsProvider.captureTrackedTelemetryHistory(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {

View File

@@ -16,7 +16,6 @@ import '../services/route_hash_preferences.dart';
import '../services/traffic_stats_reporting_service.dart';
import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart';
import 'packet_log_screen.dart';
import '../l10n/app_localizations.dart';
@@ -769,7 +768,6 @@ class _LiveTrafficCard extends StatelessWidget {
color: Colors.transparent,
child: InkWell(
onTap: () => _showPacketBytesSheet(context, log.rawData),
onLongPress: () => _showTraceSheet(context, entry),
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(14),
@@ -1034,30 +1032,6 @@ class _LiveTrafficCard extends StatelessWidget {
);
}
static Future<void> _showTraceSheet(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.pathBytes.isEmpty) {
return Future.value();
}
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet.packetPath(
packetPath: route.pathBytes,
descriptionOverride:
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
noRelayMatchTextOverride:
'No named nodes could be matched for this packet path.',
),
);
}
}
class _LiveTrafficPacketDetails {

View File

@@ -750,9 +750,7 @@ class _DecodedRouteSection extends StatelessWidget {
return FutureBuilder<List<dynamic>>(
future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
MeshMapNodesService.loadCachedNodes(),
]),
builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode(

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -10,6 +8,7 @@ import '../providers/map_provider.dart';
import '../providers/sensors_provider.dart';
import '../widgets/contacts/ping_contact_sheet.dart';
import '../widgets/sensors/bthome_met_history_sheet.dart';
import '../widgets/sensors/sensor_history_sheet.dart';
import '../widgets/sensors/sensor_telemetry_card.dart';
import '../l10n/app_localizations.dart';
@@ -23,72 +22,19 @@ class SensorsTab extends StatefulWidget {
}
class _SensorsTabState extends State<SensorsTab> {
static const Duration _autoRefreshTickInterval = Duration(seconds: 30);
Timer? _minuteTicker;
final Map<String, DateTime> _lastCenteredTelemetryAtBySensor =
<String, DateTime>{};
@override
void initState() {
super.initState();
if (widget.isActive) {
unawaited(_handleMinuteTick());
_scheduleMinuteTicker();
}
}
@override
void dispose() {
_minuteTicker?.cancel();
super.dispose();
}
@override
void didUpdateWidget(covariant SensorsTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive == widget.isActive) {
return;
}
if (widget.isActive) {
unawaited(_handleMinuteTick());
_scheduleMinuteTicker();
return;
}
_minuteTicker?.cancel();
_minuteTicker = null;
}
void _scheduleMinuteTicker() {
_minuteTicker?.cancel();
if (!widget.isActive) {
return;
}
_minuteTicker = Timer.periodic(_autoRefreshTickInterval, (_) {
unawaited(_handleMinuteTick());
});
}
Future<void> _handleMinuteTick() async {
if (!mounted || !widget.isActive) {
return;
}
final sensorsProvider = context.read<SensorsProvider>();
sensorsProvider.clearExpiredRefreshStates();
await sensorsProvider.refreshDueSensors(
contactsProvider: context.read<ContactsProvider>(),
connectionProvider: context.read<ConnectionProvider>(),
now: DateTime.now(),
);
if (!mounted) {
return;
}
setState(() {});
}
Future<void> _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>();
@@ -356,6 +302,20 @@ class _SensorsTabState extends State<SensorsTab> {
: null,
onCustomize: () =>
_showMetricSelector(context, key, contact),
onMetricTap: (fieldKey) async {
final history = sensorsProvider.historyFor(key);
final hasHistoryForField = history.any(
(sample) => sample.values.containsKey(fieldKey),
);
if (!hasHistoryForField) {
return;
}
await showSensorHistorySheet(
context,
publicKeyHex: key,
initialFieldKey: fieldKey,
);
},
onShowMetHistory: (contact) =>
showBTHomeMetHistorySheet(context, contact: contact),
onMoveUp: isWatchedCard && index > 0

View File

@@ -20,7 +20,6 @@ import '../models/contact.dart';
import '../models/config_profile.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart';
@@ -99,8 +98,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
String _messageDestinationLockType =
MessageDestinationPreferences.destinationTypeChannel;
String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService();
@@ -119,7 +116,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadFastLocationSettings();
_loadDeveloperMode();
_loadProfilesEnabled();
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
_loadMessageDestinationLock();
@@ -185,14 +181,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _loadMessageDestinationLock() async {
final lockedDestination =
await MessageDestinationPreferences.getLockedDestination();
@@ -1119,66 +1107,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Future<void> _clearOnlineTraceCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.clearOnlineTraceDatabase),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_isClearingOnlineTraceCache = true;
});
await MeshMapNodesService.clearCache();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = null;
_isClearingOnlineTraceCache = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.onlineTraceDatabaseCleared),
backgroundColor: Colors.orange,
),
);
}
String _onlineTraceCacheSubtitle() {
final cachedAt = _onlineTraceCacheUpdatedAt;
if (cachedAt == null) {
return 'No cached online database. Refresh runs in background when internet is available.';
}
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int part) => part.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>(
context: context,
@@ -1614,11 +1542,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
]),
const SizedBox(height: 12),
// ── Map & Tracing ──
// ── Map ──
_buildSection(
icon: Icons.map_rounded,
title: AppLocalizations.of(context)!.map,
subtitle: AppLocalizations.of(context)!.displayMarkersAndTraceDatabase,
children: [
SwitchListTile(
dense: true,
@@ -1672,29 +1599,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _saveMapPreference('map_show_debug_info', value);
},
),
const Divider(height: 1),
ListTile(
dense: true,
leading: const Icon(Icons.cloud_sync, size: 20),
title: Text(l10n.onlineTraceDatabase),
subtitle: Text(
_onlineTraceCacheSubtitle(),
style: Theme.of(context).textTheme.bodySmall,
),
trailing: _isClearingOnlineTraceCache
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: TextButton(
onPressed: _clearOnlineTraceCache,
child: const Text(
'Clear',
style: TextStyle(color: Colors.red),
),
),
),
]),
const SizedBox(height: 12),

View File

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

View File

@@ -45,8 +45,7 @@ class MeshMapNodesService {
'https://api.meshcore.nz/api/v1/map/nodes';
static const int repeaterType = 1;
static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30);
static const Duration _requestTimeout = Duration(seconds: 30);
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
static List<MeshMapNode>? _cachedNodes;
@@ -74,7 +73,7 @@ class MeshMapNodesService {
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint))
.timeout(traceTimeout);
.timeout(_requestTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Map nodes API returned ${response.statusCode}');
}

View File

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

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

View File

@@ -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';

View File

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

View File

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

View File

@@ -1,239 +0,0 @@
import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode {
final List<MeshMapNode> candidates;
final int matchCount;
final bool usedOnlineFallback;
final int selectedIndex;
const ResolvedTraceNode({
required this.candidates,
required this.matchCount,
required this.usedOnlineFallback,
this.selectedIndex = 0,
});
MeshMapNode? get node =>
candidates.isEmpty ? null : candidates[selectedIndex];
bool get hasMatch => node != null;
bool get isAmbiguous => matchCount > 1;
bool get canCycle => candidates.length > 1;
String? get matchSummary {
if (matchCount <= 1) return null;
final source = usedOnlineFallback ? 'online' : 'local';
return '$matchCount $source matches';
}
String? get cycleSummary =>
canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null;
ResolvedTraceNode cycle() {
if (!canCycle) return this;
return ResolvedTraceNode(
candidates: candidates,
matchCount: matchCount,
usedOnlineFallback: usedOnlineFallback,
selectedIndex: (selectedIndex + 1) % candidates.length,
);
}
}
class TraceNodeResolver {
static const Distance _distance = Distance();
const TraceNodeResolver._();
static ResolvedTraceNode resolveBest({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required String? prefixHex,
LatLng? referenceA,
LatLng? referenceB,
String? preferredPrefix,
}) {
if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final allMatches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList();
if (allMatches.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final localMatches = allMatches
.where((node) => localPublicKeys.contains(node.publicKey))
.toList();
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
final usedOnlineFallback = localMatches.isEmpty;
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
final preferredMatches = pool
.where((node) => node.publicKey.startsWith(preferredPrefix))
.toList();
if (preferredMatches.isNotEmpty) {
pool = preferredMatches;
}
}
pool.sort((a, b) {
final distanceCompare =
_scoreNode(
a,
referenceA: referenceA,
referenceB: referenceB,
).compareTo(
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
);
if (distanceCompare != 0) return distanceCompare;
return b.updatedAtMs.compareTo(a.updatedAtMs);
});
return ResolvedTraceNode(
candidates: List<MeshMapNode>.unmodifiable(pool),
matchCount: pool.length,
usedOnlineFallback: usedOnlineFallback,
);
}
static List<ResolvedTraceNode> alignPathSelections({
required List<ResolvedTraceNode> nodes,
MeshMapNode? startNode,
MeshMapNode? endNode,
}) {
if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
return nodes;
}
final candidateCosts = List.generate(
nodes.length,
(_) => <double>[],
growable: false,
);
final previousChoice = List.generate(
nodes.length,
(_) => <int>[],
growable: false,
);
for (var i = 0; i < nodes.length; i++) {
final currentCandidates = nodes[i].candidates;
candidateCosts[i] = List<double>.filled(
currentCandidates.length,
double.infinity,
);
previousChoice[i] = List<int>.filled(currentCandidates.length, -1);
for (var j = 0; j < currentCandidates.length; j++) {
final current = currentCandidates[j];
if (i == 0) {
candidateCosts[i][j] = startNode == null
? 0
: _distanceBetweenNodes(startNode, current);
continue;
}
final previousCandidates = nodes[i - 1].candidates;
for (var k = 0; k < previousCandidates.length; k++) {
final candidateCost =
candidateCosts[i - 1][k] +
_distanceBetweenNodes(previousCandidates[k], current);
if (candidateCost < candidateCosts[i][j]) {
candidateCosts[i][j] = candidateCost;
previousChoice[i][j] = k;
}
}
}
}
var bestLastIndex = 0;
var bestLastCost = double.infinity;
final lastCandidates = nodes.last.candidates;
for (var i = 0; i < lastCandidates.length; i++) {
final endCost = endNode == null
? 0
: _distanceBetweenNodes(lastCandidates[i], endNode);
final totalCost = candidateCosts.last[i] + endCost;
if (totalCost < bestLastCost) {
bestLastCost = totalCost;
bestLastIndex = i;
}
}
final selectedIndices = List<int>.filled(nodes.length, 0);
selectedIndices[nodes.length - 1] = bestLastIndex;
for (var i = nodes.length - 1; i > 0; i--) {
selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
}
return List<ResolvedTraceNode>.generate(nodes.length, (index) {
final resolved = nodes[index];
return ResolvedTraceNode(
candidates: resolved.candidates,
matchCount: resolved.matchCount,
usedOnlineFallback: resolved.usedOnlineFallback,
selectedIndex: selectedIndices[index],
);
}, growable: false);
}
static double _scoreNode(
MeshMapNode node, {
LatLng? referenceA,
LatLng? referenceB,
}) {
final point = LatLng(node.latitude, node.longitude);
if (referenceA != null && referenceB != null) {
return _distanceToSegmentMeters(point, referenceA, referenceB);
}
if (referenceA != null) {
return _distance.as(LengthUnit.Meter, point, referenceA);
}
if (referenceB != null) {
return _distance.as(LengthUnit.Meter, point, referenceB);
}
return double.maxFinite;
}
static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
return _distance.as(
LengthUnit.Meter,
LatLng(a.latitude, a.longitude),
LatLng(b.latitude, b.longitude),
);
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

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

View File

@@ -17,7 +17,6 @@ import '../../services/message_destination_preferences.dart';
import '../../services/path_history_service.dart';
import 'contact_route_dialog.dart';
import 'ping_contact_sheet.dart';
import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart';
import '../common/contact_avatar.dart';
import '../sensors/bthome_met_history_sheet.dart';
@@ -644,15 +643,6 @@ class ContactTile extends StatelessWidget {
await _addContactToSensors(context, contact);
},
),
if (!contact.isChannel)
_ContactSheetAction(
icon: Icons.route,
label: l10n.trace,
onTap: () async {
Navigator.pop(context);
_showTraceSheet(context, contact);
},
),
if (contact.type == ContactType.repeater)
_ContactSheetAction(
icon: Icons.hub_outlined,
@@ -805,18 +795,6 @@ class ContactTile extends StatelessWidget {
);
}
void _showTraceSheet(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => ContactTraceSheet(contact: contact),
);
}
void _pingRelay(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,

View File

@@ -1,508 +0,0 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../utils/trace_node_resolver.dart';
class ContactTraceSheet extends StatefulWidget {
final Contact contact;
const ContactTraceSheet({super.key, required this.contact});
@override
State<ContactTraceSheet> createState() => _ContactTraceSheetState();
}
class _ContactTraceSheetState extends State<ContactTraceSheet> {
late final Future<_ContactTraceResult> _future;
_ContactTraceResult? _traceOverride;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_ContactTraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final localNodes = _localNodesFromContacts(
contactsProvider,
connectionProvider: connectionProvider,
);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult(
nodes: localNodes,
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
if (_isCompleteTrace(trace)) {
return trace;
}
unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
return trace;
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: FutureBuilder<_ContactTraceResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 360,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return SizedBox(
height: 360,
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(AppLocalizations.of(context)!.failedToLoadTrace(snapshot.error.toString())),
),
),
);
}
final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concreteNodes = routeEntries
.where((entry) => entry.resolved.node != null)
.map((entry) => entry.resolved.node!)
.where((node) => node.hasValidCoordinates)
.toList();
final mapPoints = concreteNodes
.map((node) => LatLng(node.latitude, node.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Text(
'Trace',
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.routeHashes.isEmpty
? 'No relay path saved for ${widget.contact.displayName}'
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 10),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
child: hasMapPath
? flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit:
flutter_map.CameraFit.bounds(
bounds:
flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.meshcore.sar',
),
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: mapPoints,
strokeWidth: 4,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: concreteNodes
.asMap()
.entries
.map(
(entry) => flutter_map.Marker(
point: LatLng(
entry.value.latitude,
entry.value.longitude,
),
width: 34,
height: 34,
child: CircleAvatar(
radius: 16,
backgroundColor:
Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight:
FontWeight.bold,
fontSize: 11,
),
),
),
),
)
.toList(),
),
],
)
: const Center(
child: Text(
'Not enough geolocated nodes to draw path',
),
),
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Relay path',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar(
radius: 14,
backgroundColor: Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
title: Text(entry.value.label),
subtitle: Text(
'Path node${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
),
trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt)
: null,
),
),
const SizedBox(height: 16),
],
),
),
],
),
);
},
),
);
}
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
return trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value;
final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: resolved,
label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
target: _RouteEntryTarget.relayNode(entry.key),
);
}).toList();
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
bool _isCompleteTrace(_ContactTraceResult trace) {
if (trace.sender.node == null || trace.recipient.node == null) {
return false;
}
if (trace.routeHashes.isEmpty) {
return true;
}
return trace.matchedRelayNodes.every((node) => node.node != null);
}
_ContactTraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? selfPublicKey,
}) {
final senderNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(selfPublicKey),
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(widget.contact.publicKey),
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
final routeHashes =
widget.contact.routeHasPath && widget.contact.routeHopCount > 0
? widget.contact.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toLowerCase())
.toList()
: const <String>[];
final matchedRelayNodes = routeHashes
.map(
(hash) => TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: hash,
referenceA: senderLatLng,
referenceB: recipientLatLng,
),
)
.toList();
final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
nodes: matchedRelayNodes,
startNode: senderNode.node,
endNode: recipientNode.node,
);
return _ContactTraceResult(
sender: senderNode,
recipient: recipientNode,
routeHashes: routeHashes,
matchedRelayNodes: alignedRelayNodes,
);
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
return key
.take(take)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
List<MeshMapNode> _localNodesFromContacts(
ContactsProvider contactsProvider, {
required ConnectionProvider connectionProvider,
}) {
final nodes = contactsProvider.contactsWithLocation
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return MeshMapNode(
type: contact.type.index,
name: contact.displayName,
publicKey: contact.publicKeyHex.toLowerCase(),
latitude: location.latitude,
longitude: location.longitude,
updatedAtMs: contact.lastAdvert * 1000,
);
})
.whereType<MeshMapNode>()
.where((node) => node.hasValidCoordinates)
.toList();
final selfNode = _selfNode(connectionProvider);
if (selfNode != null) {
nodes.add(selfNode);
}
return nodes;
}
MeshMapNode? _selfNode(ConnectionProvider connectionProvider) {
final publicKey = connectionProvider.deviceInfo.publicKey;
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (publicKey == null || advLat == null || advLon == null) {
return null;
}
if (advLat == 0 && advLon == 0) {
return null;
}
final node = MeshMapNode(
type: -1,
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
? connectionProvider.deviceInfo.selfName!.trim()
: 'You',
publicKey: publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase(),
latitude: advLat / 1e6,
longitude: advLon / 1e6,
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
);
return node.hasValidCoordinates ? node : null;
}
List<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
for (final node in fallback) {
merged[node.publicKey] = node;
}
for (final node in preferred) {
merged[node.publicKey] = node;
}
return merged.values.toList();
}
}
class _ContactTraceResult {
final ResolvedTraceNode sender;
final ResolvedTraceNode recipient;
final List<String> routeHashes;
final List<ResolvedTraceNode> matchedRelayNodes;
const _ContactTraceResult({
required this.sender,
required this.recipient,
required this.routeHashes,
required this.matchedRelayNodes,
});
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) {
case _RouteEntryKind.relayNode:
final updated = matchedRelayNodes.toList();
updated[target.index] = updated[target.index].cycle();
return _ContactTraceResult(
sender: sender,
recipient: recipient,
routeHashes: routeHashes,
matchedRelayNodes: updated,
);
}
}
}
class _RouteDisplayEntry {
final ResolvedTraceNode resolved;
final String label;
final String? keyLabel;
final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
required this.target,
});
MeshMapNode? get node => resolved.node;
}
enum _RouteEntryKind { relayNode }
class _RouteEntryTarget {
final _RouteEntryKind kind;
final int index;
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.relayNode(int index)
: this._(_RouteEntryKind.relayNode, index);
}

View File

@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart';
import '../../../models/sar_marker.dart';
import '../../../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,

View File

@@ -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) {

View File

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

View File

@@ -41,7 +41,6 @@ import '../../screens/add_contact_screen.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart';
import 'message_trace_sheet.dart';
import 'message_bubble_header.dart';
import 'message_bubble_signal.dart';
import 'system_message_bubble.dart';
@@ -535,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),
@@ -544,17 +543,6 @@ class _MessageBubbleState extends State<MessageBubble> {
_showTechnicalDetails(parentContext);
},
),
if (!isOwnMessage &&
widget.message.pathLen > 0 &&
widget.message.pathLen < 255)
ListTile(
leading: Icon(Icons.route),
title: Text(l10n.trace),
onTap: () {
Navigator.pop(sheetContext);
_showTraceSheet(parentContext);
},
),
// Delete message option
ListTile(
leading: Icon(Icons.delete, color: Colors.red),
@@ -573,18 +561,6 @@ class _MessageBubbleState extends State<MessageBubble> {
);
}
void _showTraceSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet(message: widget.message),
);
}
void _showTechnicalDetails(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>();
@@ -700,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 ??
@@ -736,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 ?? '-'}',
@@ -1068,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,
@@ -1648,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,

View File

@@ -1,715 +0,0 @@
// ignore_for_file: use_null_aware_elements
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/ble_packet_log.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget {
final Message? message;
final List<int>? packetPathOverride;
final String? descriptionOverride;
final String? noRelayMatchTextOverride;
const MessageTraceSheet({super.key, required this.message})
: assert(message != null),
packetPathOverride = null,
descriptionOverride = null,
noRelayMatchTextOverride = null;
const MessageTraceSheet.packetPath({
super.key,
required List<int> packetPath,
this.descriptionOverride,
this.noRelayMatchTextOverride,
}) : message = null,
packetPathOverride = packetPath;
@override
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
}
class _MessageTraceSheetState extends State<MessageTraceSheet> {
late final Future<_TraceResult> _future;
_TraceResult? _traceOverride;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize();
List<int>? packetPath = widget.packetPathOverride;
String? senderPrefix;
String? recipientPrefix;
if (widget.message case final message?) {
final storedPath = messagesProvider
.getMessageReceptionDetails(message.id)
?.pathBytes;
packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: message,
);
senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
recipientPrefix = message.recipientPublicKey != null
? _toPrefixHex(message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
}
final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult(
nodes: localNodes,
localPublicKeys: localPublicKeys,
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
if (_isCompleteTrace(
trace,
expectedRelayCount: widget.message == null
? 0
: math.max(0, widget.message!.pathLen),
)) {
return trace;
}
unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
return trace;
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: FutureBuilder<_TraceResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 360,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return SizedBox(
height: 360,
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(AppLocalizations.of(context)!.failedToLoadTrace(snapshot.error.toString())),
),
),
);
}
final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries
.where((entry) => entry.resolved.node != null)
.map((entry) => entry.resolved.node!)
.where((node) => node.hasValidCoordinates)
.toList();
final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Text(
'Trace',
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.descriptionOverride ??
(trace.mode == TraceMode.packetPath
? 'Relay path from packet path bytes'
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 10),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
child: hasMapPath
? flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit:
flutter_map.CameraFit.bounds(
bounds:
flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.meshcore.sar',
),
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: mapPoints,
strokeWidth: 4,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: concretePathNodes
.asMap()
.entries
.map(
(entry) => flutter_map.Marker(
point: LatLng(
entry.value.latitude,
entry.value.longitude,
),
width: 34,
height: 34,
child: CircleAvatar(
radius: 16,
backgroundColor:
Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight:
FontWeight.bold,
fontSize: 11,
),
),
),
),
)
.toList(),
),
],
)
: const Center(
child: Text(
'Not enough geolocated nodes to draw path',
),
),
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Relay path',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
widget.noRelayMatchTextOverride ??
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar(
radius: 14,
backgroundColor: Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
title: Text(entry.value.label),
subtitle: Text(
'Path node${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
),
trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt)
: null,
),
),
],
),
),
],
),
);
},
),
);
}
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: entry.value,
label: entry.value.node?.name ?? 'Unknown',
keyLabel: entry.value.node != null
? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex,
matchSummary: entry.value.matchSummary,
target: _RouteEntryTarget.pathNode(entry.key),
);
}).toList();
}
return trace.matchedPathNodes
.asMap()
.entries
.where((entry) => entry.value.node != null)
.map(
(entry) => _RouteDisplayEntry.fromResolved(
entry.value,
target: _RouteEntryTarget.pathNode(entry.key),
),
)
.toList();
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
return key
.take(take)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return MeshMapNode(
type: contact.type.index,
name: contact.displayName,
publicKey: contact.publicKeyHex.toLowerCase(),
latitude: location.latitude,
longitude: location.longitude,
updatedAtMs: contact.lastAdvert * 1000,
);
})
.whereType<MeshMapNode>()
.where((node) => node.hasValidCoordinates)
.toList();
}
List<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
for (final node in fallback) {
merged[node.publicKey] = node;
}
for (final node in preferred) {
merged[node.publicKey] = node;
}
return merged.values.toList();
}
_TraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? packetPath,
required int preferredHashSize,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final senderNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: senderPrefix,
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: recipientPrefix,
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize(
packetPath,
preferredHashSize: preferredHashSize,
);
final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath,
hashSize: hashSize,
);
final matched = _matchNodesFromPathHashes(
nodes: nodes,
localPublicKeys: localPublicKeys,
pathHashes: hopHashes,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
);
final alignedMatched = TraceNodeResolver.alignPathSelections(
nodes: matched,
startNode: senderNode.node,
endNode: recipientNode.node,
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: hopHashes,
matchedPathNodes: alignedMatched,
);
}
final inferred = _inferRelaysFromHopCount(
nodes: nodes,
sender: senderNode.node,
recipient: recipientNode.node,
relayCount: math.max(0, widget.message!.pathLen),
);
final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode.node != null) senderNode,
...inferred.map(
(node) => ResolvedTraceNode(
candidates: [node],
matchCount: 1,
usedOnlineFallback: false,
),
),
if (recipientNode.node != null) recipientNode,
];
return _TraceResult(
mode: TraceMode.hopCountInference,
sender: senderNode,
recipient: recipientNode,
pathHashes: const [],
matchedPathNodes: matchedPathNodes,
);
}
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
if (trace.sender.node == null || trace.recipient.node == null) {
return false;
}
if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node.node != null);
}
final concreteCount = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.length;
return concreteCount >= expectedRelayCount + 2;
}
List<int>? _extractPathFromPacketLogs({
required List<BlePacketLog> logs,
required Message message,
}) {
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog;
var bestDeltaMs = 999999999;
for (final log in logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestLog = log;
}
}
if (bestLog == null || bestDeltaMs > 30000) return null;
final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) {
return null;
}
return decoded.pathBytes;
}
List<ResolvedTraceNode> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<String> pathHashes,
required String? senderPrefix,
required String? recipientPrefix,
required LatLng? senderLatLng,
required LatLng? recipientLatLng,
}) {
final result = <ResolvedTraceNode>[];
for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toLowerCase();
result.add(
TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: hashHex,
preferredPrefix: i == 0
? senderPrefix
: (i == pathHashes.length - 1 ? recipientPrefix : null),
referenceA: senderLatLng,
referenceB: recipientLatLng,
),
);
}
return result;
}
List<MeshMapNode> _inferRelaysFromHopCount({
required List<MeshMapNode> nodes,
required MeshMapNode? sender,
required MeshMapNode? recipient,
required int relayCount,
}) {
if (relayCount <= 0 || sender == null || recipient == null) return const [];
final candidates = nodes.where((n) {
if (sender.publicKey == n.publicKey ||
recipient.publicKey == n.publicKey) {
return false;
}
return true;
}).toList();
final ranked = candidates
..sort((a, b) {
final da = _distanceToSegmentMeters(
p: LatLng(a.latitude, a.longitude),
a: LatLng(sender.latitude, sender.longitude),
b: LatLng(recipient.latitude, recipient.longitude),
);
final db = _distanceToSegmentMeters(
p: LatLng(b.latitude, b.longitude),
a: LatLng(sender.latitude, sender.longitude),
b: LatLng(recipient.latitude, recipient.longitude),
);
return da.compareTo(db);
});
return ranked.take(relayCount).toList();
}
double _distanceToSegmentMeters({
required LatLng p,
required LatLng a,
required LatLng b,
}) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return const Distance().as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return const Distance().as(LengthUnit.Meter, closest, p);
}
}
enum TraceMode { packetPath, hopCountInference }
class _TraceResult {
final TraceMode mode;
final ResolvedTraceNode sender;
final ResolvedTraceNode recipient;
final List<String> pathHashes;
final List<ResolvedTraceNode> matchedPathNodes;
const _TraceResult({
required this.mode,
required this.sender,
required this.recipient,
required this.pathHashes,
required this.matchedPathNodes,
});
_TraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) {
case _RouteEntryKind.pathNode:
final updated = matchedPathNodes.toList();
updated[target.index] = updated[target.index].cycle();
return _TraceResult(
mode: mode,
sender: sender,
recipient: recipient,
pathHashes: pathHashes,
matchedPathNodes: updated,
);
}
}
}
class _RouteDisplayEntry {
final ResolvedTraceNode resolved;
final String label;
final String? keyLabel;
final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
required this.target,
});
MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!;
return _RouteDisplayEntry(
resolved: resolved,
label: node.name,
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
target: target,
);
}
}
enum _RouteEntryKind { pathNode }
class _RouteEntryTarget {
final _RouteEntryKind kind;
final int index;
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.pathNode(int index)
: this._(_RouteEntryKind.pathNode, index);
}

View File

@@ -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,

View File

@@ -0,0 +1,646 @@
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/connection_provider.dart';
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 Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (pageContext) => SensorHistoryScreen(
publicKeyHex: publicKeyHex,
initialFieldKey: initialFieldKey,
),
),
);
}
class SensorHistoryScreen extends StatefulWidget {
const SensorHistoryScreen({
super.key,
required this.publicKeyHex,
this.initialFieldKey,
});
final String publicKeyHex;
final String? initialFieldKey;
@override
State<SensorHistoryScreen> createState() => _SensorHistoryScreenState();
}
class _SensorHistoryScreenState extends State<SensorHistoryScreen> {
late SensorHistoryRange _selectedRange;
@override
void initState() {
super.initState();
_selectedRange = SensorHistoryRange.day;
}
@override
Widget build(BuildContext context) {
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);
});
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';
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: TabBarView(
children: [
_SensorHistoryGraphTab(
samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
totalCount: selectedSamples.length,
range: _selectedRange,
),
_SensorHistoryValuesTab(
samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
range: _selectedRange,
),
],
),
),
],
),
),
);
},
);
}
}
String? resolveInitialSensorHistoryField({
required String? requestedFieldKey,
required List<String> availableFieldKeys,
}) {
if (requestedFieldKey != null &&
availableFieldKeys.contains(requestedFieldKey)) {
return requestedFieldKey;
}
if (availableFieldKeys.isEmpty) {
return null;
}
return availableFieldKeys.first;
}
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.fieldKey,
required this.cardData,
required this.totalCount,
required this.range,
});
final List<SensorHistorySample> samples;
final String fieldKey;
final SensorMetricCardData? cardData;
final int totalCount;
final SensorHistoryRange range;
@override
Widget build(BuildContext context) {
if (samples.isEmpty) {
return _SensorHistoryEmptyState(
message:
'No samples are available for ${sensorHistoryRangeLabel(range)}.',
);
}
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 ListView(
padding: const EdgeInsets.all(16),
children: [
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(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),
),
),
child: SizedBox(
height: 280,
child: LineChart(
_historyLineChartData(
context,
samples: samples,
fieldKey: fieldKey,
color: accent,
),
duration: Duration.zero,
),
),
),
],
);
}
}
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,
required this.value,
required this.accent,
});
final String label;
final String value;
final Color accent;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
}
LineChartData _historyLineChartData(
BuildContext context, {
required List<SensorHistorySample> samples,
required String fieldKey,
required Color color,
}) {
final theme = Theme.of(context);
final values = samples
.map((sample) => sample.values[fieldKey]!)
.toList(growable: false);
final spots = values
.asMap()
.entries
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
.toList(growable: false);
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 minY = minValue - padding;
final maxY = maxValue + padding;
final interval = spread <= 0
? math.max(maxValue.abs() / 3, 1.0)
: spread / 3;
return LineChartData(
minX: 0,
maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(),
minY: minY,
maxY: maxY,
clipData: const FlClipData.all(),
lineTouchData: const LineTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: interval,
getDrawingHorizontalLine: (value) => FlLine(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.24),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: interval,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
space: 8,
child: Text(
value.toStringAsFixed(value.abs() >= 10 ? 0 : 1),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: math.max((values.length / 4).floorToDouble(), 1),
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= samples.length || value != index.toDouble()) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
space: 6,
child: Text(
_formatChartTimestamp(samples[index].timestamp),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
color: color,
barWidth: 2.8,
isCurved: false,
dotData: FlDotData(
show: true,
checkToShowDot: (spot, barData) =>
barData.spots.length <= 10 || spot == barData.spots.last,
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.20),
color.withValues(alpha: 0.03),
],
),
),
),
],
);
}
String _formatHistoryValue(SensorMetricCardData? cardData, double value) {
final template = cardData?.value;
if (template == null || template.isEmpty) {
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
}
if (template.endsWith('%')) {
return '${value.toStringAsFixed(1)}%';
}
if (template.endsWith('V')) {
return '${value.toStringAsFixed(3)}V';
}
if (template.contains(' hPa')) {
return '${value.toStringAsFixed(1)} hPa';
}
if (template.contains('°C')) {
return '${value.toStringAsFixed(1)}°C';
}
if (template.contains(' mph')) {
return '${value.toStringAsFixed(2)} mph';
}
if (template.contains(' km/h')) {
return '${value.toStringAsFixed(2)} km/h';
}
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
}
String _formatSampleTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final year = local.year.toString().substring(2);
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '$month/$day/$year $hour:$minute';
}
String _formatChartTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
return '$month/$day';
}

View File

@@ -1,7 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
@@ -239,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) {
@@ -1067,6 +1078,7 @@ class SensorTelemetryCard extends StatelessWidget {
final Future<void> Function()? onPing;
final VoidCallback? onCustomize;
final Future<void> Function(Contact contact)? onShowMetHistory;
final Future<void> Function(String fieldKey)? onMetricTap;
final Future<void> Function()? onMoveUp;
final Future<void> Function()? onMoveDown;
final EdgeInsetsGeometry margin;
@@ -1086,6 +1098,7 @@ class SensorTelemetryCard extends StatelessWidget {
this.onPing,
this.onCustomize,
this.onShowMetHistory,
this.onMetricTap,
this.onMoveUp,
this.onMoveDown,
this.margin = const EdgeInsets.only(bottom: 16),
@@ -1112,7 +1125,6 @@ class SensorTelemetryCard extends StatelessWidget {
onRemove != null ||
onMoveUp != null ||
onMoveDown != null ||
_rawTelemetryHex(contact?.telemetry) != null ||
(contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact));
@@ -1134,18 +1146,6 @@ class SensorTelemetryCard extends StatelessWidget {
await onMoveDown!();
return;
}
if (value == 'copy_raw') {
final rawTelemetry = _rawTelemetryHex(contact?.telemetry);
if (rawTelemetry != null && context.mounted) {
await Clipboard.setData(ClipboardData(text: rawTelemetry));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.rawResponseCopied)),
);
}
}
return;
}
if (value == 'remove' && onRemove != null) {
await onRemove!();
return;
@@ -1176,18 +1176,24 @@ class SensorTelemetryCard extends StatelessWidget {
label: AppLocalizations.of(context)!.ping,
onTap: () => _handleAction(context, 'ping'),
),
if (_rawTelemetryHex(contact?.telemetry) != null)
_SensorSheetAction(
icon: Icons.copy_all_outlined,
label: AppLocalizations.of(context)!.copyRawResponse,
onTap: () => _handleAction(context, 'copy_raw'),
),
if (onCustomize != null)
_SensorSheetAction(
icon: Icons.tune,
label: l10n.customizeFields,
onTap: () => _handleAction(context, 'customize'),
),
if (onMoveUp != null)
_SensorSheetAction(
icon: Icons.arrow_upward,
label: l10n.moveUp,
onTap: () => _handleAction(context, 'move_up'),
),
if (onMoveDown != null)
_SensorSheetAction(
icon: Icons.arrow_downward,
label: l10n.moveDown,
onTap: () => _handleAction(context, 'move_down'),
),
if (contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact))
@@ -1327,11 +1333,14 @@ class SensorTelemetryCard extends StatelessWidget {
),
),
if (_showsMenu)
Icon(
showActionSheetOnTap
? Icons.chevron_right_rounded
: Icons.more_horiz,
color: colorScheme.onSurfaceVariant,
IconButton(
onPressed: () => _showActionSheet(context),
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,
),
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
visualDensity: VisualDensity.compact,
),
],
),
@@ -1364,6 +1373,11 @@ class SensorTelemetryCard extends StatelessWidget {
metric.wide)
? constraints.maxWidth
: compactWidth,
onTap: onMetricTap == null
? null
: () async {
await onMetricTap!(metric.fieldKey);
},
onLongPress: onRefresh == null
? null
: () async {
@@ -1380,15 +1394,7 @@ class SensorTelemetryCard extends StatelessWidget {
),
);
if (!_showsMenu || !showActionSheetOnTap) {
return card;
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showActionSheet(context),
child: card,
);
return card;
}
List<SensorMetricCardData> _buildMetricCards(
@@ -2326,6 +2332,7 @@ class SensorMetricTile extends StatefulWidget {
final double width;
final String keyPrefix;
final bool allowMapPreview;
final GestureTapCallback? onTap;
final GestureLongPressCallback? onLongPress;
const SensorMetricTile({
@@ -2334,6 +2341,7 @@ class SensorMetricTile extends StatefulWidget {
required this.width,
this.keyPrefix = 'sensor_metric',
this.allowMapPreview = true,
this.onTap,
this.onLongPress,
});
@@ -2450,6 +2458,7 @@ class _SensorMetricTileState extends State<SensorMetricTile> {
child: InkWell(
key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'),
borderRadius: BorderRadius.circular(22),
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: Container(
width: widget.width,
@@ -3102,11 +3111,6 @@ bool _isTelemetryMetadataKey(String key) {
key == _rawTelemetryHexKey;
}
String? _rawTelemetryHex(ContactTelemetry? telemetry) {
final value = telemetry?.extraSensorData?[_rawTelemetryHexKey];
return value is String && value.trim().isNotEmpty ? value : null;
}
String _telemetrySourceChannelKey(String fieldKey) {
return '$_telemetrySourceChannelPrefix$fieldKey';
}
@@ -3125,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, {

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0408.1+51
version: 2026.0420.1+55
environment:
sdk: ^3.9.2

View File

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

View File

@@ -215,6 +215,70 @@ void main() {
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
});
test('tracked auto refresh history is captured and persisted', () async {
SharedPreferences.setMockInitialValues({});
final timestamp = DateTime(2026, 3, 15, 9, 0);
final contacts = <Contact>[
buildSensorContact().copyWith(
telemetry: ContactTelemetry(
temperature: 12.5,
humidity: 54,
pressure: 918.2,
timestamp: timestamp,
extraSensorData: const {'illuminance_2': 150.0},
),
),
];
final contactsProvider = _FakeContactsProvider(contacts);
final connectionProvider = _FakeConnectionProvider(isConnected: true);
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contacts.first);
await provider.setAutoRefreshMinutes(contacts.first.publicKeyHex, 5);
await provider.captureTrackedTelemetryHistory(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final firstHistory = provider.historyFor(contacts.first.publicKeyHex);
expect(firstHistory, hasLength(1));
expect(firstHistory.first.values['temperature'], 12.5);
expect(firstHistory.first.values['humidity'], 54);
expect(firstHistory.first.values['pressure'], 918.2);
expect(firstHistory.first.values['extra:illuminance_2'], 150.0);
await provider.captureTrackedTelemetryHistory(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
expect(provider.historyFor(contacts.first.publicKeyHex), hasLength(1));
contacts[0] = contacts[0].copyWith(
telemetry: ContactTelemetry(
temperature: 13.1,
humidity: 52,
pressure: 919.0,
timestamp: timestamp.add(const Duration(minutes: 5)),
extraSensorData: const {'illuminance_2': 160.0},
),
);
await provider.captureTrackedTelemetryHistory(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final updatedHistory = provider.historyFor(contacts.first.publicKeyHex);
expect(updatedHistory, hasLength(2));
expect(updatedHistory.last.values['temperature'], 13.1);
final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider);
expect(reloadedProvider.historyFor(contacts.first.publicKeyHex), hasLength(2));
});
test('watched sensors and preferences are isolated per profile', () async {
SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact();

View File

@@ -69,7 +69,7 @@ void main() {
expect(await MeshMapNodesService.hasFreshCache(), isFalse);
});
test('clearCache removes persisted online trace database', () async {
test('clearCache removes persisted online node cache', () async {
final client = MockClient(
(_) async => http.Response(
jsonEncode({

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

View File

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

View File

@@ -1,166 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
import 'package:meshcore_sar_app/utils/trace_node_resolver.dart';
void main() {
test(
'prefers closest local repeater over online fallback for shared prefix',
() {
final localNear = _node(
name: 'Near Local',
publicKey: 'aa1100',
latitude: 46.05,
longitude: 14.50,
);
final localFar = _node(
name: 'Far Local',
publicKey: 'aa2200',
latitude: 46.40,
longitude: 14.90,
);
final online = _node(
name: 'Online',
publicKey: 'aa3300',
latitude: 46.06,
longitude: 14.51,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [localNear, localFar, online],
localPublicKeys: {localNear.publicKey, localFar.publicKey},
prefixHex: 'aa',
referenceA: const LatLng(46.0, 14.5),
referenceB: const LatLng(46.1, 14.5),
);
expect(resolved.node?.name, 'Near Local');
expect(resolved.usedOnlineFallback, isFalse);
expect(resolved.matchCount, 2);
expect(resolved.matchSummary, '2 local matches');
},
);
test('falls back to online node only when local match is missing', () {
final online = _node(
name: 'Online Only',
publicKey: 'bb1100',
latitude: 46.06,
longitude: 14.51,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [online],
localPublicKeys: const {},
prefixHex: 'bb',
referenceA: const LatLng(46.0, 14.5),
referenceB: const LatLng(46.1, 14.5),
);
expect(resolved.node?.name, 'Online Only');
expect(resolved.usedOnlineFallback, isTrue);
expect(resolved.matchCount, 1);
});
test('cycles through ambiguous local prefix matches', () {
final first = _node(
name: 'First Match',
publicKey: 'cc1100',
latitude: 46.08,
longitude: 14.52,
);
final second = _node(
name: 'Second Match',
publicKey: 'cc11ff',
latitude: 46.09,
longitude: 14.53,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [second, first],
localPublicKeys: {first.publicKey, second.publicKey},
prefixHex: 'cc11',
);
expect(resolved.matchCount, 2);
expect(resolved.canCycle, isTrue);
expect(resolved.node?.name, 'Second Match');
expect(resolved.cycle().node?.name, 'First Match');
expect(resolved.cycle().cycle().node?.name, 'Second Match');
});
test('aligns ambiguous hops to the closest continuous path', () {
final start = _node(
name: 'Start',
publicKey: 'start00',
latitude: 46.000,
longitude: 14.000,
);
final end = _node(
name: 'End',
publicKey: 'end000',
latitude: 46.300,
longitude: 14.300,
);
final hop1Near = _node(
name: 'Hop 1 Near',
publicKey: 'aa1100',
latitude: 46.100,
longitude: 14.100,
);
final hop1Far = _node(
name: 'Hop 1 Far',
publicKey: 'aa11ff',
latitude: 46.250,
longitude: 14.000,
);
final hop2Near = _node(
name: 'Hop 2 Near',
publicKey: 'bb2200',
latitude: 46.200,
longitude: 14.200,
);
final hop2Far = _node(
name: 'Hop 2 Far',
publicKey: 'bb22ff',
latitude: 46.050,
longitude: 14.280,
);
final aligned = TraceNodeResolver.alignPathSelections(
nodes: [
TraceNodeResolver.resolveBest(
nodes: [hop1Far, hop1Near],
localPublicKeys: {hop1Near.publicKey, hop1Far.publicKey},
prefixHex: 'aa11',
),
TraceNodeResolver.resolveBest(
nodes: [hop2Far, hop2Near],
localPublicKeys: {hop2Near.publicKey, hop2Far.publicKey},
prefixHex: 'bb22',
),
],
startNode: start,
endNode: end,
);
expect(aligned[0].node?.name, 'Hop 1 Near');
expect(aligned[1].node?.name, 'Hop 2 Near');
});
}
MeshMapNode _node({
required String name,
required String publicKey,
required double latitude,
required double longitude,
}) {
return MeshMapNode(
type: 1,
name: name,
publicKey: publicKey,
latitude: latitude,
longitude: longitude,
updatedAtMs: 1,
);
}

View File

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

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/map_provider.dart';
@@ -14,6 +15,32 @@ import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeAppProvider extends ChangeNotifier implements AppProvider {
@override
ChannelLocationSharingMode? channelLocationSharingModeForChannel(
int channelIdx,
) {
return null;
}
@override
Future<ChannelLocationSharingState> getChannelLocationSharingState(
int channelIdx,
) async {
return const ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: false,
);
}
@override
dynamic noSuchMethod(Invocation invocation) {
return null;
}
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -41,22 +68,31 @@ void main() {
);
}
Future<void> pumpTile(
Future<Future<void> Function()> pumpTile(
WidgetTester tester,
Contact contact, {
SensorsProvider? sensorsProvider,
}) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final appProvider = _FakeAppProvider();
final resolvedSensorsProvider = sensorsProvider ?? SensorsProvider();
final ownsSensorsProvider = sensorsProvider == null;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => ContactsProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
),
ChangeNotifierProvider<ContactsProvider>.value(value: contactsProvider),
ChangeNotifierProvider<MessagesProvider>.value(value: messagesProvider),
ChangeNotifierProvider<SensorsProvider>.value(
value: resolvedSensorsProvider,
),
ChangeNotifierProvider(create: (_) => MapProvider()),
ChangeNotifierProvider<MapProvider>.value(value: mapProvider),
ChangeNotifierProvider<AppProvider>.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -65,64 +101,97 @@ void main() {
),
),
);
return () async {
await tester.pumpWidget(const SizedBox.shrink());
connectionProvider.dispose();
if (ownsSensorsProvider) {
resolvedSensorsProvider.dispose();
}
await tester.pump();
};
}
testWidgets('shows trace action for non-channel contacts', (tester) async {
await pumpTile(
Future<void> withPumpedTile(
WidgetTester tester,
Contact contact,
Future<void> Function() body, {
SensorsProvider? sensorsProvider,
}) async {
final dispose = await pumpTile(
tester,
contact,
sensorsProvider: sensorsProvider,
);
try {
await body();
} finally {
await dispose();
}
}
testWidgets('does not show diagnostic action for non-channel contacts', (
tester,
) async {
await withPumpedTile(
tester,
buildContact(name: 'John Smith', type: ContactType.chat),
() async {
await tester.tap(find.text('John Smith'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsNothing);
},
);
await tester.tap(find.text('John Smith'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsOneWidget);
});
testWidgets('does not show trace action for channels', (tester) async {
await pumpTile(
testWidgets('does not show diagnostic action for channels', (tester) async {
await withPumpedTile(
tester,
buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3),
() async {
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsNothing);
},
);
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsNothing);
});
testWidgets('shows overridden contact name as primary label', (tester) async {
await pumpTile(
await withPumpedTile(
tester,
buildContact(
name: 'John Smith',
type: ContactType.chat,
).copyWith(nameOverride: 'Rescue One'),
() async {
expect(find.text('Rescue One'), findsOneWidget);
expect(find.text('John Smith'), findsNothing);
},
);
expect(find.text('Rescue One'), findsOneWidget);
expect(find.text('John Smith'), findsNothing);
});
testWidgets('hides public key in contact tile', (tester) async {
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
await pumpTile(tester, contact);
expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), findsNothing);
await withPumpedTile(tester, contact, () async {
expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), findsNothing);
});
});
testWidgets('sensor contacts can be added to sensors', (tester) async {
await pumpTile(
await withPumpedTile(
tester,
buildContact(name: 'WX Station', type: ContactType.sensor),
() async {
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Add to Sensors'), findsOneWidget);
},
);
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Add to Sensors'), findsOneWidget);
});
testWidgets('sensor preview shows telemetry card', (tester) async {
@@ -146,46 +215,49 @@ void main() {
),
);
await pumpTile(tester, contact);
await withPumpedTile(tester, contact, () async {
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Preview'), findsOneWidget);
expect(find.text('Preview'), findsOneWidget);
await tester.tap(find.text('Preview'));
await tester.pumpAndSettle();
await tester.tap(find.text('Preview'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.text('Battery'), findsOneWidget);
expect(find.text('84%'), findsOneWidget);
expect(find.text('Temperature'), findsOneWidget);
expect(find.text('21.5°C'), findsOneWidget);
expect(find.text('CO2'), findsOneWidget);
expect(find.text('415 ppm'), findsOneWidget);
expect(find.text('Illuminance'), findsOneWidget);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(find.text('Current'), findsOneWidget);
expect(find.text('15 mA'), findsOneWidget);
expect(find.text('Power'), findsOneWidget);
expect(find.text('Distance'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_metric_battery')),
findsOneWidget,
);
expect(find.text('ch1'), findsWidgets);
expect(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget,
);
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.text('Battery'), findsOneWidget);
expect(find.text('84%'), findsOneWidget);
expect(find.text('Temperature'), findsOneWidget);
expect(find.text('21.5°C'), findsOneWidget);
expect(find.text('CO2'), findsOneWidget);
expect(find.text('415 ppm'), findsOneWidget);
expect(find.text('Illuminance'), findsOneWidget);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(find.text('Current'), findsOneWidget);
expect(find.text('15 mA'), findsOneWidget);
expect(find.text('Power'), findsOneWidget);
expect(find.text('Distance'), findsOneWidget);
expect(find.byKey(const ValueKey('sensor_metric_battery')), findsOneWidget);
expect(find.text('ch1'), findsWidgets);
expect(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget,
);
final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard));
final batteryTileSize = tester.getSize(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard));
final batteryTileSize = tester.getSize(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.byType(SensorTelemetryCard), findsNothing);
expect(find.byType(SensorTelemetryCard), findsNothing);
});
});
}

View File

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

View File

@@ -0,0 +1,82 @@
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 screen honors initial field key when available', () {
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'extra:illuminance_2',
availableFieldKeys: const <String>[
'temperature',
'extra:illuminance_2',
],
);
expect(selectedFieldKey, 'extra:illuminance_2');
});
test('history screen falls back to first available field', () {
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'extra:missing',
availableFieldKeys: const <String>[
'temperature',
'extra:illuminance_2',
],
);
expect(selectedFieldKey, 'temperature');
});
test('history screen returns null when no fields are available', () {
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'temperature',
availableFieldKeys: const <String>[],
);
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));
});
}

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
@@ -517,7 +518,69 @@ void main() {
expect(moveDownCount, 1);
});
testWidgets('overflow menu copies raw response', (tester) async {
testWidgets('tapping a measurement tile triggers metric callback', (
tester,
) async {
final contact = buildContact();
String? tappedFieldKey;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onMetricTap: (fieldKey) async {
tappedFieldKey = fieldKey;
},
),
),
),
);
await tester.tap(find.byKey(const ValueKey('sensor_metric_temperature')));
await tester.pumpAndSettle();
expect(tappedFieldKey, 'temperature');
});
testWidgets('tapping the card body does not trigger metric callback', (
tester,
) async {
final contact = buildContact();
var tapCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onMetricTap: (_) async {
tapCount += 1;
},
),
),
),
);
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(tapCount, 0);
});
testWidgets('raw response metadata alone does not show an overflow menu', (
tester,
) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x48;
final contact = Contact(
@@ -538,11 +601,8 @@ void main() {
),
);
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
await tester.pumpWidget(
MaterialApp(
scaffoldMessengerKey: scaffoldKey,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
@@ -556,44 +616,7 @@ void main() {
),
);
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Copy raw response'), findsOneWidget);
// Capture clipboard writes via the test platform channel mock.
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(MethodCall call) async {
if (call.method == 'Clipboard.setData') {
final args = call.arguments as Map<dynamic, dynamic>;
clipboardText = args['text'] as String?;
}
if (call.method == 'Clipboard.getData') {
return <String, dynamic>{'text': clipboardText};
}
return null;
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
await tester.tap(find.text('Copy raw response'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(clipboardText, '01 67 00 d7');
expect(find.text('Raw response copied'), findsOneWidget);
// Clear the SnackBar to prevent its timer from blocking teardown.
scaffoldKey.currentState?.clearSnackBars();
await tester.pump(const Duration(seconds: 5));
await tester.pump(const Duration(seconds: 5));
expect(find.byIcon(Icons.more_vert), findsNothing);
expect(find.byIcon(Icons.chevron_right_rounded), findsNothing);
});
}

View File

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