mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-13 17:30:28 +00:00
feat: save all pending work
ref:
This commit is contained in:
@@ -39,5 +39,13 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
|
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
|
||||||
|
# fails to compile for iOS simulators. Voice codec support is only required
|
||||||
|
# on real iOS devices, so skip the native Codec2 sources for simulator SDKs.
|
||||||
|
if target.name == 'codec2_flutter'
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ PODS:
|
|||||||
- ObjectBox (= 4.4.1)
|
- ObjectBox (= 4.4.1)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- path_provider_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- record_ios (1.2.0):
|
- record_ios (1.2.0):
|
||||||
@@ -102,6 +105,7 @@ DEPENDENCIES:
|
|||||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
@@ -149,6 +153,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
|
path_provider_foundation:
|
||||||
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
record_ios:
|
record_ios:
|
||||||
@@ -183,6 +189,7 @@ SPEC CHECKSUMS:
|
|||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
@@ -193,6 +200,6 @@ SPEC CHECKSUMS:
|
|||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>84</string>
|
<string>88</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00022">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408783">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.399739">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="120.492323">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="94.453704">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="12.544865">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="221.093787">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,14 @@ class DeviceInfo {
|
|||||||
final String? selfName;
|
final String? selfName;
|
||||||
|
|
||||||
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||||
final int? maxContacts; // Max contacts device supports
|
final int? maxContacts; // Max contacts device supports
|
||||||
final int? maxChannels; // Max channels device supports
|
final int? maxChannels; // Max channels device supports
|
||||||
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
final int?
|
||||||
final int? blePin; // BLE PIN code
|
telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
final int? blePin; // BLE PIN code
|
||||||
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||||
|
final int?
|
||||||
|
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||||
|
|
||||||
// Firmware info
|
// Firmware info
|
||||||
final int? firmwareVersion;
|
final int? firmwareVersion;
|
||||||
@@ -88,6 +90,9 @@ class DeviceInfo {
|
|||||||
|
|
||||||
// Repeat mode (firmware v9+)
|
// Repeat mode (firmware v9+)
|
||||||
final bool? clientRepeat;
|
final bool? clientRepeat;
|
||||||
|
final bool? supportsSpectrumScan;
|
||||||
|
final int? spectrumScanMinKhz;
|
||||||
|
final int? spectrumScanMaxKhz;
|
||||||
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
||||||
|
|
||||||
DeviceInfo({
|
DeviceInfo({
|
||||||
@@ -124,6 +129,9 @@ class DeviceInfo {
|
|||||||
this.manufacturerModel,
|
this.manufacturerModel,
|
||||||
this.semanticVersion,
|
this.semanticVersion,
|
||||||
this.clientRepeat,
|
this.clientRepeat,
|
||||||
|
this.supportsSpectrumScan,
|
||||||
|
this.spectrumScanMinKhz,
|
||||||
|
this.spectrumScanMaxKhz,
|
||||||
this.allowedRepeatFreqRanges,
|
this.allowedRepeatFreqRanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +168,9 @@ class DeviceInfo {
|
|||||||
|
|
||||||
/// Get storage usage percentage (0-100)
|
/// Get storage usage percentage (0-100)
|
||||||
double? get storageUsedPercent {
|
double? get storageUsedPercent {
|
||||||
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
|
if (storageUsedKb == null ||
|
||||||
|
storageTotalKb == null ||
|
||||||
|
storageTotalKb == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
||||||
@@ -245,6 +255,9 @@ class DeviceInfo {
|
|||||||
String? manufacturerModel,
|
String? manufacturerModel,
|
||||||
String? semanticVersion,
|
String? semanticVersion,
|
||||||
bool? clientRepeat,
|
bool? clientRepeat,
|
||||||
|
bool? supportsSpectrumScan,
|
||||||
|
int? spectrumScanMinKhz,
|
||||||
|
int? spectrumScanMaxKhz,
|
||||||
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
||||||
}) {
|
}) {
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
@@ -281,7 +294,11 @@ class DeviceInfo {
|
|||||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||||
clientRepeat: clientRepeat ?? this.clientRepeat,
|
clientRepeat: clientRepeat ?? this.clientRepeat,
|
||||||
allowedRepeatFreqRanges: allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||||
|
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||||
|
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||||
|
allowedRepeatFreqRanges:
|
||||||
|
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
bool get isScanning => _isScanning;
|
bool get isScanning => _isScanning;
|
||||||
|
bool _isSpectrumScanActive = false;
|
||||||
|
bool get isSpectrumScanActive => _isSpectrumScanActive;
|
||||||
|
|
||||||
String? _error;
|
String? _error;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
@@ -312,6 +314,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
service.onMessageWaiting = () {
|
service.onMessageWaiting = () {
|
||||||
|
if (_isSpectrumScanActive) {
|
||||||
|
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
@@ -379,6 +385,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
|
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
|
||||||
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
||||||
clientRepeat: deviceInfo['clientRepeat'] as bool?,
|
clientRepeat: deviceInfo['clientRepeat'] as bool?,
|
||||||
|
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
|
||||||
|
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
|
||||||
|
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
if (_sseServer.isRunning) {
|
if (_sseServer.isRunning) {
|
||||||
@@ -1555,6 +1564,43 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<SpectrumScanResult?> scanSpectrum({
|
||||||
|
required int startFrequencyKhz,
|
||||||
|
required int stopFrequencyKhz,
|
||||||
|
required int bandwidthKhz,
|
||||||
|
required int stepKhz,
|
||||||
|
required int dwellMs,
|
||||||
|
required int thresholdDb,
|
||||||
|
}) async {
|
||||||
|
if (!_activeService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isSpectrumScanActive = true;
|
||||||
|
_activeService.setSpectrumScanActive(true);
|
||||||
|
notifyListeners();
|
||||||
|
return await _activeService.scanSpectrum(
|
||||||
|
startFrequencyKhz: startFrequencyKhz,
|
||||||
|
stopFrequencyKhz: stopFrequencyKhz,
|
||||||
|
bandwidthKhz: bandwidthKhz,
|
||||||
|
stepKhz: stepKhz,
|
||||||
|
dwellMs: dwellMs,
|
||||||
|
thresholdDb: thresholdDb,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to scan spectrum: $e';
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
_isSpectrumScanActive = false;
|
||||||
|
_activeService.setSpectrumScanActive(false);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set transmit power
|
/// Set transmit power
|
||||||
Future<void> setTxPower(int powerDbm) async {
|
Future<void> setTxPower(int powerDbm) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
@@ -1599,6 +1645,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Request fresh device info (triggers SelfInfo response)
|
/// Request fresh device info (triggers SelfInfo response)
|
||||||
Future<void> refreshDeviceInfo() async {
|
Future<void> refreshDeviceInfo() async {
|
||||||
|
if (_isSpectrumScanActive) return;
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1642,6 +1689,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// Sync messages from device queue
|
/// Sync messages from device queue
|
||||||
/// Call this repeatedly until no more messages are available
|
/// Call this repeatedly until no more messages are available
|
||||||
Future<bool> syncNextMessage() async {
|
Future<bool> syncNextMessage() async {
|
||||||
|
if (_isSpectrumScanActive) return false;
|
||||||
// Prevent re-entrancy and too-fast triggers
|
// Prevent re-entrancy and too-fast triggers
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Another sync (single or loop) is in progress
|
// Another sync (single or loop) is in progress
|
||||||
@@ -1680,6 +1728,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Sync all waiting messages from device
|
/// Sync all waiting messages from device
|
||||||
Future<int> syncAllMessages() async {
|
Future<int> syncAllMessages() async {
|
||||||
|
if (_isSpectrumScanActive) {
|
||||||
|
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Already syncing; avoid overlapping loops
|
// Already syncing; avoid overlapping loops
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../services/validation_service.dart';
|
import '../services/validation_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -722,7 +722,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Client Repeat Mode'),
|
title: const Text('Client Repeat Mode'),
|
||||||
subtitle: deviceInfo.allowedRepeatFreqRanges != null &&
|
subtitle:
|
||||||
|
deviceInfo.allowedRepeatFreqRanges != null &&
|
||||||
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
||||||
? Text(
|
? Text(
|
||||||
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
|
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'map_management_screen.dart';
|
|||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
import 'spectrum_scan_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/permission_request_dialog.dart';
|
import '../widgets/permission_request_dialog.dart';
|
||||||
@@ -381,6 +382,26 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.radar),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Spectrum Scan'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const SpectrumScanScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
601
lib/screens/spectrum_scan_screen.dart
Normal file
601
lib/screens/spectrum_scan_screen.dart
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/device_info.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../widgets/device/spectrum_scan_panel.dart';
|
||||||
|
|
||||||
|
class SpectrumScanScreen extends StatefulWidget {
|
||||||
|
const SpectrumScanScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
|
||||||
|
static const List<String> _bandwidthOptions = [
|
||||||
|
'7.8 kHz',
|
||||||
|
'10.4 kHz',
|
||||||
|
'15.6 kHz',
|
||||||
|
'20.8 kHz',
|
||||||
|
'31.25 kHz',
|
||||||
|
'41.7 kHz',
|
||||||
|
'62.5 kHz',
|
||||||
|
'125 kHz',
|
||||||
|
'250 kHz',
|
||||||
|
'500 kHz',
|
||||||
|
];
|
||||||
|
|
||||||
|
String _selectedBandwidth = '62.5 kHz';
|
||||||
|
bool _isSpectrumScanRunning = false;
|
||||||
|
bool _rangeInitialized = false;
|
||||||
|
String? _lastRangeSourceKey;
|
||||||
|
late double _scanRangeMinMhz;
|
||||||
|
late double _scanRangeMaxMhz;
|
||||||
|
late RangeValues _scanRangeValues;
|
||||||
|
List<SpectrumScanCandidate> _scanCandidates = const [];
|
||||||
|
int? _selectedScanFrequencyKhz;
|
||||||
|
int _completedScanSectors = 0;
|
||||||
|
int _totalScanSectors = 0;
|
||||||
|
|
||||||
|
List<SpectrumScanCandidate> get _recommendedScanCandidates =>
|
||||||
|
_scanCandidates.take(8).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final deviceInfo = _deviceInfo;
|
||||||
|
if (deviceInfo.radioBw != null &&
|
||||||
|
deviceInfo.radioBw! >= 0 &&
|
||||||
|
deviceInfo.radioBw! < _bandwidthOptions.length) {
|
||||||
|
_selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!];
|
||||||
|
}
|
||||||
|
_syncRangeFromDevice(deviceInfo);
|
||||||
|
_selectedScanFrequencyKhz = deviceInfo.radioFreq;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceInfo get _deviceInfo => context.read<ConnectionProvider>().deviceInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_syncRangeFromDevice(context.watch<ConnectionProvider>().deviceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncRangeFromDevice(DeviceInfo deviceInfo) {
|
||||||
|
final minKhz = deviceInfo.spectrumScanMinKhz;
|
||||||
|
final maxKhz = deviceInfo.spectrumScanMaxKhz;
|
||||||
|
late double normalizedMinMhz;
|
||||||
|
late double normalizedMaxMhz;
|
||||||
|
late String sourceKey;
|
||||||
|
|
||||||
|
if (minKhz != null && maxKhz != null && maxKhz > minKhz) {
|
||||||
|
final normalized = _normalizeScanRange(
|
||||||
|
minKhz / 1000.0,
|
||||||
|
maxKhz / 1000.0,
|
||||||
|
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
|
||||||
|
);
|
||||||
|
normalizedMinMhz = normalized.$1;
|
||||||
|
normalizedMaxMhz = normalized.$2;
|
||||||
|
sourceKey = 'fw:$minKhz:$maxKhz';
|
||||||
|
} else {
|
||||||
|
final normalized = _normalizeScanRange(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
|
||||||
|
);
|
||||||
|
normalizedMinMhz = normalized.$1;
|
||||||
|
normalizedMaxMhz = normalized.$2;
|
||||||
|
sourceKey =
|
||||||
|
'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rangeInitialized && _lastRangeSourceKey == sourceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scanRangeMinMhz = normalizedMinMhz;
|
||||||
|
_scanRangeMaxMhz = normalizedMaxMhz;
|
||||||
|
|
||||||
|
if (_scanRangeMaxMhz <= _scanRangeMinMhz) {
|
||||||
|
_scanRangeMaxMhz = _scanRangeMinMhz + 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz);
|
||||||
|
_rangeInitialized = true;
|
||||||
|
_lastRangeSourceKey = sourceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
(double, double) _normalizeScanRange(
|
||||||
|
double? minMhz,
|
||||||
|
double? maxMhz,
|
||||||
|
double? centerMhz,
|
||||||
|
) {
|
||||||
|
const hardMinMhz = 800.0;
|
||||||
|
const hardMaxMhz = 950.0;
|
||||||
|
final center = centerMhz ?? 869.525;
|
||||||
|
|
||||||
|
if (minMhz != null && maxMhz != null) {
|
||||||
|
final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz);
|
||||||
|
final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz);
|
||||||
|
if (clampedMax > clampedMin) {
|
||||||
|
return (clampedMin, clampedMax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (center >= 900.0 && center <= 930.0) {
|
||||||
|
return (902.0, 928.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (863.0, 870.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _bandwidthToKhz(String bw) {
|
||||||
|
switch (bw) {
|
||||||
|
case '7.8 kHz':
|
||||||
|
return 7.8;
|
||||||
|
case '10.4 kHz':
|
||||||
|
return 10.4;
|
||||||
|
case '15.6 kHz':
|
||||||
|
return 15.6;
|
||||||
|
case '20.8 kHz':
|
||||||
|
return 20.8;
|
||||||
|
case '31.25 kHz':
|
||||||
|
return 31.25;
|
||||||
|
case '41.7 kHz':
|
||||||
|
return 41.7;
|
||||||
|
case '62.5 kHz':
|
||||||
|
return 62.5;
|
||||||
|
case '125 kHz':
|
||||||
|
return 125.0;
|
||||||
|
case '250 kHz':
|
||||||
|
return 250.0;
|
||||||
|
case '500 kHz':
|
||||||
|
return 500.0;
|
||||||
|
default:
|
||||||
|
return 62.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _currentParamProfile(DeviceInfo deviceInfo) {
|
||||||
|
final sf = deviceInfo.radioSf ?? 8;
|
||||||
|
final cr = deviceInfo.radioCr ?? 8;
|
||||||
|
return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _recommendationTitle(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return 'Best candidate';
|
||||||
|
case 1:
|
||||||
|
return 'Alternate';
|
||||||
|
case 2:
|
||||||
|
return 'Fallback';
|
||||||
|
default:
|
||||||
|
return 'Candidate ${index + 1}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _possibleBracketFrequenciesKhz() {
|
||||||
|
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
|
||||||
|
final halfBandwidthKhz = bandwidthKhz / 2.0;
|
||||||
|
final startKhz = (_scanRangeValues.start * 1000).round();
|
||||||
|
final stopKhz = (_scanRangeValues.end * 1000).round();
|
||||||
|
final firstCenterKhz = (startKhz + halfBandwidthKhz).round();
|
||||||
|
final lastCenterKhz = (stopKhz - halfBandwidthKhz).round();
|
||||||
|
|
||||||
|
if (lastCenterKhz < firstCenterKhz) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25;
|
||||||
|
final centers = <int>[];
|
||||||
|
for (
|
||||||
|
var centerKhz = firstCenterKhz;
|
||||||
|
centerKhz <= lastCenterKhz && centers.length < 8;
|
||||||
|
centerKhz += stepKhz
|
||||||
|
) {
|
||||||
|
centers.add(centerKhz);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (centers.isEmpty || centers.last != lastCenterKhz) {
|
||||||
|
centers.add(lastCenterKhz);
|
||||||
|
}
|
||||||
|
|
||||||
|
return centers.toSet().toList()..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetDerivedScanResults() {
|
||||||
|
_scanCandidates = const [];
|
||||||
|
_selectedScanFrequencyKhz = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) {
|
||||||
|
final startKhz = (_scanRangeValues.start * 1000).round();
|
||||||
|
final stopKhz = (_scanRangeValues.end * 1000).round();
|
||||||
|
final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200);
|
||||||
|
final overlapKhz = bandwidthKhz.round().clamp(8, 500);
|
||||||
|
final sectors = <(int startKhz, int stopKhz)>[];
|
||||||
|
|
||||||
|
var sectorStartKhz = startKhz;
|
||||||
|
while (sectorStartKhz < stopKhz) {
|
||||||
|
final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp(
|
||||||
|
sectorStartKhz + overlapKhz,
|
||||||
|
stopKhz,
|
||||||
|
);
|
||||||
|
sectors.add((sectorStartKhz, sectorStopKhz));
|
||||||
|
if (sectorStopKhz >= stopKhz) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sectorStartKhz = sectorStopKhz - overlapKhz;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SpectrumScanCandidate> _mergeSectorCandidates(
|
||||||
|
Iterable<SpectrumScanCandidate> candidates,
|
||||||
|
) {
|
||||||
|
final byFrequency = <int, SpectrumScanCandidate>{};
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
final existing = byFrequency[candidate.centerFrequencyKhz];
|
||||||
|
if (existing == null ||
|
||||||
|
candidate.occupancyPercent < existing.occupancyPercent ||
|
||||||
|
(candidate.occupancyPercent == existing.occupancyPercent &&
|
||||||
|
candidate.peakRssiDbm < existing.peakRssiDbm) ||
|
||||||
|
(candidate.occupancyPercent == existing.occupancyPercent &&
|
||||||
|
candidate.peakRssiDbm == existing.peakRssiDbm &&
|
||||||
|
candidate.avgRssiDbm < existing.avgRssiDbm)) {
|
||||||
|
byFrequency[candidate.centerFrequencyKhz] = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final merged = byFrequency.values.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final occupancyCompare = a.occupancyPercent.compareTo(
|
||||||
|
b.occupancyPercent,
|
||||||
|
);
|
||||||
|
if (occupancyCompare != 0) return occupancyCompare;
|
||||||
|
final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm);
|
||||||
|
if (peakCompare != 0) return peakCompare;
|
||||||
|
return a.avgRssiDbm.compareTo(b.avgRssiDbm);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runSpectrumScan() async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
|
||||||
|
final sectors = _buildScanSectors(bandwidthKhz);
|
||||||
|
final sectorCandidates = <SpectrumScanCandidate>[];
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSpectrumScanRunning = true;
|
||||||
|
_resetDerivedScanResults();
|
||||||
|
_completedScanSectors = 0;
|
||||||
|
_totalScanSectors = sectors.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < sectors.length; i++) {
|
||||||
|
final sector = sectors[i];
|
||||||
|
final result = await connectionProvider.scanSpectrum(
|
||||||
|
startFrequencyKhz: sector.$1,
|
||||||
|
stopFrequencyKhz: sector.$2,
|
||||||
|
bandwidthKhz: bandwidthKhz.round(),
|
||||||
|
stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000),
|
||||||
|
dwellMs: 160,
|
||||||
|
thresholdDb: 8,
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
sectorCandidates.addAll(result.candidates);
|
||||||
|
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_scanCandidates = mergedCandidates;
|
||||||
|
if (_selectedScanFrequencyKhz == null &&
|
||||||
|
mergedCandidates.isNotEmpty) {
|
||||||
|
_selectedScanFrequencyKhz =
|
||||||
|
mergedCandidates.first.centerFrequencyKhz;
|
||||||
|
}
|
||||||
|
_completedScanSectors = i + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_completedScanSectors = i + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSpectrumScanRunning = false;
|
||||||
|
if (_completedScanSectors == 0) {
|
||||||
|
_totalScanSectors = sectors.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
|
||||||
|
if (mergedCandidates.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Spectrum scan returned no candidate frequencies'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_scanCandidates = mergedCandidates;
|
||||||
|
_selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySelectedScanFrequency() async {
|
||||||
|
if (_selectedScanFrequencyKhz == null) return;
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
|
||||||
|
await connectionProvider.setRadioParams(
|
||||||
|
frequency: _selectedScanFrequencyKhz!,
|
||||||
|
bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth),
|
||||||
|
spreadingFactor: deviceInfo.radioSf ?? 8,
|
||||||
|
codingRate: deviceInfo.radioCr ?? 8,
|
||||||
|
repeat: deviceInfo.clientRepeat,
|
||||||
|
);
|
||||||
|
await connectionProvider.refreshDeviceInfo();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) {
|
||||||
|
if (_selectedScanFrequencyKhz != null) {
|
||||||
|
return _selectedScanFrequencyKhz;
|
||||||
|
}
|
||||||
|
return deviceInfo.radioFreq;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final possibleBracketFrequencies = _possibleBracketFrequenciesKhz();
|
||||||
|
final recommendedScanCandidates = _recommendedScanCandidates;
|
||||||
|
final recommendationFrequencies = recommendedScanCandidates.isNotEmpty
|
||||||
|
? recommendedScanCandidates
|
||||||
|
.map((candidate) => candidate.centerFrequencyKhz)
|
||||||
|
.toList()
|
||||||
|
: possibleBracketFrequencies;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Spectrum Scan')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _selectedBandwidth,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Bandwidth',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText:
|
||||||
|
'Scan and apply frequencies for this bandwidth',
|
||||||
|
),
|
||||||
|
items: _bandwidthOptions.map((value) {
|
||||||
|
return DropdownMenuItem<String>(
|
||||||
|
value: value,
|
||||||
|
child: Text(value),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedBandwidth = value;
|
||||||
|
_resetDerivedScanResults();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
deviceInfo.spectrumScanMinKhz != null &&
|
||||||
|
deviceInfo.spectrumScanMaxKhz != null
|
||||||
|
? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz'
|
||||||
|
: 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isSpectrumScanRunning) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SpectrumScanPanel(
|
||||||
|
theme: theme,
|
||||||
|
scanSupported: deviceInfo.supportsSpectrumScan == true,
|
||||||
|
isRunning: _isSpectrumScanRunning,
|
||||||
|
rangeMinMhz: _scanRangeMinMhz,
|
||||||
|
rangeMaxMhz: _scanRangeMaxMhz,
|
||||||
|
rangeValues: _scanRangeValues,
|
||||||
|
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
|
||||||
|
selectedFrequencyKhz: _currentPreviewFrequencyKhz(
|
||||||
|
deviceInfo,
|
||||||
|
),
|
||||||
|
graphCandidates: _scanCandidates,
|
||||||
|
selectableCandidates: recommendedScanCandidates,
|
||||||
|
onRangeChanged: (values) {
|
||||||
|
setState(() {
|
||||||
|
_scanRangeValues = values;
|
||||||
|
_resetDerivedScanResults();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCandidateChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedScanFrequencyKhz = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRunScan: _runSpectrumScan,
|
||||||
|
onApplySelected: _applySelectedScanFrequency,
|
||||||
|
),
|
||||||
|
if (recommendationFrequencies.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
_scanCandidates.isNotEmpty
|
||||||
|
? 'Recommended profiles'
|
||||||
|
: 'Possible brackets',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_scanCandidates.isNotEmpty
|
||||||
|
? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.'
|
||||||
|
: 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (
|
||||||
|
var i = 0;
|
||||||
|
i < recommendationFrequencies.length;
|
||||||
|
i++
|
||||||
|
) ...[
|
||||||
|
_RecommendationTile(
|
||||||
|
title: _recommendationTitle(i),
|
||||||
|
frequencyKhz: recommendationFrequencies[i],
|
||||||
|
candidate: recommendedScanCandidates.isNotEmpty
|
||||||
|
? recommendedScanCandidates[i]
|
||||||
|
: null,
|
||||||
|
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
|
||||||
|
paramsLabel: _currentParamProfile(deviceInfo),
|
||||||
|
isSelected:
|
||||||
|
_selectedScanFrequencyKhz ==
|
||||||
|
recommendationFrequencies[i],
|
||||||
|
onSelect: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedScanFrequencyKhz =
|
||||||
|
recommendationFrequencies[i];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (i != recommendationFrequencies.length - 1)
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecommendationTile extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final int frequencyKhz;
|
||||||
|
final SpectrumScanCandidate? candidate;
|
||||||
|
final double bandwidthKhz;
|
||||||
|
final String paramsLabel;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onSelect;
|
||||||
|
|
||||||
|
const _RecommendationTile({
|
||||||
|
required this.title,
|
||||||
|
required this.frequencyKhz,
|
||||||
|
required this.candidate,
|
||||||
|
required this.bandwidthKhz,
|
||||||
|
required this.paramsLabel,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onSelect,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? scheme.primaryContainer.withValues(alpha: 0.55)
|
||||||
|
: scheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? scheme.primary : scheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Icon(Icons.check_circle, color: scheme.primary, size: 18),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(paramsLabel, style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
candidate != null
|
||||||
|
? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm'
|
||||||
|
: 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
lib/utils/avatar_label_helper.dart
Normal file
32
lib/utils/avatar_label_helper.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
class AvatarLabelHelper {
|
||||||
|
static String buildLabel(String name) {
|
||||||
|
final trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty) return '?';
|
||||||
|
|
||||||
|
if (trimmed.startsWith('#')) {
|
||||||
|
final hashBody = trimmed.substring(1).replaceAll(RegExp(r'[\s_-]+'), '');
|
||||||
|
if (hashBody.isEmpty) {
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final parts = trimmed
|
||||||
|
.split(RegExp(r'[\s_-]+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
final first = _take(parts[0], 1);
|
||||||
|
final second = _take(parts[1], 1);
|
||||||
|
return '$first$second'.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _take(trimmed, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _take(String value, int count) {
|
||||||
|
if (value.length <= count) return value;
|
||||||
|
return value.substring(0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
lib/widgets/common/contact_avatar.dart
Normal file
129
lib/widgets/common/contact_avatar.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
import '../../services/trail_color_service.dart';
|
||||||
|
import '../../utils/avatar_label_helper.dart';
|
||||||
|
|
||||||
|
class ContactAvatar extends StatelessWidget {
|
||||||
|
final Contact contact;
|
||||||
|
final double radius;
|
||||||
|
final String? displayName;
|
||||||
|
|
||||||
|
const ContactAvatar({
|
||||||
|
super.key,
|
||||||
|
required this.contact,
|
||||||
|
this.radius = 20,
|
||||||
|
this.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final backgroundColor = _getBackgroundColor(context);
|
||||||
|
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||||
|
final emoji = contact.roleEmoji;
|
||||||
|
|
||||||
|
if (emoji != null && emoji.isNotEmpty) {
|
||||||
|
return _buildAvatarFrame(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_shouldUseLabelFallback) {
|
||||||
|
return _buildAvatarFrame(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: Text(
|
||||||
|
AvatarLabelHelper.buildLabel(displayName ?? contact.displayName),
|
||||||
|
style: TextStyle(
|
||||||
|
color: foregroundColor,
|
||||||
|
fontSize: radius * 0.68,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildAvatarFrame(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: Icon(
|
||||||
|
_getTypeIcon(contact.type),
|
||||||
|
color: foregroundColor,
|
||||||
|
size: radius,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatarFrame({
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
if (_usesSquareShape) {
|
||||||
|
return Container(
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(radius * 0.6),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _shouldUseLabelFallback =>
|
||||||
|
contact.type == ContactType.chat ||
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room;
|
||||||
|
|
||||||
|
bool get _usesSquareShape =>
|
||||||
|
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||||
|
|
||||||
|
Color _getBackgroundColor(BuildContext context) {
|
||||||
|
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
||||||
|
return TrailColorService.getTrailColor(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (contact.type) {
|
||||||
|
case ContactType.none:
|
||||||
|
return Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||||
|
case ContactType.chat:
|
||||||
|
return Colors.blue;
|
||||||
|
case ContactType.repeater:
|
||||||
|
return Colors.orange;
|
||||||
|
case ContactType.room:
|
||||||
|
return Colors.purple;
|
||||||
|
case ContactType.channel:
|
||||||
|
return Colors.teal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getForegroundColor(Color backgroundColor) {
|
||||||
|
return ThemeData.estimateBrightnessForColor(backgroundColor) ==
|
||||||
|
Brightness.dark
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black87;
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getTypeIcon(ContactType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ContactType.none:
|
||||||
|
return Icons.help_outline;
|
||||||
|
case ContactType.chat:
|
||||||
|
return Icons.person;
|
||||||
|
case ContactType.repeater:
|
||||||
|
return Icons.router;
|
||||||
|
case ContactType.room:
|
||||||
|
return Icons.meeting_room;
|
||||||
|
case ContactType.channel:
|
||||||
|
return Icons.public;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
|
||||||
|
class SpectrumScanPanel extends StatelessWidget {
|
||||||
|
final ThemeData theme;
|
||||||
|
final bool scanSupported;
|
||||||
|
final bool isRunning;
|
||||||
|
final double rangeMinMhz;
|
||||||
|
final double rangeMaxMhz;
|
||||||
|
final RangeValues rangeValues;
|
||||||
|
final double bandwidthKhz;
|
||||||
|
final int? selectedFrequencyKhz;
|
||||||
|
final List<SpectrumScanCandidate> graphCandidates;
|
||||||
|
final List<SpectrumScanCandidate> selectableCandidates;
|
||||||
|
final ValueChanged<RangeValues> onRangeChanged;
|
||||||
|
final ValueChanged<int?> onCandidateChanged;
|
||||||
|
final VoidCallback onRunScan;
|
||||||
|
final VoidCallback onApplySelected;
|
||||||
|
|
||||||
|
const SpectrumScanPanel({
|
||||||
|
super.key,
|
||||||
|
required this.theme,
|
||||||
|
required this.scanSupported,
|
||||||
|
required this.isRunning,
|
||||||
|
required this.rangeMinMhz,
|
||||||
|
required this.rangeMaxMhz,
|
||||||
|
required this.rangeValues,
|
||||||
|
required this.bandwidthKhz,
|
||||||
|
required this.selectedFrequencyKhz,
|
||||||
|
required this.graphCandidates,
|
||||||
|
required this.selectableCandidates,
|
||||||
|
required this.onRangeChanged,
|
||||||
|
required this.onCandidateChanged,
|
||||||
|
required this.onRunScan,
|
||||||
|
required this.onApplySelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.tune, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Power Scan',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: scanSupported && !isRunning ? onRunScan : null,
|
||||||
|
icon: isRunning
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.radar),
|
||||||
|
label: Text(
|
||||||
|
scanSupported
|
||||||
|
? (isRunning ? 'Scanning' : 'Scan')
|
||||||
|
: 'Unavailable',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
scanSupported
|
||||||
|
? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.'
|
||||||
|
: 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_FrequencyRangePreview(
|
||||||
|
minMhz: rangeMinMhz,
|
||||||
|
maxMhz: rangeMaxMhz,
|
||||||
|
selectedRange: rangeValues,
|
||||||
|
selectedBandwidthKhz: bandwidthKhz,
|
||||||
|
selectedFrequencyKhz: selectedFrequencyKhz,
|
||||||
|
candidates: graphCandidates,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 14,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
_LegendChip(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
label: 'Quiet',
|
||||||
|
),
|
||||||
|
const _LegendChip(color: Colors.orange, label: 'Moderate'),
|
||||||
|
_LegendChip(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
label: 'Busy',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${rangeValues.start.toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${rangeValues.end.toStringAsFixed(3)} MHz',
|
||||||
|
style: theme.textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
values: rangeValues,
|
||||||
|
min: rangeMinMhz,
|
||||||
|
max: rangeMaxMhz,
|
||||||
|
divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp(
|
||||||
|
1,
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
labels: RangeLabels(
|
||||||
|
rangeValues.start.toStringAsFixed(3),
|
||||||
|
rangeValues.end.toStringAsFixed(3),
|
||||||
|
),
|
||||||
|
onChanged: onRangeChanged,
|
||||||
|
),
|
||||||
|
if (selectableCandidates.isEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
scanSupported
|
||||||
|
? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.'
|
||||||
|
: 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: selectedFrequencyKhz,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Candidate frequency',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: 'Best frequencies for the current bandwidth',
|
||||||
|
),
|
||||||
|
items: selectableCandidates.map((candidate) {
|
||||||
|
return DropdownMenuItem<int>(
|
||||||
|
value: candidate.centerFrequencyKhz,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
selectedItemBuilder: (context) {
|
||||||
|
return selectableCandidates.map((candidate) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
onChanged: onCandidateChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: selectedFrequencyKhz == null
|
||||||
|
? null
|
||||||
|
: onApplySelected,
|
||||||
|
icon: const Icon(Icons.north_east),
|
||||||
|
label: const Text('Use selected frequency'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegendChip extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _LegendChip({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FrequencyRangePreview extends StatelessWidget {
|
||||||
|
final double minMhz;
|
||||||
|
final double maxMhz;
|
||||||
|
final RangeValues selectedRange;
|
||||||
|
final double selectedBandwidthKhz;
|
||||||
|
final int? selectedFrequencyKhz;
|
||||||
|
final List<SpectrumScanCandidate> candidates;
|
||||||
|
|
||||||
|
const _FrequencyRangePreview({
|
||||||
|
required this.minMhz,
|
||||||
|
required this.maxMhz,
|
||||||
|
required this.selectedRange,
|
||||||
|
required this.selectedBandwidthKhz,
|
||||||
|
required this.selectedFrequencyKhz,
|
||||||
|
required this.candidates,
|
||||||
|
});
|
||||||
|
|
||||||
|
double _positionFor(double mhz) {
|
||||||
|
final span = maxMhz - minMhz;
|
||||||
|
if (span <= 0) return 0;
|
||||||
|
return ((mhz - minMhz) / span).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _candidateColor(BuildContext context, int occupancyPercent) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
if (occupancyPercent <= 10) return scheme.primary;
|
||||||
|
if (occupancyPercent <= 35) return Colors.orange;
|
||||||
|
return scheme.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final spanMhz = maxMhz - minMhz;
|
||||||
|
final selectedFreqMhz = selectedFrequencyKhz != null
|
||||||
|
? selectedFrequencyKhz! / 1000.0
|
||||||
|
: null;
|
||||||
|
final bwMhz = selectedBandwidthKhz / 1000.0;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final rangeLeft = _positionFor(selectedRange.start) * width;
|
||||||
|
final rangeRight = _positionFor(selectedRange.end) * width;
|
||||||
|
|
||||||
|
double? bwLeft;
|
||||||
|
double? bwWidth;
|
||||||
|
if (selectedFreqMhz != null && spanMhz > 0) {
|
||||||
|
bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width;
|
||||||
|
final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width;
|
||||||
|
bwWidth = (bwRight - bwLeft).clamp(4.0, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 108,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
scheme.surface,
|
||||||
|
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
scheme.primary.withValues(alpha: 0.12),
|
||||||
|
scheme.tertiary.withValues(alpha: 0.08),
|
||||||
|
scheme.primary.withValues(alpha: 0.12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: rangeLeft,
|
||||||
|
top: 12,
|
||||||
|
width: (rangeRight - rangeLeft).clamp(8.0, width),
|
||||||
|
height: 56,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: scheme.primary.withValues(alpha: 0.20),
|
||||||
|
border: Border.all(color: scheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bwLeft != null && bwWidth != null)
|
||||||
|
Positioned(
|
||||||
|
left: bwLeft,
|
||||||
|
top: 28,
|
||||||
|
width: bwWidth,
|
||||||
|
height: 24,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
color: scheme.tertiary.withValues(alpha: 0.32),
|
||||||
|
border: Border.all(color: scheme.tertiary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final candidate in candidates)
|
||||||
|
Positioned(
|
||||||
|
left: (_positionFor(candidate.centerFrequencyMhz) * width)
|
||||||
|
.clamp(10.0, width - 18.0),
|
||||||
|
top: 72,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _candidateColor(
|
||||||
|
context,
|
||||||
|
candidate.occupancyPercent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
candidate.centerFrequencyMhz.toStringAsFixed(3),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import geolocator_apple
|
|||||||
import nsd_macos
|
import nsd_macos
|
||||||
import objectbox_flutter_libs
|
import objectbox_flutter_libs
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
|
import path_provider_foundation
|
||||||
import record_macos
|
import record_macos
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -34,6 +35,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
74
pubspec.lock
74
pubspec.lock
@@ -129,14 +129,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
code_assets:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: code_assets
|
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -646,14 +638,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.5"
|
version: "0.2.5"
|
||||||
glob:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: glob
|
|
||||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
gsettings:
|
gsettings:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -662,14 +646,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.8"
|
||||||
hooks:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: hooks
|
|
||||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1"
|
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -846,14 +822,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.2"
|
version: "2.6.2"
|
||||||
logging:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: logging
|
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.0"
|
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -881,11 +849,9 @@ packages:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "../meshcore_client"
|
||||||
ref: main
|
relative: true
|
||||||
resolved-ref: "11f51ccaba850531496179bf63e023cb4c9ad797"
|
source: path
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
|
||||||
source: git
|
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
@@ -911,14 +877,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.17.4"
|
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -991,14 +949,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.1"
|
version: "4.3.1"
|
||||||
objective_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: objective_c
|
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.3.0"
|
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1040,13 +990,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.22"
|
version: "2.2.22"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.0"
|
version: "2.5.1"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1183,14 +1133,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
pub_semver:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: pub_semver
|
|
||||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
record:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1702,5 +1644,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.3 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.38.0"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0305.3+9
|
version: 2026.0306.1+11
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -145,6 +145,11 @@ dev_dependencies:
|
|||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: "^0.14.4"
|
flutter_launcher_icons: "^0.14.4"
|
||||||
|
|
||||||
|
dependency_overrides:
|
||||||
|
meshcore_client:
|
||||||
|
path: ../meshcore_client
|
||||||
|
path_provider_foundation: 2.5.1
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
android: "launcher_icon"
|
||||||
ios: true
|
ios: true
|
||||||
|
|||||||
26
test/utils/avatar_label_helper_test.dart
Normal file
26
test/utils/avatar_label_helper_test.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/avatar_label_helper.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AvatarLabelHelper.buildLabel', () {
|
||||||
|
test('returns two initials for multi-word names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns first two characters for single-word names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows three compact characters for hash-prefixed names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('#ops'), '#OP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes spacing after hash-prefixed names', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps non-hash labels at two characters', () {
|
||||||
|
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
106
test/widgets/contact_avatar_test.dart
Normal file
106
test/widgets/contact_avatar_test.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Contact buildContact({
|
||||||
|
required String name,
|
||||||
|
required ContactType type,
|
||||||
|
int secondByte = 0,
|
||||||
|
}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[1] = secondByte;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: type,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: 0,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: Center(child: ContactAvatar(contact: contact))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('renders label avatar for rooms', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'Operations Room', type: ContactType.room),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('OR'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders hash label avatar for rooms', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: '#ops-room', type: ContactType.room),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#OP'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders compact hash label avatar when name includes spacing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: '# foo alpha', type: ContactType.room),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#FO'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders label avatar for channels', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: '#ops', type: ContactType.channel, secondByte: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#OP'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.public), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders non-hash label avatar for channels', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('CN'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.public), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders round avatar for chat contacts', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'John Smith', type: ContactType.chat),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('JS'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.person), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user