mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
10 Commits
v2026.0315
...
v2026.0315
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55cfffa59 | ||
|
|
2bb5b353bd | ||
|
|
991421d097 | ||
|
|
1dcd85f178 | ||
|
|
5d2f61749e | ||
|
|
69377d90a4 | ||
|
|
3e559a4f4e | ||
|
|
a124e3d9f2 | ||
|
|
3bf4971279 | ||
|
|
e355f28ca9 |
@@ -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 = 116;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 116;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 116;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 116;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 116;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 116;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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>116</string>
|
<string>117</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -1257,6 +1257,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
channelName: channelName,
|
channelName: channelName,
|
||||||
secret: secretBytes,
|
secret: secretBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reconcile local state from the device after the write completes,
|
||||||
|
// matching the delete flow so channel/contact lists refresh immediately.
|
||||||
|
await _activeService.getChannel(slotIdx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_isChannelRefreshTimeoutError(e)) {
|
if (_isChannelRefreshTimeoutError(e)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
|
|||||||
@@ -875,10 +875,19 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
telemetry.gpsLocation,
|
telemetry.gpsLocation,
|
||||||
)) {
|
)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
' ⚠️ Incoming telemetry GPS is invalid/missing; keeping last known contact location as fallback: $incomingGps',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
telemetry = mergedTelemetry;
|
telemetry = ContactTelemetry(
|
||||||
|
gpsLocation: telemetry.gpsLocation,
|
||||||
|
batteryPercentage: mergedTelemetry.batteryPercentage,
|
||||||
|
batteryMilliVolts: mergedTelemetry.batteryMilliVolts,
|
||||||
|
temperature: mergedTelemetry.temperature,
|
||||||
|
timestamp: mergedTelemetry.timestamp,
|
||||||
|
humidity: mergedTelemetry.humidity,
|
||||||
|
pressure: mergedTelemetry.pressure,
|
||||||
|
extraSensorData: telemetry.extraSensorData,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update contact with new telemetry AND last seen time
|
// Update contact with new telemetry AND last seen time
|
||||||
@@ -993,10 +1002,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
// last known reading for any field that is omitted in the incoming update.
|
// last known reading for any field that is omitted in the incoming update.
|
||||||
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
||||||
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
||||||
final mergedExtraSensorData = <String, dynamic>{
|
final mergedExtraSensorData = _mergeExtraSensorData(
|
||||||
...?existingTelemetry?.extraSensorData,
|
existingTelemetry?.extraSensorData,
|
||||||
...?incomingTelemetry.extraSensorData,
|
incomingTelemetry.extraSensorData,
|
||||||
};
|
);
|
||||||
|
|
||||||
return ContactTelemetry(
|
return ContactTelemetry(
|
||||||
gpsLocation: incomingGps ?? previousGps,
|
gpsLocation: incomingGps ?? previousGps,
|
||||||
@@ -1017,6 +1026,49 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _mergeExtraSensorData(
|
||||||
|
Map<String, dynamic>? existingExtraSensorData,
|
||||||
|
Map<String, dynamic>? incomingExtraSensorData,
|
||||||
|
) {
|
||||||
|
final merged = <String, dynamic>{...?existingExtraSensorData};
|
||||||
|
if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) {
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
final incomingMetricFamilies = incomingExtraSensorData.keys
|
||||||
|
.map(_telemetryMetricFamilyForKey)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
if (incomingMetricFamilies.isNotEmpty) {
|
||||||
|
merged.removeWhere((key, _) {
|
||||||
|
final family = _telemetryMetricFamilyForKey(key);
|
||||||
|
return family != null && incomingMetricFamilies.contains(family);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.addAll(incomingExtraSensorData);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _telemetryMetricFamilyForKey(String key) {
|
||||||
|
const sourcePrefix = '__source_channel:';
|
||||||
|
if (key.startsWith(sourcePrefix)) {
|
||||||
|
return key.substring(sourcePrefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
final separatorIndex = key.lastIndexOf('_');
|
||||||
|
if (separatorIndex <= 0 || separatorIndex == key.length - 1) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
final suffix = key.substring(separatorIndex + 1);
|
||||||
|
if (int.tryParse(suffix) == null) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
|
||||||
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
||||||
return (coordinate * 1e6).round();
|
return (coordinate * 1e6).round();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../widgets/sensors/sensor_telemetry_card.dart';
|
||||||
import 'connection_provider.dart';
|
import 'connection_provider.dart';
|
||||||
import 'contacts_provider.dart';
|
import 'contacts_provider.dart';
|
||||||
|
|
||||||
@@ -18,7 +19,16 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
static const String _metricLabelKey = 'sensor_metric_labels';
|
static const String _metricLabelKey = 'sensor_metric_labels';
|
||||||
static const String _metricOrderKey = 'sensor_metric_order';
|
static const String _metricOrderKey = 'sensor_metric_order';
|
||||||
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
|
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
|
||||||
static const List<int> supportedAutoRefreshIntervals = <int>[0, 1, 5, 15];
|
static const List<int> supportedAutoRefreshIntervals = <int>[
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
15,
|
||||||
|
30,
|
||||||
|
60,
|
||||||
|
360,
|
||||||
|
720,
|
||||||
|
1440,
|
||||||
|
];
|
||||||
static const Set<String> _defaultVisibleFields = <String>{
|
static const Set<String> _defaultVisibleFields = <String>{
|
||||||
'voltage',
|
'voltage',
|
||||||
'battery',
|
'battery',
|
||||||
@@ -118,7 +128,9 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
final decoded =
|
final decoded =
|
||||||
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
|
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
|
||||||
for (final entry in decoded.entries) {
|
for (final entry in decoded.entries) {
|
||||||
final minutes = (entry.value as num).toInt();
|
final minutes = _normalizeAutoRefreshMinutes(
|
||||||
|
(entry.value as num).toInt(),
|
||||||
|
);
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
_autoRefreshMinutesBySensor[entry.key] = minutes;
|
_autoRefreshMinutesBySensor[entry.key] = minutes;
|
||||||
}
|
}
|
||||||
@@ -212,6 +224,22 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Set<String> effectiveVisibleFieldsFor(
|
||||||
|
String publicKeyHex,
|
||||||
|
Iterable<String> availableFieldKeys,
|
||||||
|
) {
|
||||||
|
final storedVisibleFields =
|
||||||
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields;
|
||||||
|
if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) {
|
||||||
|
return Set<String>.unmodifiable(storedVisibleFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set<String>.unmodifiable(<String>{
|
||||||
|
...storedVisibleFields,
|
||||||
|
...availableFieldKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bool showsField(String publicKeyHex, String fieldKey) =>
|
bool showsField(String publicKeyHex, String fieldKey) =>
|
||||||
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
||||||
|
|
||||||
@@ -258,7 +286,7 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
|
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
|
||||||
|
|
||||||
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
|
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
|
||||||
final normalizedMinutes = minutes <= 0 ? 0 : minutes;
|
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
|
||||||
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
|
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
|
||||||
if (currentMinutes == normalizedMinutes) {
|
if (currentMinutes == normalizedMinutes) {
|
||||||
return;
|
return;
|
||||||
@@ -273,6 +301,32 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _normalizeAutoRefreshMinutes(int minutes) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportedAutoRefreshIntervals.contains(minutes)) {
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
final positiveIntervals = supportedAutoRefreshIntervals.where(
|
||||||
|
(value) => value > 0,
|
||||||
|
);
|
||||||
|
var bestMatch = positiveIntervals.first;
|
||||||
|
var bestDistance = (minutes - bestMatch).abs();
|
||||||
|
|
||||||
|
for (final interval in positiveIntervals.skip(1)) {
|
||||||
|
final distance = (minutes - interval).abs();
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestMatch = interval;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
|
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
|
||||||
final refreshTime = now ?? DateTime.now();
|
final refreshTime = now ?? DateTime.now();
|
||||||
final dueKeys = <String>[];
|
final dueKeys = <String>[];
|
||||||
@@ -403,14 +457,14 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_watchedSensorKeys.add(contact.publicKeyHex);
|
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||||
|
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
|
_visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
|
||||||
_defaultVisibleFields,
|
|
||||||
);
|
|
||||||
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||||
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
|
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
|
||||||
_metricOrderBySensor[contact.publicKeyHex] = List<String>.from(
|
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
|
||||||
_defaultMetricOrder,
|
contact.publicKeyHex,
|
||||||
|
initialVisibleFields,
|
||||||
);
|
);
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
await _persistFieldSpans();
|
await _persistFieldSpans();
|
||||||
@@ -419,6 +473,37 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldAutoIncludeAvailableFields(
|
||||||
|
String publicKeyHex,
|
||||||
|
Set<String> storedVisibleFields,
|
||||||
|
) {
|
||||||
|
if (!setEquals(storedVisibleFields, _defaultVisibleFields)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedOrder = _metricOrderBySensor[publicKeyHex];
|
||||||
|
if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedLabels = _metricLabelsBySensor[publicKeyHex];
|
||||||
|
if (storedLabels != null && storedLabels.isNotEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedSpans = _fieldSpansBySensor[publicKeyHex];
|
||||||
|
if (storedSpans != null &&
|
||||||
|
!(storedSpans.length == 1 && storedSpans['gps'] == 2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _initialVisibleFieldsForContact(Contact contact) {
|
||||||
|
return <String>{..._defaultVisibleFields, ...sensorMetricKeysFor(contact)};
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> removeSensor(String publicKeyHex) async {
|
Future<void> removeSensor(String publicKeyHex) async {
|
||||||
_watchedSensorKeys.remove(publicKeyHex);
|
_watchedSensorKeys.remove(publicKeyHex);
|
||||||
_refreshStates.remove(publicKeyHex);
|
_refreshStates.remove(publicKeyHex);
|
||||||
|
|||||||
@@ -151,107 +151,234 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final normalized = _normalizeAdvertText(_advertController.text);
|
final normalized = _normalizeAdvertText(_advertController.text);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Add Contact')),
|
appBar: AppBar(title: const Text('Import Contact')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Container(
|
||||||
'Import an exported contact advert, like meshcore-open.',
|
padding: const EdgeInsets.all(18),
|
||||||
style: theme.textTheme.titleMedium,
|
decoration: BoxDecoration(
|
||||||
),
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
const SizedBox(height: 8),
|
borderRadius: BorderRadius.circular(20),
|
||||||
Text(
|
border: Border.all(
|
||||||
'Paste a `meshcore://...` link or raw hex advert from the clipboard.',
|
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: _advertController,
|
|
||||||
minLines: 4,
|
|
||||||
maxLines: 8,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (_validationError != null || _importSucceeded) {
|
|
||||||
setState(() {
|
|
||||||
_importSucceeded = false;
|
|
||||||
_validationError = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Contact advert',
|
|
||||||
hintText: 'meshcore://...',
|
|
||||||
alignLabelWithHint: true,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
errorText: _validationError,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (normalized != null)
|
|
||||||
Text(
|
|
||||||
'Advert size: ${normalized.length ~/ 2} bytes',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
child: Column(
|
||||||
Row(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: _isImporting ? null : _pasteFromClipboard,
|
|
||||||
icon: const Icon(Icons.content_paste_go_outlined),
|
|
||||||
label: const Text('Paste'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: (_isImporting || _importSucceeded)
|
|
||||||
? null
|
|
||||||
: _importContact,
|
|
||||||
icon: _isImporting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: _importSucceeded
|
|
||||||
? const Icon(Icons.check_circle_outline)
|
|
||||||
: const Icon(Icons.person_add_alt_1_outlined),
|
|
||||||
label: Text(
|
|
||||||
_isImporting
|
|
||||||
? 'Importing...'
|
|
||||||
: _importSucceeded
|
|
||||||
? 'Added'
|
|
||||||
: 'Add Contact',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (_importSucceeded) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Row(
|
||||||
Icons.check_circle,
|
children: [
|
||||||
size: 18,
|
Container(
|
||||||
color: theme.colorScheme.primary,
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person_add_alt_1_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Import a shared contact advert',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 14),
|
||||||
Text(
|
Text(
|
||||||
'Contact added. Paste or edit another advert to import again.',
|
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: const [
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.link_outlined,
|
||||||
|
label: 'Accepts share links',
|
||||||
|
),
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.code_outlined,
|
||||||
|
label: 'Supports raw hex',
|
||||||
|
),
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.content_paste_go_outlined,
|
||||||
|
label: 'Clipboard-friendly',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Advert payload',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'You can paste the full share link or only the hex payload.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _advertController,
|
||||||
|
minLines: 5,
|
||||||
|
maxLines: 9,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_validationError != null || _importSucceeded) {
|
||||||
|
setState(() {
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Contact advert',
|
||||||
|
hintText: 'Paste a share link or hex advert',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: _validationError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
normalized == null
|
||||||
|
? 'No valid advert detected yet'
|
||||||
|
: 'Advert size: ${normalized.length ~/ 2} bytes',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isImporting ? null : _pasteFromClipboard,
|
||||||
|
icon: const Icon(Icons.content_paste_go_outlined),
|
||||||
|
label: const Text('Paste'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: (_isImporting || _importSucceeded)
|
||||||
|
? null
|
||||||
|
: _importContact,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
icon: _isImporting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: _importSucceeded
|
||||||
|
? const Icon(Icons.check_circle_outline)
|
||||||
|
: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
|
label: Text(
|
||||||
|
_isImporting
|
||||||
|
? 'Importing...'
|
||||||
|
: _importSucceeded
|
||||||
|
? 'Imported'
|
||||||
|
: 'Import Contact',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_importSucceeded)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Contact imported. Paste another advert or edit the current one to import again.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportHintChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _ImportHintChip({required this.icon, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: theme.textTheme.labelMedium),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _openAddContactScreen(context),
|
onPressed: () => _openAddContactScreen(context),
|
||||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
label: const Text('Add Contact'),
|
label: const Text('Import Contact'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -887,7 +887,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _openAddContactScreen(context),
|
onPressed: () => _openAddContactScreen(context),
|
||||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
label: const Text('Add Contact'),
|
label: const Text('Import Contact'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
|
|||||||
@@ -171,135 +171,26 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
String publicKeyHex,
|
String publicKeyHex,
|
||||||
Contact? contact,
|
Contact? contact,
|
||||||
) async {
|
) async {
|
||||||
await showModalBottomSheet<void>(
|
await Navigator.of(context).push(
|
||||||
context: context,
|
MaterialPageRoute<void>(
|
||||||
showDragHandle: true,
|
builder: (pageContext) => _SensorCustomizeView(
|
||||||
builder: (sheetContext) => Consumer<SensorsProvider>(
|
publicKeyHex: publicKeyHex,
|
||||||
builder: (context, sensorsProvider, child) {
|
initialContact: contact,
|
||||||
final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex);
|
onRenameMetric:
|
||||||
final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor(
|
({
|
||||||
publicKeyHex,
|
required BuildContext context,
|
||||||
);
|
required String publicKeyHex,
|
||||||
final options = sensorMetricOptionsFor(
|
required SensorMetricOption option,
|
||||||
contact,
|
required SensorsProvider sensorsProvider,
|
||||||
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
}) {
|
||||||
);
|
return _showMetricRenameDialog(
|
||||||
final orderedFieldKeys = sensorsProvider.metricOrderFor(
|
context,
|
||||||
publicKeyHex,
|
publicKeyHex: publicKeyHex,
|
||||||
options.map((option) => option.key),
|
option: option,
|
||||||
);
|
sensorsProvider: sensorsProvider,
|
||||||
final optionByKey = <String, SensorMetricOption>{
|
);
|
||||||
for (final option in options) option.key: option,
|
},
|
||||||
};
|
),
|
||||||
final orderedOptions = orderedFieldKeys
|
|
||||||
.map((fieldKey) => optionByKey[fieldKey])
|
|
||||||
.whereType<SensorMetricOption>()
|
|
||||||
.toList(growable: false);
|
|
||||||
return SafeArea(
|
|
||||||
child: ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Auto refresh telemetry',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Refresh this contact automatically while the device is connected.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: SensorsProvider.supportedAutoRefreshIntervals
|
|
||||||
.map(
|
|
||||||
(minutes) => ChoiceChip(
|
|
||||||
label: Text(minutes == 0 ? 'Off' : '${minutes}m'),
|
|
||||||
selected: autoRefreshMinutes == minutes,
|
|
||||||
onSelected: (_) {
|
|
||||||
sensorsProvider.setAutoRefreshMinutes(
|
|
||||||
publicKeyHex,
|
|
||||||
minutes,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(growable: false),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
'Visible fields',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Choose which values appear on sensor cards and rename them.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Use the arrows to change card order.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
...orderedOptions.asMap().entries.map((entry) {
|
|
||||||
final index = entry.key;
|
|
||||||
final option = entry.value;
|
|
||||||
final visible = visibleFields.contains(option.key);
|
|
||||||
final span = sensorsProvider.fieldSpanFor(
|
|
||||||
publicKeyHex,
|
|
||||||
option.key,
|
|
||||||
);
|
|
||||||
return SensorMetricSelectorItem(
|
|
||||||
option: option,
|
|
||||||
visible: visible,
|
|
||||||
span: span,
|
|
||||||
canMoveUp: index > 0,
|
|
||||||
canMoveDown: index < orderedOptions.length - 1,
|
|
||||||
onToggle: (value) {
|
|
||||||
sensorsProvider.toggleMetric(
|
|
||||||
publicKeyHex,
|
|
||||||
option.key,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onRename: () => _showMetricRenameDialog(
|
|
||||||
context,
|
|
||||||
publicKeyHex: publicKeyHex,
|
|
||||||
option: option,
|
|
||||||
sensorsProvider: sensorsProvider,
|
|
||||||
),
|
|
||||||
onMoveUp: index > 0
|
|
||||||
? () => sensorsProvider.moveMetric(
|
|
||||||
publicKeyHex,
|
|
||||||
availableFieldKeys: orderedFieldKeys,
|
|
||||||
oldIndex: index,
|
|
||||||
newIndex: index - 1,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onMoveDown: index < orderedOptions.length - 1
|
|
||||||
? () => sensorsProvider.moveMetric(
|
|
||||||
publicKeyHex,
|
|
||||||
availableFieldKeys: orderedFieldKeys,
|
|
||||||
oldIndex: index,
|
|
||||||
newIndex: index + 1,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onSpanChanged: (selection) {
|
|
||||||
sensorsProvider.setFieldSpan(
|
|
||||||
publicKeyHex,
|
|
||||||
option.key,
|
|
||||||
selection,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -403,19 +294,20 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final availableFieldKeys = sensorMetricKeysFor(contact);
|
||||||
|
final visibleFields = sensorsProvider
|
||||||
|
.effectiveVisibleFieldsFor(key, availableFieldKeys);
|
||||||
return SensorTelemetryCard(
|
return SensorTelemetryCard(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
state: sensorsProvider.stateFor(key),
|
state: sensorsProvider.stateFor(key),
|
||||||
visibleFields: sensorsProvider.visibleFieldsFor(key),
|
visibleFields: visibleFields,
|
||||||
fieldOrder: sensorsProvider.metricOrderFor(
|
fieldOrder: sensorsProvider.metricOrderFor(
|
||||||
key,
|
key,
|
||||||
sensorsProvider.visibleFieldsFor(key),
|
availableFieldKeys,
|
||||||
),
|
),
|
||||||
labelOverrides: sensorsProvider.labelOverridesFor(key),
|
labelOverrides: sensorsProvider.labelOverridesFor(key),
|
||||||
fieldSpans: {
|
fieldSpans: {
|
||||||
for (final field in sensorsProvider.visibleFieldsFor(
|
for (final field in visibleFields)
|
||||||
key,
|
|
||||||
))
|
|
||||||
field: sensorsProvider.fieldSpanFor(key, field),
|
field: sensorsProvider.fieldSpanFor(key, field),
|
||||||
},
|
},
|
||||||
onRemove: () async {
|
onRemove: () async {
|
||||||
@@ -439,6 +331,276 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SensorCustomizeView extends StatelessWidget {
|
||||||
|
final String publicKeyHex;
|
||||||
|
final Contact? initialContact;
|
||||||
|
final Future<void> Function({
|
||||||
|
required BuildContext context,
|
||||||
|
required String publicKeyHex,
|
||||||
|
required SensorMetricOption option,
|
||||||
|
required SensorsProvider sensorsProvider,
|
||||||
|
})
|
||||||
|
onRenameMetric;
|
||||||
|
|
||||||
|
const _SensorCustomizeView({
|
||||||
|
required this.publicKeyHex,
|
||||||
|
required this.initialContact,
|
||||||
|
required this.onRenameMetric,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer2<SensorsProvider, ContactsProvider>(
|
||||||
|
builder: (context, sensorsProvider, contactsProvider, child) {
|
||||||
|
Contact? contact = initialContact;
|
||||||
|
for (final entry in contactsProvider.contacts) {
|
||||||
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
|
contact = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final options = sensorMetricOptionsFor(
|
||||||
|
contact,
|
||||||
|
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
||||||
|
);
|
||||||
|
final visibleFields = sensorsProvider.effectiveVisibleFieldsFor(
|
||||||
|
publicKeyHex,
|
||||||
|
options.map((option) => option.key),
|
||||||
|
);
|
||||||
|
final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor(
|
||||||
|
publicKeyHex,
|
||||||
|
);
|
||||||
|
final orderedFieldKeys = sensorsProvider.metricOrderFor(
|
||||||
|
publicKeyHex,
|
||||||
|
options.map((option) => option.key),
|
||||||
|
);
|
||||||
|
final optionByKey = <String, SensorMetricOption>{
|
||||||
|
for (final option in options) option.key: option,
|
||||||
|
};
|
||||||
|
final orderedOptions = orderedFieldKeys
|
||||||
|
.map((fieldKey) => optionByKey[fieldKey])
|
||||||
|
.whereType<SensorMetricOption>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Customize ${contact?.displayName ?? 'Sensor'}'),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||||
|
children: [
|
||||||
|
_SensorCustomizeSectionCard(
|
||||||
|
title: 'Live preview',
|
||||||
|
subtitle:
|
||||||
|
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
|
||||||
|
child: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: sensorsProvider.stateFor(publicKeyHex),
|
||||||
|
visibleFields: visibleFields,
|
||||||
|
fieldOrder: sensorsProvider.metricOrderFor(
|
||||||
|
publicKeyHex,
|
||||||
|
visibleFields,
|
||||||
|
),
|
||||||
|
labelOverrides: sensorsProvider.labelOverridesFor(
|
||||||
|
publicKeyHex,
|
||||||
|
),
|
||||||
|
fieldSpans: {
|
||||||
|
for (final field in visibleFields)
|
||||||
|
field: sensorsProvider.fieldSpanFor(publicKeyHex, field),
|
||||||
|
},
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
emptyMetricsMessage: 'No telemetry fields available yet.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_SensorCustomizeSectionCard(
|
||||||
|
title: 'Refresh schedule',
|
||||||
|
subtitle:
|
||||||
|
'Choose how often this sensor should refresh while your device stays connected.',
|
||||||
|
child: SensorAutoRefreshOptions(
|
||||||
|
selectedMinutes: autoRefreshMinutes,
|
||||||
|
onSelected: (minutes) {
|
||||||
|
sensorsProvider.setAutoRefreshMinutes(
|
||||||
|
publicKeyHex,
|
||||||
|
minutes,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 8, 4, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Field layout',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Use the same value-card previews shown on the dashboard to control visibility, labels, width, and order.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...orderedOptions.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final option = entry.value;
|
||||||
|
final visible = visibleFields.contains(option.key);
|
||||||
|
final span = sensorsProvider.fieldSpanFor(
|
||||||
|
publicKeyHex,
|
||||||
|
option.key,
|
||||||
|
);
|
||||||
|
return SensorMetricSelectorItem(
|
||||||
|
option: option,
|
||||||
|
visible: visible,
|
||||||
|
span: span,
|
||||||
|
canMoveUp: index > 0,
|
||||||
|
canMoveDown: index < orderedOptions.length - 1,
|
||||||
|
onToggle: (value) {
|
||||||
|
sensorsProvider.toggleMetric(
|
||||||
|
publicKeyHex,
|
||||||
|
option.key,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRename: () => onRenameMetric(
|
||||||
|
context: context,
|
||||||
|
publicKeyHex: publicKeyHex,
|
||||||
|
option: option,
|
||||||
|
sensorsProvider: sensorsProvider,
|
||||||
|
),
|
||||||
|
onMoveUp: index > 0
|
||||||
|
? () => sensorsProvider.moveMetric(
|
||||||
|
publicKeyHex,
|
||||||
|
availableFieldKeys: orderedFieldKeys,
|
||||||
|
oldIndex: index,
|
||||||
|
newIndex: index - 1,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onMoveDown: index < orderedOptions.length - 1
|
||||||
|
? () => sensorsProvider.moveMetric(
|
||||||
|
publicKeyHex,
|
||||||
|
availableFieldKeys: orderedFieldKeys,
|
||||||
|
oldIndex: index,
|
||||||
|
newIndex: index + 1,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onSpanChanged: (selection) {
|
||||||
|
sensorsProvider.setFieldSpan(
|
||||||
|
publicKeyHex,
|
||||||
|
option.key,
|
||||||
|
selection,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SensorCustomizeSectionCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _SensorCustomizeSectionCard({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surfaceContainerLow,
|
||||||
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.045),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SensorAutoRefreshOptions extends StatelessWidget {
|
||||||
|
final int selectedMinutes;
|
||||||
|
final ValueChanged<int> onSelected;
|
||||||
|
|
||||||
|
const SensorAutoRefreshOptions({
|
||||||
|
super.key,
|
||||||
|
required this.selectedMinutes,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: SensorsProvider.supportedAutoRefreshIntervals
|
||||||
|
.map(
|
||||||
|
(minutes) => ChoiceChip(
|
||||||
|
label: Text(_formatAutoRefreshIntervalLabel(minutes)),
|
||||||
|
selected: selectedMinutes == minutes,
|
||||||
|
onSelected: (_) => onSelected(minutes),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAutoRefreshIntervalLabel(int minutes) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return 'Off';
|
||||||
|
}
|
||||||
|
if (minutes >= 360 && minutes % 60 == 0) {
|
||||||
|
return '${minutes ~/ 60}h';
|
||||||
|
}
|
||||||
|
return '${minutes}m';
|
||||||
|
}
|
||||||
|
|
||||||
class SensorMetricSelectorItem extends StatelessWidget {
|
class SensorMetricSelectorItem extends StatelessWidget {
|
||||||
final SensorMetricOption option;
|
final SensorMetricOption option;
|
||||||
final bool visible;
|
final bool visible;
|
||||||
@@ -468,84 +630,167 @@ class SensorMetricSelectorItem extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Padding(
|
final colorScheme = theme.colorScheme;
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
final showChannelChip = option.channel != null && option.channel != 1;
|
||||||
child: Column(
|
final previewCardData =
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
option.previewCardData ??
|
||||||
children: [
|
SensorMetricCardData(
|
||||||
Row(
|
fieldKey: option.key,
|
||||||
children: [
|
icon: Icons.sensors,
|
||||||
Expanded(
|
label: option.defaultLabel,
|
||||||
child: FilterChip(
|
value: option.valuePreview ?? 'No telemetry yet',
|
||||||
selected: visible,
|
accent: colorScheme.primary,
|
||||||
label: Text(option.label),
|
channel: option.channel,
|
||||||
onSelected: onToggle,
|
);
|
||||||
),
|
|
||||||
),
|
return Container(
|
||||||
const SizedBox(width: 8),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
IconButton(
|
decoration: BoxDecoration(
|
||||||
tooltip: 'Rename',
|
borderRadius: BorderRadius.circular(28),
|
||||||
onPressed: onRename,
|
gradient: LinearGradient(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
begin: Alignment.topLeft,
|
||||||
),
|
end: Alignment.bottomRight,
|
||||||
],
|
colors: [
|
||||||
|
colorScheme.surfaceContainerLow,
|
||||||
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.045),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
if (option.valuePreview != null || option.channel != null)
|
],
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(top: 8),
|
child: Padding(
|
||||||
child: Wrap(
|
padding: const EdgeInsets.all(12),
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
child: Column(
|
||||||
spacing: 8,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
runSpacing: 6,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
if (option.valuePreview != null)
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(
|
children: [
|
||||||
option.valuePreview!,
|
Expanded(
|
||||||
key: ValueKey('sensor_selector_value_${option.key}'),
|
child: Column(
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
children: [
|
||||||
fontWeight: FontWeight.w600,
|
Text(
|
||||||
),
|
option.defaultLabel,
|
||||||
),
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
if (option.channel != null)
|
fontWeight: FontWeight.w700,
|
||||||
Container(
|
|
||||||
key: ValueKey('sensor_selector_channel_${option.key}'),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'ch${option.channel}',
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onRename,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
foregroundColor: colorScheme.primary,
|
||||||
|
textStyle: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
|
label: const Text('Rename'),
|
||||||
|
),
|
||||||
|
if (showChannelChip)
|
||||||
|
Container(
|
||||||
|
key: ValueKey(
|
||||||
|
'sensor_selector_channel_${option.key}',
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: previewCardData.accent.withValues(
|
||||||
|
alpha: 0.10,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Channel ${option.channel}',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: previewCardData.accent,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: visible
|
||||||
|
? const Color(0xFF218B63).withValues(alpha: 0.12)
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
visible ? 'Visible' : 'Hidden',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: visible
|
||||||
|
? const Color(0xFF218B63)
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Opacity(
|
||||||
|
opacity: visible ? 1 : 0.55,
|
||||||
|
child: SensorMetricTile(
|
||||||
|
data: previewCardData,
|
||||||
|
width: double.infinity,
|
||||||
|
keyPrefix: 'sensor_selector_metric',
|
||||||
|
allowMapPreview: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
Row(
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Expanded(
|
||||||
tooltip: 'Move up',
|
child: Text(
|
||||||
onPressed: canMoveUp ? onMoveUp : null,
|
'Show on sensor card',
|
||||||
icon: const Icon(Icons.arrow_upward),
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
Switch(value: visible, onChanged: onToggle),
|
||||||
tooltip: 'Move down',
|
],
|
||||||
onPressed: canMoveDown ? onMoveDown : null,
|
),
|
||||||
icon: const Icon(Icons.arrow_downward),
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Card width',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
SegmentedButton<int>(
|
SegmentedButton<int>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment<int>(value: 1, label: Text('1x')),
|
ButtonSegment<int>(value: 1, label: Text('1x')),
|
||||||
@@ -558,8 +803,30 @@ class SensorMetricSelectorItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
],
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Order',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Move up',
|
||||||
|
onPressed: canMoveUp ? onMoveUp : null,
|
||||||
|
icon: const Icon(Icons.arrow_upward),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Move down',
|
||||||
|
onPressed: canMoveDown ? onMoveDown : null,
|
||||||
|
icon: const Icon(Icons.arrow_downward),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1875,7 +1875,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Switch.adaptive(
|
Switch(
|
||||||
value: _showCurrentImagePreview,
|
value: _showCurrentImagePreview,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() => _showCurrentImagePreview = value);
|
setState(() => _showCurrentImagePreview = value);
|
||||||
|
|||||||
@@ -19,7 +19,61 @@ class CayenneLppParser {
|
|||||||
static const int _lppDirection = 132;
|
static const int _lppDirection = 132;
|
||||||
static const int _lppUnixTime = 133;
|
static const int _lppUnixTime = 133;
|
||||||
static const int _lppColour = 135;
|
static const int _lppColour = 135;
|
||||||
|
static const int _lppGust = 137;
|
||||||
|
static const int _lppDewPoint = 138;
|
||||||
|
static const int _lppRain = 139;
|
||||||
static const int _lppSwitch = 142;
|
static const int _lppSwitch = 142;
|
||||||
|
static const int _lppBinaryBool = 143;
|
||||||
|
static const int _lppBinaryPowerSwitch = 144;
|
||||||
|
static const int _lppBinaryOpen = 145;
|
||||||
|
static const int _lppBinaryBatteryLow = 146;
|
||||||
|
static const int _lppBinaryCharging = 147;
|
||||||
|
static const int _lppBinaryCarbonMonoxide = 148;
|
||||||
|
static const int _lppBinaryCold = 149;
|
||||||
|
static const int _lppBinaryConnectivity = 150;
|
||||||
|
static const int _lppBinaryDoor = 151;
|
||||||
|
static const int _lppBinaryGarageDoor = 152;
|
||||||
|
static const int _lppBinaryGas = 153;
|
||||||
|
static const int _lppBinaryHeat = 154;
|
||||||
|
static const int _lppBinaryLight = 155;
|
||||||
|
static const int _lppBinaryLock = 156;
|
||||||
|
static const int _lppBinaryMoisture = 157;
|
||||||
|
static const int _lppBinaryMotion = 158;
|
||||||
|
static const int _lppBinaryMoving = 159;
|
||||||
|
static const int _lppBinaryOccupancy = 160;
|
||||||
|
static const int _lppBinaryPlug = 161;
|
||||||
|
static const int _lppBinaryPresence = 162;
|
||||||
|
static const int _lppBinaryProblem = 163;
|
||||||
|
static const int _lppBinaryRunning = 164;
|
||||||
|
static const int _lppBinarySafety = 165;
|
||||||
|
static const int _lppBinarySmoke = 166;
|
||||||
|
static const int _lppBinarySound = 167;
|
||||||
|
static const int _lppBinaryTamper = 168;
|
||||||
|
static const int _lppBinaryVibration = 169;
|
||||||
|
static const int _lppBinaryWindow = 170;
|
||||||
|
static const int _lppButtonEvent = 171;
|
||||||
|
static const int _lppDimmer = 172;
|
||||||
|
static const int _lppUv = 173;
|
||||||
|
static const int _lppLightLevel = 174;
|
||||||
|
static const int _lppPm25 = 175;
|
||||||
|
static const int _lppPm10 = 176;
|
||||||
|
static const int _lppCo2 = 177;
|
||||||
|
static const int _lppTvoc = 178;
|
||||||
|
static const int _lppRpm = 179;
|
||||||
|
static const int _lppConductivity = 180;
|
||||||
|
static const int _lppRotation = 181;
|
||||||
|
static const int _lppDuration = 182;
|
||||||
|
static const int _lppAcceleration = 183;
|
||||||
|
static const int _lppGyroRate = 184;
|
||||||
|
static const int _lppVolume = 185;
|
||||||
|
static const int _lppFlowRate = 186;
|
||||||
|
static const int _lppVolumeStorage = 187;
|
||||||
|
static const int _lppWater = 188;
|
||||||
|
static const int _lppGasVolume = 189;
|
||||||
|
static const int _lppMass = 190;
|
||||||
|
static const int _lppSignedSpeed = 191;
|
||||||
|
static const int _lppSignedPower = 192;
|
||||||
|
static const int _lppSignedCurrent = 193;
|
||||||
|
|
||||||
/// Parse Cayenne LPP data into ContactTelemetry
|
/// Parse Cayenne LPP data into ContactTelemetry
|
||||||
static ContactTelemetry parse(Uint8List data) {
|
static ContactTelemetry parse(Uint8List data) {
|
||||||
@@ -345,6 +399,212 @@ class CayenneLppParser {
|
|||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case _lppGust:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Gust: ${value}m/s');
|
||||||
|
extraSensorData['gust_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDewPoint:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
|
||||||
|
extraSensorData['dew_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRain:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Rain: ${value}mm');
|
||||||
|
extraSensorData['rain_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppBinaryBool:
|
||||||
|
case _lppBinaryPowerSwitch:
|
||||||
|
case _lppBinaryOpen:
|
||||||
|
case _lppBinaryBatteryLow:
|
||||||
|
case _lppBinaryCharging:
|
||||||
|
case _lppBinaryCarbonMonoxide:
|
||||||
|
case _lppBinaryCold:
|
||||||
|
case _lppBinaryConnectivity:
|
||||||
|
case _lppBinaryDoor:
|
||||||
|
case _lppBinaryGarageDoor:
|
||||||
|
case _lppBinaryGas:
|
||||||
|
case _lppBinaryHeat:
|
||||||
|
case _lppBinaryLight:
|
||||||
|
case _lppBinaryLock:
|
||||||
|
case _lppBinaryMoisture:
|
||||||
|
case _lppBinaryMotion:
|
||||||
|
case _lppBinaryMoving:
|
||||||
|
case _lppBinaryOccupancy:
|
||||||
|
case _lppBinaryPlug:
|
||||||
|
case _lppBinaryPresence:
|
||||||
|
case _lppBinaryProblem:
|
||||||
|
case _lppBinaryRunning:
|
||||||
|
case _lppBinarySafety:
|
||||||
|
case _lppBinarySmoke:
|
||||||
|
case _lppBinarySound:
|
||||||
|
case _lppBinaryTamper:
|
||||||
|
case _lppBinaryVibration:
|
||||||
|
case _lppBinaryWindow:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Binary state: $value');
|
||||||
|
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
|
||||||
|
value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppButtonEvent:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Button event: $value');
|
||||||
|
extraSensorData['button_event_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDimmer:
|
||||||
|
final value = _readInt8(reader);
|
||||||
|
debugPrint(' Dimmer: $value');
|
||||||
|
extraSensorData['dimmer_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppUv:
|
||||||
|
final value = reader.readByte() / 10.0;
|
||||||
|
debugPrint(' UV index: $value');
|
||||||
|
extraSensorData['uv_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppLightLevel:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Light level: $value');
|
||||||
|
extraSensorData['light_level_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPm25:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' PM2.5: $value');
|
||||||
|
extraSensorData['pm25_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPm10:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' PM10: $value');
|
||||||
|
extraSensorData['pm10_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppCo2:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' CO2: $value');
|
||||||
|
extraSensorData['co2_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppTvoc:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' TVOC: $value');
|
||||||
|
extraSensorData['tvoc_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRpm:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' RPM: $value');
|
||||||
|
extraSensorData['rpm_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppConductivity:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Conductivity: $value');
|
||||||
|
extraSensorData['conductivity_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRotation:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Rotation: $value');
|
||||||
|
extraSensorData['rotation_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDuration:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Duration: $value s');
|
||||||
|
extraSensorData['duration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppAcceleration:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000000.0;
|
||||||
|
debugPrint(' Acceleration: $value');
|
||||||
|
extraSensorData['acceleration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGyroRate:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Gyro rate: $value');
|
||||||
|
extraSensorData['gyro_rate_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppVolume:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Volume: $value');
|
||||||
|
extraSensorData['volume_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppFlowRate:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Flow rate: $value');
|
||||||
|
extraSensorData['flow_rate_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppVolumeStorage:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Storage volume: $value');
|
||||||
|
extraSensorData['volume_storage_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppWater:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Water: $value');
|
||||||
|
extraSensorData['water_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGasVolume:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Gas volume: $value');
|
||||||
|
extraSensorData['gas_volume_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppMass:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Mass: $value');
|
||||||
|
extraSensorData['mass_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedSpeed:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000000.0;
|
||||||
|
debugPrint(' Signed speed: $value');
|
||||||
|
extraSensorData['signed_speed_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedPower:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Signed power: $value');
|
||||||
|
extraSensorData['signed_power_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedCurrent:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Signed current: $value');
|
||||||
|
extraSensorData['signed_current_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
case _lppSwitch:
|
case _lppSwitch:
|
||||||
final value = reader.readByte();
|
final value = reader.readByte();
|
||||||
debugPrint(' Switch: $value');
|
debugPrint(' Switch: $value');
|
||||||
@@ -352,11 +612,16 @@ class CayenneLppParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
debugPrint(
|
final size = _payloadSizeForType(type);
|
||||||
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
|
if (size == null || reader.remainingBytesCount < size) {
|
||||||
);
|
debugPrint(
|
||||||
// Unknown type, skip remaining to avoid parsing errors
|
' ⚠️ Unknown type $type with unsupported size, stopping parse',
|
||||||
reader.skip(reader.remainingBytesCount);
|
);
|
||||||
|
reader.skip(reader.remainingBytesCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
|
||||||
|
reader.skip(size);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -420,7 +685,154 @@ class CayenneLppParser {
|
|||||||
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
|
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _sourceChannelKey(String fieldKey) => '__source_channel:$fieldKey';
|
static int _readInt32BE(BufferReader reader) {
|
||||||
|
final value = _readUInt32BE(reader);
|
||||||
|
if ((value & 0x80000000) != 0) {
|
||||||
|
return value - 0x100000000;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _readInt8(BufferReader reader) {
|
||||||
|
final value = reader.readByte();
|
||||||
|
if ((value & 0x80) != 0) {
|
||||||
|
return value - 0x100;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _sourceChannelKey(String fieldKey) =>
|
||||||
|
'__source_channel:$fieldKey';
|
||||||
|
|
||||||
|
static String _binaryMetricKeyForType(int type) {
|
||||||
|
switch (type) {
|
||||||
|
case _lppBinaryBool:
|
||||||
|
return 'binary_bool';
|
||||||
|
case _lppBinaryPowerSwitch:
|
||||||
|
return 'binary_power_switch';
|
||||||
|
case _lppBinaryOpen:
|
||||||
|
return 'binary_open';
|
||||||
|
case _lppBinaryBatteryLow:
|
||||||
|
return 'binary_battery_low';
|
||||||
|
case _lppBinaryCharging:
|
||||||
|
return 'binary_charging';
|
||||||
|
case _lppBinaryCarbonMonoxide:
|
||||||
|
return 'binary_carbon_monoxide';
|
||||||
|
case _lppBinaryCold:
|
||||||
|
return 'binary_cold';
|
||||||
|
case _lppBinaryConnectivity:
|
||||||
|
return 'binary_connectivity';
|
||||||
|
case _lppBinaryDoor:
|
||||||
|
return 'binary_door';
|
||||||
|
case _lppBinaryGarageDoor:
|
||||||
|
return 'binary_garage_door';
|
||||||
|
case _lppBinaryGas:
|
||||||
|
return 'binary_gas';
|
||||||
|
case _lppBinaryHeat:
|
||||||
|
return 'binary_heat';
|
||||||
|
case _lppBinaryLight:
|
||||||
|
return 'binary_light';
|
||||||
|
case _lppBinaryLock:
|
||||||
|
return 'binary_lock';
|
||||||
|
case _lppBinaryMoisture:
|
||||||
|
return 'binary_moisture';
|
||||||
|
case _lppBinaryMotion:
|
||||||
|
return 'binary_motion';
|
||||||
|
case _lppBinaryMoving:
|
||||||
|
return 'binary_moving';
|
||||||
|
case _lppBinaryOccupancy:
|
||||||
|
return 'binary_occupancy';
|
||||||
|
case _lppBinaryPlug:
|
||||||
|
return 'binary_plug';
|
||||||
|
case _lppBinaryPresence:
|
||||||
|
return 'binary_presence';
|
||||||
|
case _lppBinaryProblem:
|
||||||
|
return 'binary_problem';
|
||||||
|
case _lppBinaryRunning:
|
||||||
|
return 'binary_running';
|
||||||
|
case _lppBinarySafety:
|
||||||
|
return 'binary_safety';
|
||||||
|
case _lppBinarySmoke:
|
||||||
|
return 'binary_smoke';
|
||||||
|
case _lppBinarySound:
|
||||||
|
return 'binary_sound';
|
||||||
|
case _lppBinaryTamper:
|
||||||
|
return 'binary_tamper';
|
||||||
|
case _lppBinaryVibration:
|
||||||
|
return 'binary_vibration';
|
||||||
|
case _lppBinaryWindow:
|
||||||
|
return 'binary_window';
|
||||||
|
}
|
||||||
|
return 'binary_state';
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _payloadSizeForType(int type) {
|
||||||
|
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case MeshCoreConstants.lppDigitalInput:
|
||||||
|
case MeshCoreConstants.lppDigitalOutput:
|
||||||
|
case MeshCoreConstants.lppPresenceSensor:
|
||||||
|
case MeshCoreConstants.lppHumiditySensor:
|
||||||
|
case _lppPercentage:
|
||||||
|
case _lppSwitch:
|
||||||
|
case _lppButtonEvent:
|
||||||
|
case _lppDimmer:
|
||||||
|
case _lppUv:
|
||||||
|
case _lppLightLevel:
|
||||||
|
return 1;
|
||||||
|
case MeshCoreConstants.lppAnalogInput:
|
||||||
|
case MeshCoreConstants.lppAnalogOutput:
|
||||||
|
case MeshCoreConstants.lppIlluminanceSensor:
|
||||||
|
case MeshCoreConstants.lppTemperatureSensor:
|
||||||
|
case MeshCoreConstants.lppBarometer:
|
||||||
|
case MeshCoreConstants.lppVoltageSensor:
|
||||||
|
case _lppCurrent:
|
||||||
|
case _lppAltitude:
|
||||||
|
case _lppConcentration:
|
||||||
|
case _lppPower:
|
||||||
|
case _lppSpeed:
|
||||||
|
case _lppDirection:
|
||||||
|
case _lppGust:
|
||||||
|
case _lppDewPoint:
|
||||||
|
case _lppRain:
|
||||||
|
case _lppPm25:
|
||||||
|
case _lppPm10:
|
||||||
|
case _lppCo2:
|
||||||
|
case _lppTvoc:
|
||||||
|
case _lppRpm:
|
||||||
|
case _lppConductivity:
|
||||||
|
case _lppRotation:
|
||||||
|
return 2;
|
||||||
|
case MeshCoreConstants.lppAccelerometer:
|
||||||
|
case MeshCoreConstants.lppGyrometer:
|
||||||
|
return 6;
|
||||||
|
case MeshCoreConstants.lppGps:
|
||||||
|
return 9;
|
||||||
|
case _lppGenericSensor:
|
||||||
|
case _lppFrequency:
|
||||||
|
case _lppDistance:
|
||||||
|
case _lppEnergy:
|
||||||
|
case _lppUnixTime:
|
||||||
|
case _lppDuration:
|
||||||
|
case _lppAcceleration:
|
||||||
|
case _lppGyroRate:
|
||||||
|
case _lppVolume:
|
||||||
|
case _lppFlowRate:
|
||||||
|
case _lppVolumeStorage:
|
||||||
|
case _lppWater:
|
||||||
|
case _lppGasVolume:
|
||||||
|
case _lppMass:
|
||||||
|
case _lppSignedSpeed:
|
||||||
|
case _lppSignedPower:
|
||||||
|
case _lppSignedCurrent:
|
||||||
|
return 4;
|
||||||
|
case _lppColour:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||||
final start = data.length - remainingBytes;
|
final start = data.length - remainingBytes;
|
||||||
|
|||||||
@@ -1,123 +1,141 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
enum AppThemeMode {
|
enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system }
|
||||||
light,
|
|
||||||
dark,
|
|
||||||
sarRed,
|
|
||||||
sarGreen,
|
|
||||||
sarNavyBlue,
|
|
||||||
system,
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
|
static ThemeData _withSharedControls(ThemeData theme) {
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
return theme.copyWith(
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return colorScheme.onSurface.withValues(alpha: 0.38);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.onPrimary;
|
||||||
|
}
|
||||||
|
return colorScheme.outline;
|
||||||
|
}),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return colorScheme.onSurface.withValues(alpha: 0.12);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.primary;
|
||||||
|
}
|
||||||
|
return colorScheme.surfaceContainerHighest;
|
||||||
|
}),
|
||||||
|
trackOutlineColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.primary;
|
||||||
|
}
|
||||||
|
return colorScheme.outlineVariant;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Light theme (Blue)
|
// Light theme (Blue)
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
useMaterial3: true,
|
||||||
seedColor: Colors.blue,
|
colorScheme: ColorScheme.fromSeed(
|
||||||
brightness: Brightness.light,
|
seedColor: Colors.blue,
|
||||||
),
|
brightness: Brightness.light,
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
centerTitle: true,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
cardTheme: CardThemeData(
|
|
||||||
elevation: 2,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
),
|
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
cardTheme: CardThemeData(
|
||||||
border: OutlineInputBorder(
|
elevation: 2,
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
filled: true,
|
||||||
),
|
),
|
||||||
filled: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark theme (Blue)
|
// Dark theme (Blue)
|
||||||
static ThemeData get darkTheme {
|
static ThemeData get darkTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
useMaterial3: true,
|
||||||
seedColor: Colors.blue,
|
colorScheme: ColorScheme.fromSeed(
|
||||||
brightness: Brightness.dark,
|
seedColor: Colors.blue,
|
||||||
),
|
brightness: Brightness.dark,
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
centerTitle: true,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
cardTheme: CardThemeData(
|
|
||||||
elevation: 2,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
),
|
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
cardTheme: CardThemeData(
|
||||||
border: OutlineInputBorder(
|
elevation: 2,
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
filled: true,
|
||||||
),
|
),
|
||||||
filled: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAR Red theme (Emergency/Alert tones)
|
// SAR Red theme (Emergency/Alert tones)
|
||||||
static ThemeData get sarRedTheme {
|
static ThemeData get sarRedTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: const ColorScheme.dark(
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFFFF5252), // Bright red
|
brightness: Brightness.dark,
|
||||||
onPrimary: Color(0xFF000000),
|
primary: Color(0xFFFF5252), // Bright red
|
||||||
primaryContainer: Color(0xFF8B0000), // Dark red
|
onPrimary: Color(0xFF000000),
|
||||||
onPrimaryContainer: Color(0xFFFFCDD2),
|
primaryContainer: Color(0xFF8B0000), // Dark red
|
||||||
secondary: Color(0xFFFF8A80),
|
onPrimaryContainer: Color(0xFFFFCDD2),
|
||||||
onSecondary: Color(0xFF000000),
|
secondary: Color(0xFFFF8A80),
|
||||||
secondaryContainer: Color(0xFFB71C1C),
|
onSecondary: Color(0xFF000000),
|
||||||
onSecondaryContainer: Color(0xFFFFCDD2),
|
secondaryContainer: Color(0xFFB71C1C),
|
||||||
tertiary: Color(0xFFFF6E40),
|
onSecondaryContainer: Color(0xFFFFCDD2),
|
||||||
onTertiary: Color(0xFF000000),
|
tertiary: Color(0xFFFF6E40),
|
||||||
error: Color(0xFFCF6679),
|
onTertiary: Color(0xFF000000),
|
||||||
onError: Color(0xFF000000),
|
error: Color(0xFFCF6679),
|
||||||
surface: Color(0xFF1A0000), // Very dark red-tinted
|
onError: Color(0xFF000000),
|
||||||
onSurface: Color(0xFFFFEBEE),
|
surface: Color(0xFF1A0000), // Very dark red-tinted
|
||||||
surfaceContainerHighest: Color(0xFF2D0000),
|
onSurface: Color(0xFFFFEBEE),
|
||||||
onSurfaceVariant: Color(0xFFFFCDD2),
|
surfaceContainerHighest: Color(0xFF2D0000),
|
||||||
outline: Color(0xFFFF5252),
|
onSurfaceVariant: Color(0xFFFFCDD2),
|
||||||
),
|
outline: Color(0xFFFF5252),
|
||||||
scaffoldBackgroundColor: const Color(0xFF1A0000),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
scaffoldBackgroundColor: const Color(0xFF1A0000),
|
||||||
centerTitle: true,
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
centerTitle: true,
|
||||||
backgroundColor: Color(0xFF2D0000),
|
elevation: 0,
|
||||||
foregroundColor: Color(0xFFFFEBEE),
|
backgroundColor: Color(0xFF2D0000),
|
||||||
),
|
foregroundColor: Color(0xFFFFEBEE),
|
||||||
cardTheme: CardThemeData(
|
),
|
||||||
elevation: 2,
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF2D0000),
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
color: const Color(0xFF2D0000),
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
side: const BorderSide(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Color(0xFFFF5252),
|
side: const BorderSide(color: Color(0xFFFF5252), width: 1),
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
border: OutlineInputBorder(
|
||||||
border: OutlineInputBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderSide: const BorderSide(color: Color(0xFFFF5252)),
|
||||||
borderSide: const BorderSide(color: Color(0xFFFF5252)),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF2D0000),
|
||||||
),
|
),
|
||||||
filled: true,
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
fillColor: const Color(0xFF2D0000),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: const Color(0xFFFF5252),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
foregroundColor: const Color(0xFF000000),
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: const Color(0xFFFF5252),
|
|
||||||
foregroundColor: const Color(0xFF000000),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -125,58 +143,57 @@ class AppTheme {
|
|||||||
|
|
||||||
// SAR Green theme (All Clear/Safe tones)
|
// SAR Green theme (All Clear/Safe tones)
|
||||||
static ThemeData get sarGreenTheme {
|
static ThemeData get sarGreenTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: const ColorScheme.dark(
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFF69F0AE), // Bright green
|
brightness: Brightness.dark,
|
||||||
onPrimary: Color(0xFF000000),
|
primary: Color(0xFF69F0AE), // Bright green
|
||||||
primaryContainer: Color(0xFF00695C), // Dark teal-green
|
onPrimary: Color(0xFF000000),
|
||||||
onPrimaryContainer: Color(0xFFB9F6CA),
|
primaryContainer: Color(0xFF00695C), // Dark teal-green
|
||||||
secondary: Color(0xFF64FFDA),
|
onPrimaryContainer: Color(0xFFB9F6CA),
|
||||||
onSecondary: Color(0xFF000000),
|
secondary: Color(0xFF64FFDA),
|
||||||
secondaryContainer: Color(0xFF004D40),
|
onSecondary: Color(0xFF000000),
|
||||||
onSecondaryContainer: Color(0xFFB9F6CA),
|
secondaryContainer: Color(0xFF004D40),
|
||||||
tertiary: Color(0xFF1DE9B6),
|
onSecondaryContainer: Color(0xFFB9F6CA),
|
||||||
onTertiary: Color(0xFF000000),
|
tertiary: Color(0xFF1DE9B6),
|
||||||
error: Color(0xFFCF6679),
|
onTertiary: Color(0xFF000000),
|
||||||
onError: Color(0xFF000000),
|
error: Color(0xFFCF6679),
|
||||||
surface: Color(0xFF001A12), // Very dark green-tinted
|
onError: Color(0xFF000000),
|
||||||
onSurface: Color(0xFFE8F5E9),
|
surface: Color(0xFF001A12), // Very dark green-tinted
|
||||||
surfaceContainerHighest: Color(0xFF002D1F),
|
onSurface: Color(0xFFE8F5E9),
|
||||||
onSurfaceVariant: Color(0xFFB9F6CA),
|
surfaceContainerHighest: Color(0xFF002D1F),
|
||||||
outline: Color(0xFF69F0AE),
|
onSurfaceVariant: Color(0xFFB9F6CA),
|
||||||
),
|
outline: Color(0xFF69F0AE),
|
||||||
scaffoldBackgroundColor: const Color(0xFF001A12),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
scaffoldBackgroundColor: const Color(0xFF001A12),
|
||||||
centerTitle: true,
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
centerTitle: true,
|
||||||
backgroundColor: Color(0xFF002D1F),
|
elevation: 0,
|
||||||
foregroundColor: Color(0xFFE8F5E9),
|
backgroundColor: Color(0xFF002D1F),
|
||||||
),
|
foregroundColor: Color(0xFFE8F5E9),
|
||||||
cardTheme: CardThemeData(
|
),
|
||||||
elevation: 2,
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF002D1F),
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
color: const Color(0xFF002D1F),
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
side: const BorderSide(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Color(0xFF69F0AE),
|
side: const BorderSide(color: Color(0xFF69F0AE), width: 1),
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
border: OutlineInputBorder(
|
||||||
border: OutlineInputBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
|
||||||
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF002D1F),
|
||||||
),
|
),
|
||||||
filled: true,
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
fillColor: const Color(0xFF002D1F),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: const Color(0xFF69F0AE),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
foregroundColor: const Color(0xFF000000),
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: const Color(0xFF69F0AE),
|
|
||||||
foregroundColor: const Color(0xFF000000),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -184,58 +201,57 @@ class AppTheme {
|
|||||||
|
|
||||||
// SAR Navy Blue theme (Professional/Operations tones)
|
// SAR Navy Blue theme (Professional/Operations tones)
|
||||||
static ThemeData get sarNavyBlueTheme {
|
static ThemeData get sarNavyBlueTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: const ColorScheme.dark(
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFF5C9FFF), // Bright navy blue
|
brightness: Brightness.dark,
|
||||||
onPrimary: Color(0xFF000000),
|
primary: Color(0xFF5C9FFF), // Bright navy blue
|
||||||
primaryContainer: Color(0xFF003366), // Dark navy
|
onPrimary: Color(0xFF000000),
|
||||||
onPrimaryContainer: Color(0xFFBBDEFF),
|
primaryContainer: Color(0xFF003366), // Dark navy
|
||||||
secondary: Color(0xFF80B3FF),
|
onPrimaryContainer: Color(0xFFBBDEFF),
|
||||||
onSecondary: Color(0xFF000000),
|
secondary: Color(0xFF80B3FF),
|
||||||
secondaryContainer: Color(0xFF002244),
|
onSecondary: Color(0xFF000000),
|
||||||
onSecondaryContainer: Color(0xFFBBDEFF),
|
secondaryContainer: Color(0xFF002244),
|
||||||
tertiary: Color(0xFF4DB8FF),
|
onSecondaryContainer: Color(0xFFBBDEFF),
|
||||||
onTertiary: Color(0xFF000000),
|
tertiary: Color(0xFF4DB8FF),
|
||||||
error: Color(0xFFCF6679),
|
onTertiary: Color(0xFF000000),
|
||||||
onError: Color(0xFF000000),
|
error: Color(0xFFCF6679),
|
||||||
surface: Color(0xFF00111C), // Very dark blue-tinted
|
onError: Color(0xFF000000),
|
||||||
onSurface: Color(0xFFE3F2FD),
|
surface: Color(0xFF00111C), // Very dark blue-tinted
|
||||||
surfaceContainerHighest: Color(0xFF001A2D),
|
onSurface: Color(0xFFE3F2FD),
|
||||||
onSurfaceVariant: Color(0xFFBBDEFF),
|
surfaceContainerHighest: Color(0xFF001A2D),
|
||||||
outline: Color(0xFF5C9FFF),
|
onSurfaceVariant: Color(0xFFBBDEFF),
|
||||||
),
|
outline: Color(0xFF5C9FFF),
|
||||||
scaffoldBackgroundColor: const Color(0xFF00111C),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
scaffoldBackgroundColor: const Color(0xFF00111C),
|
||||||
centerTitle: true,
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
centerTitle: true,
|
||||||
backgroundColor: Color(0xFF001A2D),
|
elevation: 0,
|
||||||
foregroundColor: Color(0xFFE3F2FD),
|
backgroundColor: Color(0xFF001A2D),
|
||||||
),
|
foregroundColor: Color(0xFFE3F2FD),
|
||||||
cardTheme: CardThemeData(
|
),
|
||||||
elevation: 2,
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF001A2D),
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
color: const Color(0xFF001A2D),
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
side: const BorderSide(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Color(0xFF5C9FFF),
|
side: const BorderSide(color: Color(0xFF5C9FFF), width: 1),
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
border: OutlineInputBorder(
|
||||||
border: OutlineInputBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
|
||||||
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF001A2D),
|
||||||
),
|
),
|
||||||
filled: true,
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
fillColor: const Color(0xFF001A2D),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: const Color(0xFF5C9FFF),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
foregroundColor: const Color(0xFF000000),
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: const Color(0xFF5C9FFF),
|
|
||||||
foregroundColor: const Color(0xFF000000),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await _showSensorPreviewSheet(context, contact);
|
await _showSensorPreviewView(context, contact);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (canAddToSensors)
|
if (canAddToSensors)
|
||||||
@@ -469,49 +469,14 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showSensorPreviewSheet(
|
Future<void> _showSensorPreviewView(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact,
|
Contact contact,
|
||||||
) async {
|
) async {
|
||||||
final publicKeyHex = contact.publicKeyHex;
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
await showModalBottomSheet<void>(
|
fullscreenDialog: true,
|
||||||
context: context,
|
builder: (pageContext) => _SensorPreviewView(contact: contact),
|
||||||
isScrollControlled: true,
|
|
||||||
showDragHandle: true,
|
|
||||||
builder: (sheetContext) => Consumer2<ContactsProvider, SensorsProvider>(
|
|
||||||
builder: (context, contactsProvider, sensorsProvider, child) {
|
|
||||||
Contact? liveContact;
|
|
||||||
for (final entry in contactsProvider.contacts) {
|
|
||||||
if (entry.publicKeyHex == publicKeyHex) {
|
|
||||||
liveContact = entry;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final previewContact = liveContact ?? contact;
|
|
||||||
final visibleFields = sensorMetricKeysFor(previewContact);
|
|
||||||
final fieldOrder = sensorsProvider.metricOrderFor(
|
|
||||||
publicKeyHex,
|
|
||||||
visibleFields,
|
|
||||||
);
|
|
||||||
|
|
||||||
return SafeArea(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
|
||||||
child: SensorTelemetryCard(
|
|
||||||
contact: previewContact,
|
|
||||||
state: sensorsProvider.stateFor(publicKeyHex),
|
|
||||||
visibleFields: visibleFields,
|
|
||||||
fieldOrder: fieldOrder,
|
|
||||||
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
|
||||||
fieldSpans: sensorFullWidthFieldSpans(visibleFields),
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
emptyMetricsMessage: 'No telemetry fields available yet.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1009,60 @@ class ContactTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SensorPreviewView extends StatelessWidget {
|
||||||
|
final Contact contact;
|
||||||
|
|
||||||
|
const _SensorPreviewView({required this.contact});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final publicKeyHex = contact.publicKeyHex;
|
||||||
|
return Consumer2<ContactsProvider, SensorsProvider>(
|
||||||
|
builder: (context, contactsProvider, sensorsProvider, child) {
|
||||||
|
Contact? liveContact;
|
||||||
|
for (final entry in contactsProvider.contacts) {
|
||||||
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
|
liveContact = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final previewContact = liveContact ?? contact;
|
||||||
|
final visibleFields = sensorMetricKeysFor(previewContact);
|
||||||
|
final fieldOrder = sensorsProvider.metricOrderFor(
|
||||||
|
publicKeyHex,
|
||||||
|
visibleFields,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: Text(previewContact.displayName),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||||
|
children: [
|
||||||
|
SensorTelemetryCard(
|
||||||
|
contact: previewContact,
|
||||||
|
state: sensorsProvider.stateFor(publicKeyHex),
|
||||||
|
visibleFields: visibleFields,
|
||||||
|
fieldOrder: fieldOrder,
|
||||||
|
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(visibleFields),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
emptyMetricsMessage: 'No telemetry fields available yet.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ContactNameOverrideSheet extends StatefulWidget {
|
class _ContactNameOverrideSheet extends StatefulWidget {
|
||||||
final String initialValue;
|
final String initialValue;
|
||||||
final String advertisedName;
|
final String advertisedName;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.0314.1+30
|
version: 2026.0315.1+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'retains last valid gps for any contact when telemetry gps is invalid or missing',
|
'clears telemetry gps on refresh when gps is invalid or missing but keeps fallback display location',
|
||||||
() {
|
() {
|
||||||
final contactTypes = <ContactType>[
|
final contactTypes = <ContactType>[
|
||||||
ContactType.chat,
|
ContactType.chat,
|
||||||
@@ -165,23 +165,24 @@ void main() {
|
|||||||
);
|
);
|
||||||
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), validGps);
|
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), validGps);
|
||||||
|
|
||||||
// No GPS frame should keep previous valid GPS.
|
// No GPS frame should clear telemetry GPS but keep the last known
|
||||||
|
// contact location as display fallback.
|
||||||
final batteryOnly = CayenneLppParser.createBatteryData(3.8);
|
final batteryOnly = CayenneLppParser.createBatteryData(3.8);
|
||||||
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), batteryOnly);
|
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), batteryOnly);
|
||||||
|
|
||||||
var updated = scopedProvider.findContactByKey(scopedKey)!;
|
var updated = scopedProvider.findContactByKey(scopedKey)!;
|
||||||
expect(updated.telemetry, isNotNull);
|
expect(updated.telemetry, isNotNull);
|
||||||
expect(updated.telemetry!.gpsLocation, isNotNull);
|
expect(updated.telemetry!.gpsLocation, isNull);
|
||||||
expect(
|
expect(
|
||||||
updated.telemetry!.gpsLocation!.latitude,
|
updated.displayLocation!.latitude,
|
||||||
closeTo(45.1234, 0.0001),
|
closeTo(45.1234, 0.0001),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
updated.telemetry!.gpsLocation!.longitude,
|
updated.displayLocation!.longitude,
|
||||||
closeTo(13.8765, 0.0001),
|
closeTo(13.8765, 0.0001),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Invalid 0,0 GPS frame should also keep previous valid GPS.
|
// Invalid 0,0 GPS frame should behave the same way.
|
||||||
final invalidGps = CayenneLppParser.createGpsData(
|
final invalidGps = CayenneLppParser.createGpsData(
|
||||||
latitude: 0.0,
|
latitude: 0.0,
|
||||||
longitude: 0.0,
|
longitude: 0.0,
|
||||||
@@ -190,13 +191,13 @@ void main() {
|
|||||||
|
|
||||||
updated = scopedProvider.findContactByKey(scopedKey)!;
|
updated = scopedProvider.findContactByKey(scopedKey)!;
|
||||||
expect(updated.telemetry, isNotNull);
|
expect(updated.telemetry, isNotNull);
|
||||||
expect(updated.telemetry!.gpsLocation, isNotNull);
|
expect(updated.telemetry!.gpsLocation, isNull);
|
||||||
expect(
|
expect(
|
||||||
updated.telemetry!.gpsLocation!.latitude,
|
updated.displayLocation!.latitude,
|
||||||
closeTo(45.1234, 0.0001),
|
closeTo(45.1234, 0.0001),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
updated.telemetry!.gpsLocation!.longitude,
|
updated.displayLocation!.longitude,
|
||||||
closeTo(13.8765, 0.0001),
|
closeTo(13.8765, 0.0001),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -336,7 +337,7 @@ void main() {
|
|||||||
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
|
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('retains prior telemetry fields across sparse telemetry updates', () {
|
test('retains scalar telemetry but wipes stale extra sensor fields on refresh', () {
|
||||||
final fullTelemetry = ContactTelemetry(
|
final fullTelemetry = ContactTelemetry(
|
||||||
gpsLocation: const LatLng(46.0569, 14.5058),
|
gpsLocation: const LatLng(46.0569, 14.5058),
|
||||||
batteryPercentage: 54.0,
|
batteryPercentage: 54.0,
|
||||||
@@ -360,13 +361,70 @@ void main() {
|
|||||||
|
|
||||||
final updated = provider.findContactByKey(publicKey)!;
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
expect(updated.telemetry, isNotNull);
|
expect(updated.telemetry, isNotNull);
|
||||||
expect(updated.telemetry!.gpsLocation, const LatLng(46.0569, 14.5058));
|
expect(updated.telemetry!.gpsLocation, isNull);
|
||||||
|
expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
|
||||||
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
||||||
expect(updated.telemetry!.batteryPercentage, isNotNull);
|
expect(updated.telemetry!.batteryPercentage, isNotNull);
|
||||||
expect(updated.telemetry!.temperature, equals(19.5));
|
expect(updated.telemetry!.temperature, equals(19.5));
|
||||||
expect(updated.telemetry!.humidity, equals(58.0));
|
expect(updated.telemetry!.humidity, equals(58.0));
|
||||||
expect(updated.telemetry!.pressure, equals(1011.2));
|
expect(updated.telemetry!.pressure, equals(1011.2));
|
||||||
expect(updated.telemetry!.extraSensorData, containsPair('pm25', 8.0));
|
expect(
|
||||||
|
updated.telemetry!.extraSensorData,
|
||||||
|
containsPair('__source_channel:battery', 0),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.extraSensorData,
|
||||||
|
containsPair('__source_channel:voltage', 0),
|
||||||
|
);
|
||||||
|
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces old source-channel mappings when a metric moves channels', () {
|
||||||
|
final initialTelemetry = ContactTelemetry(
|
||||||
|
gpsLocation: null,
|
||||||
|
batteryPercentage: null,
|
||||||
|
batteryMilliVolts: null,
|
||||||
|
temperature: 21.5,
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
|
||||||
|
humidity: null,
|
||||||
|
pressure: null,
|
||||||
|
extraSensorData: const {
|
||||||
|
'__source_channel:temperature': 2,
|
||||||
|
'temperature_2': 21.5,
|
||||||
|
'humidity_4': 66.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
).copyWith(telemetry: initialTelemetry),
|
||||||
|
);
|
||||||
|
|
||||||
|
final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
|
||||||
|
23.5,
|
||||||
|
channel: 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.updateTelemetry(publicKey.sublist(0, 6), movedChannelTelemetry);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.telemetry, isNotNull);
|
||||||
|
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.extraSensorData,
|
||||||
|
containsPair('__source_channel:temperature', 3),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.extraSensorData,
|
||||||
|
containsPair('temperature_3', closeTo(23.5, 0.1)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.extraSensorData,
|
||||||
|
isNot(contains('temperature_2')),
|
||||||
|
);
|
||||||
|
expect(updated.telemetry!.extraSensorData, isNot(contains('humidity_4')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('builds message snapshot from latest valid telemetry', () {
|
test('builds message snapshot from latest valid telemetry', () {
|
||||||
|
|||||||
@@ -75,6 +75,42 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact buildTelemetrySensorContact() {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x55;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'Weather Station',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
temperature: 11.8,
|
||||||
|
humidity: 51,
|
||||||
|
pressure: 921.9,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
extraSensorData: const {
|
||||||
|
'__source_channel:temperature': 3,
|
||||||
|
'__source_channel:humidity': 2,
|
||||||
|
'__source_channel:pressure': 2,
|
||||||
|
'illuminance_2': 19150.0,
|
||||||
|
'temperature_2': 1.99,
|
||||||
|
'voltage_2': 5.2,
|
||||||
|
'switch_2': 0,
|
||||||
|
'speed_2': 2.8,
|
||||||
|
'speed_3': 3.7,
|
||||||
|
'uv_2': 1.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test('metric label overrides persist across reloads', () async {
|
test('metric label overrides persist across reloads', () async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final contact = buildSensorContact();
|
final contact = buildSensorContact();
|
||||||
@@ -148,16 +184,31 @@ void main() {
|
|||||||
final provider = SensorsProvider();
|
final provider = SensorsProvider();
|
||||||
await waitUntilLoaded(provider);
|
await waitUntilLoaded(provider);
|
||||||
await provider.addSensor(contact);
|
await provider.addSensor(contact);
|
||||||
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5);
|
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1440);
|
||||||
|
|
||||||
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5);
|
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
|
||||||
|
|
||||||
final reloadedProvider = SensorsProvider();
|
final reloadedProvider = SensorsProvider();
|
||||||
await waitUntilLoaded(reloadedProvider);
|
await waitUntilLoaded(reloadedProvider);
|
||||||
|
|
||||||
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 5);
|
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'unsupported auto refresh minutes normalize to nearest option',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final contact = buildSensorContact();
|
||||||
|
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
await provider.addSensor(contact);
|
||||||
|
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1);
|
||||||
|
|
||||||
|
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('refreshDueSensors respects per-contact interval', () async {
|
test('refreshDueSensors respects per-contact interval', () async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final contact = buildSensorContact();
|
final contact = buildSensorContact();
|
||||||
@@ -191,4 +242,32 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(connectionProvider.pingCalls, 2);
|
expect(connectionProvider.pingCalls, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'addSensor includes available extra telemetry fields by default',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final contact = buildTelemetrySensorContact();
|
||||||
|
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
await provider.addSensor(contact);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.visibleFieldsFor(contact.publicKeyHex),
|
||||||
|
containsAll(<String>{
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'extra:illuminance_2',
|
||||||
|
'extra:temperature_2',
|
||||||
|
'extra:voltage_2',
|
||||||
|
'extra:switch_2',
|
||||||
|
'extra:speed_2',
|
||||||
|
'extra:speed_3',
|
||||||
|
'extra:uv_2',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('customize sheet shows metric value previews and channels', (
|
testWidgets('customize action opens full customization view', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final contact = buildSensorContact();
|
final contact = buildSensorContact();
|
||||||
final sensorsProvider = SensorsProvider();
|
final sensorsProvider = SensorsProvider();
|
||||||
final contactsProvider = ContactsProvider();
|
final contactsProvider = ContactsProvider();
|
||||||
@@ -61,40 +59,39 @@ void main() {
|
|||||||
contactsProvider.addOrUpdateContact(contact);
|
contactsProvider.addOrUpdateContact(contact);
|
||||||
await sensorsProvider.addSensor(contact);
|
await sensorsProvider.addSensor(contact);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider<ContactsProvider>.value(
|
ChangeNotifierProvider<ContactsProvider>.value(
|
||||||
value: contactsProvider,
|
value: contactsProvider,
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider),
|
ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
home: const SensorsTab(),
|
home: const SensorsTab(),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
await tester.pump();
|
);
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.more_vert));
|
await tester.tap(find.byIcon(Icons.more_vert));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
await tester.tap(find.text('Customize fields'));
|
await tester.tap(find.text('Customize fields'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(find.text('Customize WX Station'), findsOneWidget);
|
||||||
|
expect(find.text('Live preview'), findsOneWidget);
|
||||||
|
expect(find.text('Refresh schedule'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
find.byKey(const ValueKey('sensor_selector_value_extra:illuminance_2')),
|
find.byKey(
|
||||||
|
const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'),
|
||||||
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('500 lx'), findsOneWidget);
|
expect(find.text('Channel 2'), findsOneWidget);
|
||||||
expect(
|
|
||||||
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
expect(find.text('ch2'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,6 +373,9 @@ void main() {
|
|||||||
const lppDirection = 132;
|
const lppDirection = 132;
|
||||||
const lppUnixTime = 133;
|
const lppUnixTime = 133;
|
||||||
const lppColour = 135;
|
const lppColour = 135;
|
||||||
|
const lppGust = 137;
|
||||||
|
const lppDewPoint = 138;
|
||||||
|
const lppRain = 139;
|
||||||
const lppSwitch = 142;
|
const lppSwitch = 142;
|
||||||
|
|
||||||
final payload = Uint8List.fromList([
|
final payload = Uint8List.fromList([
|
||||||
@@ -388,6 +391,9 @@ void main() {
|
|||||||
11, lppDirection, 0x01, 0x0E, // 270 deg
|
11, lppDirection, 0x01, 0x0E, // 270 deg
|
||||||
12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312
|
12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312
|
||||||
13, lppColour, 0xFF, 0x80, 0x40, // #FF8040
|
13, lppColour, 0xFF, 0x80, 0x40, // #FF8040
|
||||||
|
15, lppGust, 0x01, 0x72, // 3.70 m/s
|
||||||
|
16, lppDewPoint, 0x00, 0x14, // 2.0 C
|
||||||
|
17, lppRain, 0x00, 0x7B, // 12.3 mm
|
||||||
14, lppSwitch, 0x01, // on
|
14, lppSwitch, 0x01, // on
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -409,9 +415,59 @@ void main() {
|
|||||||
decoded.extraSensorData!['colour_13'],
|
decoded.extraSensorData!['colour_13'],
|
||||||
equals({'r': 255, 'g': 128, 'b': 64}),
|
equals({'r': 255, 'g': 128, 'b': 64}),
|
||||||
);
|
);
|
||||||
|
expect(decoded.extraSensorData!['gust_15'], closeTo(3.7, 0.001));
|
||||||
|
expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001));
|
||||||
|
expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001));
|
||||||
expect(decoded.extraSensorData!['switch_14'], equals(1));
|
expect(decoded.extraSensorData!['switch_14'], equals(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('new MeshCore custom telemetry types decode without truncation', () {
|
||||||
|
const lppBinaryMoisture = 157;
|
||||||
|
const lppButtonEvent = 171;
|
||||||
|
const lppDimmer = 172;
|
||||||
|
const lppUv = 173;
|
||||||
|
const lppPm25 = 175;
|
||||||
|
const lppConductivity = 180;
|
||||||
|
const lppDuration = 182;
|
||||||
|
const lppSignedSpeed = 191;
|
||||||
|
const lppSignedPower = 192;
|
||||||
|
const lppSignedCurrent = 193;
|
||||||
|
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
2, lppBinaryMoisture, 0x01,
|
||||||
|
3, lppButtonEvent, 0x04,
|
||||||
|
4, lppDimmer, 0xFB, // -5
|
||||||
|
5, lppUv, 0x0A, // 1.0
|
||||||
|
6, lppPm25, 0x00, 0x0C, // 12
|
||||||
|
7, lppConductivity, 0x01, 0xF4, // 500
|
||||||
|
8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s
|
||||||
|
9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s
|
||||||
|
10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W
|
||||||
|
11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A
|
||||||
|
12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C
|
||||||
|
]);
|
||||||
|
|
||||||
|
final decoded = CayenneLppParser.parse(payload);
|
||||||
|
|
||||||
|
expect(decoded.extraSensorData!['binary_moisture_2'], equals(1));
|
||||||
|
expect(decoded.extraSensorData!['button_event_3'], equals(4));
|
||||||
|
expect(decoded.extraSensorData!['dimmer_4'], equals(-5));
|
||||||
|
expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001));
|
||||||
|
expect(decoded.extraSensorData!['pm25_6'], equals(12.0));
|
||||||
|
expect(decoded.extraSensorData!['conductivity_7'], equals(500.0));
|
||||||
|
expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001));
|
||||||
|
expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001));
|
||||||
|
expect(
|
||||||
|
decoded.extraSensorData!['signed_power_10'],
|
||||||
|
closeTo(-10.0, 0.001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
decoded.extraSensorData!['signed_current_11'],
|
||||||
|
closeTo(-1.0, 0.001),
|
||||||
|
);
|
||||||
|
expect(decoded.temperature, closeTo(11.8, 0.1));
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'percentage battery and non-battery voltage channels are preserved separately',
|
'percentage battery and non-battery voltage channels are preserved separately',
|
||||||
() {
|
() {
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ void main() {
|
|||||||
await tester.tap(find.text('Preview'));
|
await tester.tap(find.text('Preview'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||||
expect(find.text('Battery'), findsOneWidget);
|
expect(find.text('Battery'), findsOneWidget);
|
||||||
expect(find.text('84%'), findsOneWidget);
|
expect(find.text('84%'), findsOneWidget);
|
||||||
expect(find.text('Temperature'), findsOneWidget);
|
expect(find.text('Temperature'), findsOneWidget);
|
||||||
@@ -172,10 +173,7 @@ void main() {
|
|||||||
find.byKey(const ValueKey('sensor_metric_channel_battery')),
|
find.byKey(const ValueKey('sensor_metric_channel_battery')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expect(find.text('ch1'), findsWidgets);
|
||||||
find.text('ch1'),
|
|
||||||
findsWidgets,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')),
|
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@@ -186,5 +184,10 @@ void main() {
|
|||||||
find.byKey(const ValueKey('sensor_metric_battery')),
|
find.byKey(const ValueKey('sensor_metric_battery')),
|
||||||
);
|
);
|
||||||
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
|
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(SensorTelemetryCard), findsNothing);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
38
test/widgets/sensor_auto_refresh_options_test.dart
Normal file
38
test/widgets/sensor_auto_refresh_options_test.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('renders expanded auto refresh intervals and selects value', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var selected = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorAutoRefreshOptions(
|
||||||
|
selectedMinutes: selected,
|
||||||
|
onSelected: (minutes) {
|
||||||
|
selected = minutes;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Off'), findsOneWidget);
|
||||||
|
expect(find.text('5m'), findsOneWidget);
|
||||||
|
expect(find.text('15m'), findsOneWidget);
|
||||||
|
expect(find.text('30m'), findsOneWidget);
|
||||||
|
expect(find.text('60m'), findsOneWidget);
|
||||||
|
expect(find.text('6h'), findsOneWidget);
|
||||||
|
expect(find.text('12h'), findsOneWidget);
|
||||||
|
expect(find.text('24h'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('24h'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(selected, 1440);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ void main() {
|
|||||||
defaultLabel: 'Illuminance',
|
defaultLabel: 'Illuminance',
|
||||||
channel: 2,
|
channel: 2,
|
||||||
valuePreview: '500 lx',
|
valuePreview: '500 lx',
|
||||||
|
previewCardData: SensorMetricCardData(
|
||||||
|
fieldKey: 'extra:illuminance_2',
|
||||||
|
icon: Icons.light_mode_outlined,
|
||||||
|
label: 'Illuminance',
|
||||||
|
value: '500 lx',
|
||||||
|
accent: Color(0xFFC17B1D),
|
||||||
|
channel: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
visible: true,
|
visible: true,
|
||||||
span: 1,
|
span: 1,
|
||||||
@@ -30,11 +38,54 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(find.text('Show on sensor card'), findsOneWidget);
|
||||||
expect(find.text('500 lx'), findsOneWidget);
|
expect(find.text('500 lx'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
|
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('ch2'), findsOneWidget);
|
expect(find.text('Channel 2'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('omits duplicate channel chip for channel 1', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorMetricSelectorItem(
|
||||||
|
option: const SensorMetricOption(
|
||||||
|
key: 'battery',
|
||||||
|
label: 'Battery',
|
||||||
|
defaultLabel: 'Battery',
|
||||||
|
channel: 1,
|
||||||
|
valuePreview: '84%',
|
||||||
|
previewCardData: SensorMetricCardData(
|
||||||
|
fieldKey: 'battery',
|
||||||
|
icon: Icons.battery_5_bar,
|
||||||
|
label: 'Battery',
|
||||||
|
value: '84%',
|
||||||
|
accent: Color(0xFF4B8E2F),
|
||||||
|
channel: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
visible: true,
|
||||||
|
span: 1,
|
||||||
|
canMoveUp: true,
|
||||||
|
canMoveDown: true,
|
||||||
|
onToggle: (_) {},
|
||||||
|
onRename: () {},
|
||||||
|
onMoveUp: () {},
|
||||||
|
onMoveDown: () {},
|
||||||
|
onSpanChanged: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Rename'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_selector_channel_battery')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(find.text('Channel 1'), findsNothing);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ void main() {
|
|||||||
'temperature': 'Ambient',
|
'temperature': 'Ambient',
|
||||||
'extra:illuminance_2': 'Light',
|
'extra:illuminance_2': 'Light',
|
||||||
},
|
},
|
||||||
fieldSpans: sensorFullWidthFieldSpans(
|
fieldSpans: sensorFullWidthFieldSpans(const {
|
||||||
const {'temperature', 'extra:illuminance_2'},
|
'temperature',
|
||||||
),
|
'extra:illuminance_2',
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -111,18 +112,12 @@ void main() {
|
|||||||
'temperature',
|
'temperature',
|
||||||
'extra:illuminance_2',
|
'extra:illuminance_2',
|
||||||
},
|
},
|
||||||
fieldOrder: const [
|
fieldOrder: const ['extra:illuminance_2', 'temperature', 'battery'],
|
||||||
'extra:illuminance_2',
|
fieldSpans: sensorFullWidthFieldSpans(const {
|
||||||
'temperature',
|
|
||||||
'battery',
|
'battery',
|
||||||
],
|
'temperature',
|
||||||
fieldSpans: sensorFullWidthFieldSpans(
|
'extra:illuminance_2',
|
||||||
const {
|
}),
|
||||||
'battery',
|
|
||||||
'temperature',
|
|
||||||
'extra:illuminance_2',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -141,4 +136,55 @@ void main() {
|
|||||||
expect(illuminanceTop.dy, lessThan(temperatureTop.dy));
|
expect(illuminanceTop.dy, lessThan(temperatureTop.dy));
|
||||||
expect(temperatureTop.dy, lessThan(batteryTop.dy));
|
expect(temperatureTop.dy, lessThan(batteryTop.dy));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('renders MeshCore custom weather metrics', (tester) async {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x46;
|
||||||
|
final contact = Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'WX Station',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
extraSensorData: const {'gust_2': 3.7, 'dew_2': 2.0, 'rain_2': 12.3},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: SensorRefreshState.idle,
|
||||||
|
visibleFields: const {
|
||||||
|
'extra:gust_2',
|
||||||
|
'extra:dew_2',
|
||||||
|
'extra:rain_2',
|
||||||
|
},
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(const {
|
||||||
|
'extra:gust_2',
|
||||||
|
'extra:dew_2',
|
||||||
|
'extra:rain_2',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Wind gust'), findsOneWidget);
|
||||||
|
expect(find.text('Dew point'), findsOneWidget);
|
||||||
|
expect(find.text('Rain'), findsOneWidget);
|
||||||
|
expect(find.text('3.7 m/s'), findsOneWidget);
|
||||||
|
expect(find.text('2°C'), findsOneWidget);
|
||||||
|
expect(find.text('12.3 mm'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user