mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
3 Commits
v2026.0321
...
v2026.0322
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24e699e22 | ||
|
|
7486837ad0 | ||
|
|
059125c714 |
@@ -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 = 123;
|
CURRENT_PROJECT_VERSION = 124;
|
||||||
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 = 123;
|
CURRENT_PROJECT_VERSION = 124;
|
||||||
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 = 123;
|
CURRENT_PROJECT_VERSION = 124;
|
||||||
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 = 123;
|
CURRENT_PROJECT_VERSION = 124;
|
||||||
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 = 123;
|
CURRENT_PROJECT_VERSION = 124;
|
||||||
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 = 123;
|
CURRENT_PROJECT_VERSION = 124;
|
||||||
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>123</string>
|
<string>124</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.000217">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.503497">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.144858">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="103.12875">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="98.142592">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="58490.472877">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="157.12775">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -127,11 +127,13 @@ class _RetainedRoute {
|
|||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
static const double _firstHopFallbackOffsetMeters = 100.0;
|
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||||||
static const String autoGroupIdPrefix = 'auto_group_';
|
static const String autoGroupIdPrefix = 'auto_group_';
|
||||||
|
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final Map<String, LatLng> _estimatedLocations = {};
|
final Map<String, LatLng> _estimatedLocations = {};
|
||||||
final Map<String, List<RssiObservation>> _rssiObservations = {};
|
final Map<String, List<RssiObservation>> _rssiObservations = {};
|
||||||
|
final Map<String, Contact> _retainedContactsForSync = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _isPersisting = false;
|
bool _isPersisting = false;
|
||||||
@@ -279,7 +281,6 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
/// empty list while sync is in progress.
|
/// empty list while sync is in progress.
|
||||||
Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async {
|
Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async {
|
||||||
_setSelfDevicePublicKey(devicePublicKey);
|
_setSelfDevicePublicKey(devicePublicKey);
|
||||||
_selfTelemetry = null;
|
|
||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
final storedGroups = await _storageService.loadContactGroups(
|
final storedGroups = await _storageService.loadContactGroups(
|
||||||
namespace: _storageNamespace,
|
namespace: _storageNamespace,
|
||||||
@@ -293,6 +294,13 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🧹 [ContactsProvider] Clearing runtime contacts before device sync',
|
'🧹 [ContactsProvider] Clearing runtime contacts before device sync',
|
||||||
);
|
);
|
||||||
|
_retainedContactsForSync
|
||||||
|
..clear()
|
||||||
|
..addEntries(
|
||||||
|
_contactsForStorage().map(
|
||||||
|
(contact) => MapEntry(contact.publicKeyHex, contact),
|
||||||
|
),
|
||||||
|
);
|
||||||
_contacts.clear();
|
_contacts.clear();
|
||||||
_pendingAdverts.clear();
|
_pendingAdverts.clear();
|
||||||
_estimatedLocations.clear();
|
_estimatedLocations.clear();
|
||||||
@@ -676,7 +684,9 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a new contact
|
// Check if this is a new contact
|
||||||
final existingContact = _contacts[contact.publicKeyHex];
|
final existingContact =
|
||||||
|
_contacts[contact.publicKeyHex] ??
|
||||||
|
_retainedContactsForSync[contact.publicKeyHex];
|
||||||
final isNewContact = existingContact == null;
|
final isNewContact = existingContact == null;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
||||||
@@ -688,6 +698,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
|
_retainedContactsForSync.remove(contact.publicKeyHex);
|
||||||
// Keep repeaters and sensors in pending adverts so they remain visible
|
// Keep repeaters and sensors in pending adverts so they remain visible
|
||||||
// in the discovery list (with a checkmark). Remove others.
|
// in the discovery list (with a checkmark). Remove others.
|
||||||
if (contact.type != ContactType.repeater &&
|
if (contact.type != ContactType.repeater &&
|
||||||
@@ -717,11 +728,14 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
excluded++;
|
excluded++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final existingContact = _contacts[contact.publicKeyHex];
|
final existingContact =
|
||||||
|
_contacts[contact.publicKeyHex] ??
|
||||||
|
_retainedContactsForSync[contact.publicKeyHex];
|
||||||
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
|
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
|
||||||
incomingContact: contact,
|
incomingContact: contact,
|
||||||
existingContact: existingContact,
|
existingContact: existingContact,
|
||||||
);
|
);
|
||||||
|
_retainedContactsForSync.remove(contact.publicKeyHex);
|
||||||
if (contact.type != ContactType.repeater &&
|
if (contact.type != ContactType.repeater &&
|
||||||
contact.type != ContactType.sensor) {
|
contact.type != ContactType.sensor) {
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
@@ -1012,7 +1026,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse Cayenne LPP data
|
// Parse Cayenne LPP data
|
||||||
var telemetry = CayenneLppParser.parse(lppData);
|
var telemetry = _withRawTelemetryHex(
|
||||||
|
CayenneLppParser.parse(lppData),
|
||||||
|
lppData,
|
||||||
|
);
|
||||||
debugPrint(' ✅ Parsed new telemetry');
|
debugPrint(' ✅ Parsed new telemetry');
|
||||||
debugPrint(' New telemetry timestamp: ${telemetry.timestamp}');
|
debugPrint(' New telemetry timestamp: ${telemetry.timestamp}');
|
||||||
|
|
||||||
@@ -1056,7 +1073,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
timestamp: mergedTelemetry.timestamp,
|
timestamp: mergedTelemetry.timestamp,
|
||||||
humidity: mergedTelemetry.humidity,
|
humidity: mergedTelemetry.humidity,
|
||||||
pressure: mergedTelemetry.pressure,
|
pressure: mergedTelemetry.pressure,
|
||||||
extraSensorData: telemetry.extraSensorData,
|
extraSensorData: mergedTelemetry.extraSensorData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,6 +1222,28 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ContactTelemetry _withRawTelemetryHex(
|
||||||
|
ContactTelemetry telemetry,
|
||||||
|
Uint8List lppData,
|
||||||
|
) {
|
||||||
|
final extraSensorData = <String, dynamic>{...?telemetry.extraSensorData};
|
||||||
|
extraSensorData[_rawTelemetryHexKey] = _bytesToHex(lppData);
|
||||||
|
return ContactTelemetry(
|
||||||
|
gpsLocation: telemetry.gpsLocation,
|
||||||
|
batteryPercentage: telemetry.batteryPercentage,
|
||||||
|
batteryMilliVolts: telemetry.batteryMilliVolts,
|
||||||
|
temperature: telemetry.temperature,
|
||||||
|
timestamp: telemetry.timestamp,
|
||||||
|
humidity: telemetry.humidity,
|
||||||
|
pressure: telemetry.pressure,
|
||||||
|
extraSensorData: extraSensorData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _bytesToHex(Uint8List bytes) {
|
||||||
|
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _mergeExtraSensorData(
|
Map<String, dynamic> _mergeExtraSensorData(
|
||||||
Map<String, dynamic>? existingExtraSensorData,
|
Map<String, dynamic>? existingExtraSensorData,
|
||||||
Map<String, dynamic>? incomingExtraSensorData,
|
Map<String, dynamic>? incomingExtraSensorData,
|
||||||
@@ -1621,6 +1660,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
void clearContacts() {
|
void clearContacts() {
|
||||||
_contacts.clear();
|
_contacts.clear();
|
||||||
_pendingAdverts.clear();
|
_pendingAdverts.clear();
|
||||||
|
_retainedContactsForSync.clear();
|
||||||
_ensurePublicChannelExists();
|
_ensurePublicChannelExists();
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
_persistPendingAdverts();
|
_persistPendingAdverts();
|
||||||
@@ -1645,6 +1685,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
// Then remove from local storage
|
// Then remove from local storage
|
||||||
_contacts.remove(publicKeyHex);
|
_contacts.remove(publicKeyHex);
|
||||||
_pendingAdverts.remove(publicKeyHex);
|
_pendingAdverts.remove(publicKeyHex);
|
||||||
|
_retainedContactsForSync.remove(publicKeyHex);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
_persistPendingAdverts();
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1729,6 +1770,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_pendingAdverts.clear();
|
_pendingAdverts.clear();
|
||||||
_estimatedLocations.clear();
|
_estimatedLocations.clear();
|
||||||
_rssiObservations.clear();
|
_rssiObservations.clear();
|
||||||
|
_retainedContactsForSync.clear();
|
||||||
_selfTelemetry = null;
|
_selfTelemetry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
|||||||
|
|
||||||
class SensorsProvider with ChangeNotifier {
|
class SensorsProvider with ChangeNotifier {
|
||||||
static const Duration _successStateRetention = Duration(minutes: 1);
|
static const Duration _successStateRetention = Duration(minutes: 1);
|
||||||
|
static const int selfAutoRefreshMinutes = 1;
|
||||||
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||||
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||||
static const String _fieldSpanKey = 'sensor_field_spans';
|
static const String _fieldSpanKey = 'sensor_field_spans';
|
||||||
@@ -373,6 +374,20 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
return List<String>.unmodifiable(dueKeys);
|
return List<String>.unmodifiable(dueKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isRefreshDue(
|
||||||
|
String publicKeyHex, {
|
||||||
|
required int minutes,
|
||||||
|
required DateTime refreshTime,
|
||||||
|
}) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex];
|
||||||
|
return lastRefreshAt == null ||
|
||||||
|
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggleMetric(
|
Future<void> toggleMetric(
|
||||||
String publicKeyHex,
|
String publicKeyHex,
|
||||||
String fieldKey,
|
String fieldKey,
|
||||||
@@ -636,6 +651,17 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
return <String>[self.publicKeyHex];
|
return <String>[self.publicKeyHex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? displaySelfKey({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) {
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
if (self == null || _watchedSensorKeys.contains(self.publicKeyHex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self.publicKeyHex;
|
||||||
|
}
|
||||||
|
|
||||||
Contact? contactForDisplay(
|
Contact? contactForDisplay(
|
||||||
String publicKeyHex, {
|
String publicKeyHex, {
|
||||||
required ContactsProvider contactsProvider,
|
required ContactsProvider contactsProvider,
|
||||||
@@ -659,7 +685,18 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
required ContactsProvider contactsProvider,
|
required ContactsProvider contactsProvider,
|
||||||
required ConnectionProvider connectionProvider,
|
required ConnectionProvider connectionProvider,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
|
if (_isRefreshingAll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
final keysToRefresh = <String>[
|
||||||
|
if (self != null) self.publicKeyHex,
|
||||||
|
..._watchedSensorKeys.where(
|
||||||
|
(key) => self == null || key != self.publicKeyHex,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (keysToRefresh.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,7 +704,7 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in keysToRefresh) {
|
||||||
await refreshSensor(
|
await refreshSensor(
|
||||||
publicKeyHex: key,
|
publicKeyHex: key,
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
@@ -728,7 +765,17 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final refreshTime = now ?? DateTime.now();
|
final refreshTime = now ?? DateTime.now();
|
||||||
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime);
|
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime).toList();
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
if (self != null &&
|
||||||
|
!dueKeys.contains(self.publicKeyHex) &&
|
||||||
|
_isRefreshDue(
|
||||||
|
self.publicKeyHex,
|
||||||
|
minutes: selfAutoRefreshMinutes,
|
||||||
|
refreshTime: refreshTime,
|
||||||
|
)) {
|
||||||
|
dueKeys.insert(0, self.publicKeyHex);
|
||||||
|
}
|
||||||
if (dueKeys.isEmpty) {
|
if (dueKeys.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,61 +519,58 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
final canExportHashChannelPsk = Channel.isHashChannelName(
|
final canExportHashChannelPsk = Channel.isHashChannelName(
|
||||||
channel.advName.trim(),
|
channel.advName.trim(),
|
||||||
);
|
);
|
||||||
|
final actions = <_ChannelSheetAction>[
|
||||||
showModalBottomSheet(
|
_ChannelSheetAction(
|
||||||
context: context,
|
icon: Icons.message_outlined,
|
||||||
shape: const RoundedRectangleBorder(
|
label: l10n.messages,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _openMessagesForChannel(context, channel);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
builder: (sheetContext) => SafeArea(
|
if (channel.displayLocation != null)
|
||||||
child: Column(
|
_ChannelSheetAction(
|
||||||
mainAxisSize: MainAxisSize.min,
|
icon: Icons.map_outlined,
|
||||||
children: [
|
label: l10n.viewOnMap,
|
||||||
ListTile(
|
onTap: () async {
|
||||||
leading: Icon(Icons.message_outlined),
|
Navigator.pop(context);
|
||||||
title: Text(l10n.messages),
|
_showChannelOnMap(context, channel);
|
||||||
onTap: () async {
|
},
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
await _openMessagesForChannel(context, channel);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (channel.displayLocation != null)
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.map_outlined),
|
|
||||||
title: Text(l10n.viewOnMap),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
_showChannelOnMap(context, channel);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (canExportHashChannelPsk)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.key_outlined),
|
|
||||||
title: Text('${l10n.exportToClipboard} psk_base64'),
|
|
||||||
onTap: () async {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
await _exportHashChannelPskBase64(context, channel);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!channel.isPublicChannel)
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.delete, color: Colors.red),
|
|
||||||
title: Text(
|
|
||||||
l10n.deleteChannel,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
_showDeleteChannelDialog(context, channel);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
if (canExportHashChannelPsk)
|
||||||
|
_ChannelSheetAction(
|
||||||
|
icon: Icons.key_outlined,
|
||||||
|
label: '${l10n.exportToClipboard} psk_base64',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _exportHashChannelPskBase64(context, channel);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!channel.isPublicChannel)
|
||||||
|
_ChannelSheetAction(
|
||||||
|
icon: Icons.delete_outline_rounded,
|
||||||
|
label: l10n.deleteChannel,
|
||||||
|
destructive: true,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
_showDeleteChannelDialog(context, channel);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) => _ChannelActionSheet(
|
||||||
|
channel: channel,
|
||||||
|
actions: actions,
|
||||||
|
onClose: () => Navigator.pop(sheetContext),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1980,6 +1977,302 @@ class _ExpandableParticipantStackState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ChannelSheetAction {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final Future<void> Function() onTap;
|
||||||
|
final bool destructive;
|
||||||
|
|
||||||
|
const _ChannelSheetAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.destructive = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelActionSheet extends StatelessWidget {
|
||||||
|
final Contact channel;
|
||||||
|
final List<_ChannelSheetAction> actions;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const _ChannelActionSheet({
|
||||||
|
required this.channel,
|
||||||
|
required this.actions,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final title = channel.getLocalizedDisplayName(context);
|
||||||
|
final subtitle = channel.isPublicChannel
|
||||||
|
? l10n.broadcastToAllNearby
|
||||||
|
: '${l10n.channel} ${channel.publicKey[1]}';
|
||||||
|
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.12),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, -4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ContactAvatar(
|
||||||
|
contact: channel,
|
||||||
|
radius: 28,
|
||||||
|
displayName: title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: -0.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_ChannelSheetChip(
|
||||||
|
icon: channel.isPublicChannel
|
||||||
|
? Icons.broadcast_on_personal_rounded
|
||||||
|
: Icons.tag_rounded,
|
||||||
|
label: channel.isPublicChannel
|
||||||
|
? l10n.broadcastToAllNearby
|
||||||
|
: channel.publicKeyShort,
|
||||||
|
monospace: !channel.isPublicChannel,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: onClose,
|
||||||
|
tooltip: l10n.close,
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (actions.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
const spacing = 12.0;
|
||||||
|
const minTileWidth = 132.0;
|
||||||
|
final actionCount = actions.length;
|
||||||
|
final maxColumnsByWidth =
|
||||||
|
((constraints.maxWidth + spacing) /
|
||||||
|
(minTileWidth + spacing))
|
||||||
|
.floor()
|
||||||
|
.clamp(1, 3);
|
||||||
|
var columnCount = actionCount.clamp(1, maxColumnsByWidth);
|
||||||
|
if (actionCount > 3 &&
|
||||||
|
columnCount > 2 &&
|
||||||
|
actionCount % columnCount == 1) {
|
||||||
|
columnCount -= 1;
|
||||||
|
}
|
||||||
|
final itemWidth =
|
||||||
|
(constraints.maxWidth - (spacing * (columnCount - 1))) /
|
||||||
|
columnCount;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
|
children: [
|
||||||
|
for (final action in actions)
|
||||||
|
SizedBox(
|
||||||
|
width: itemWidth,
|
||||||
|
child: _ChannelPrimaryActionButton(action: action),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelPrimaryActionButton extends StatelessWidget {
|
||||||
|
final _ChannelSheetAction action;
|
||||||
|
|
||||||
|
const _ChannelPrimaryActionButton({required this.action});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final accent = action.destructive ? colorScheme.error : colorScheme.primary;
|
||||||
|
final backgroundColor = action.destructive
|
||||||
|
? colorScheme.errorContainer.withValues(alpha: 0.82)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
accent.withValues(alpha: 0.12),
|
||||||
|
colorScheme.surfaceContainerLow,
|
||||||
|
);
|
||||||
|
final foregroundColor = action.destructive
|
||||||
|
? colorScheme.onErrorContainer
|
||||||
|
: accent;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: action.onTap,
|
||||||
|
child: Ink(
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: action.destructive
|
||||||
|
? colorScheme.error.withValues(alpha: 0.18)
|
||||||
|
: accent.withValues(alpha: 0.14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: foregroundColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
),
|
||||||
|
child: Icon(action.icon, color: foregroundColor, size: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
action.label,
|
||||||
|
maxLines: 2,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: action.destructive
|
||||||
|
? colorScheme.onErrorContainer
|
||||||
|
: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelSheetChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool monospace;
|
||||||
|
|
||||||
|
const _ChannelSheetChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
this.monospace = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 260),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFamily: monospace ? 'monospace' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _OverflowAvatar extends StatelessWidget {
|
class _OverflowAvatar extends StatelessWidget {
|
||||||
final int count;
|
final int count;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../services/mesh_map_nodes_service.dart';
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
|
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
|
||||||
|
|
||||||
enum _DiscoveryMenuAction { repeaters, sensors }
|
enum _DiscoveryListFilter { all, repeaters, sensors, others }
|
||||||
|
|
||||||
class DiscoveryScreen extends StatefulWidget {
|
class DiscoveryScreen extends StatefulWidget {
|
||||||
const DiscoveryScreen({super.key});
|
const DiscoveryScreen({super.key});
|
||||||
@@ -23,7 +23,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
static const int _sensorAdvertType = 4;
|
static const int _sensorAdvertType = 4;
|
||||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||||
final Set<int> _runningDiscoveryTypes = <int>{};
|
final Set<int> _runningDiscoveryTypes = <int>{};
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
bool _isResolvingAll = false;
|
bool _isResolvingAll = false;
|
||||||
|
String _searchQuery = '';
|
||||||
|
_DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all;
|
||||||
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,15 +37,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
|
@override
|
||||||
switch (action) {
|
void dispose() {
|
||||||
case _DiscoveryMenuAction.repeaters:
|
_searchController.dispose();
|
||||||
await _discoverNodeType(_repeaterAdvertType);
|
super.dispose();
|
||||||
break;
|
|
||||||
case _DiscoveryMenuAction.sensors:
|
|
||||||
await _discoverNodeType(_sensorAdvertType);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearAllDiscoveries() async {
|
Future<void> _clearAllDiscoveries() async {
|
||||||
@@ -80,7 +78,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries)),
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +293,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _typeLabelForAdvert(PendingAdvert advert) {
|
String? _typeLabelForValue(int? typeValue) {
|
||||||
return switch (advert.typeValue) {
|
return switch (typeValue) {
|
||||||
_repeaterAdvertType => 'Repeater',
|
_repeaterAdvertType => 'Repeater',
|
||||||
_sensorAdvertType => 'Sensor',
|
_sensorAdvertType => 'Sensor',
|
||||||
3 => 'Room',
|
3 => 'Room',
|
||||||
@@ -303,22 +303,22 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _resolvedTypeLabelForAdvert(
|
int? _resolvedTypeValueForAdvert(
|
||||||
PendingAdvert advert,
|
PendingAdvert advert,
|
||||||
List<MeshMapNode> cachedNodes,
|
List<MeshMapNode> cachedNodes,
|
||||||
) {
|
) {
|
||||||
final directType = _typeLabelForAdvert(advert);
|
final directType = advert.typeValue;
|
||||||
if (directType != null) {
|
if (directType != null && directType != 0) {
|
||||||
return directType;
|
return directType;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final node in cachedNodes) {
|
for (final node in cachedNodes) {
|
||||||
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||||
return switch (node.type) {
|
return switch (node.type) {
|
||||||
1 => 'Repeater',
|
1 => _repeaterAdvertType,
|
||||||
4 => 'Sensor',
|
4 => _sensorAdvertType,
|
||||||
3 => 'Room',
|
3 => 3,
|
||||||
2 => 'Chat',
|
2 => 1,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -327,6 +327,81 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _resolvedTypeLabelForAdvert(
|
||||||
|
PendingAdvert advert,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
return _typeLabelForValue(_resolvedTypeValueForAdvert(advert, cachedNodes));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasActiveInlineFilter =>
|
||||||
|
_searchQuery.trim().isNotEmpty ||
|
||||||
|
_selectedFilter != _DiscoveryListFilter.all;
|
||||||
|
|
||||||
|
bool _matchesSelectedFilter(
|
||||||
|
PendingAdvert advert,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
if (_selectedFilter == _DiscoveryListFilter.all) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolvedType = _resolvedTypeValueForAdvert(advert, cachedNodes);
|
||||||
|
return switch (_selectedFilter) {
|
||||||
|
_DiscoveryListFilter.all => true,
|
||||||
|
_DiscoveryListFilter.repeaters => resolvedType == _repeaterAdvertType,
|
||||||
|
_DiscoveryListFilter.sensors => resolvedType == _sensorAdvertType,
|
||||||
|
_DiscoveryListFilter.others =>
|
||||||
|
resolvedType != _repeaterAdvertType &&
|
||||||
|
resolvedType != _sensorAdvertType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesSearchQuery(
|
||||||
|
PendingAdvert advert,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
final query = _searchQuery.trim().toLowerCase();
|
||||||
|
if (query.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayName = _displayNameForAdvert(
|
||||||
|
advert,
|
||||||
|
contactsProvider,
|
||||||
|
cachedNodes,
|
||||||
|
).toLowerCase();
|
||||||
|
final typeLabel = (_resolvedTypeLabelForAdvert(advert, cachedNodes) ?? '')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return displayName.contains(query) ||
|
||||||
|
advert.publicKeyHex.toLowerCase().contains(query) ||
|
||||||
|
advert.shortDisplayKey.toLowerCase().contains(query) ||
|
||||||
|
typeLabel.contains(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PendingAdvert> _filteredPendingAdverts(
|
||||||
|
List<PendingAdvert> adverts,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
return adverts
|
||||||
|
.where((advert) => _matchesSelectedFilter(advert, cachedNodes))
|
||||||
|
.where(
|
||||||
|
(advert) =>
|
||||||
|
_matchesSearchQuery(advert, contactsProvider, cachedNodes),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _summaryTitle({required int totalCount, required int filteredCount}) {
|
||||||
|
if (filteredCount == totalCount) {
|
||||||
|
return 'Discovered nodes ($totalCount)';
|
||||||
|
}
|
||||||
|
return 'Discovered nodes ($filteredCount/$totalCount)';
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildAdvertTitle(
|
Widget _buildAdvertTitle(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String displayName,
|
required String displayName,
|
||||||
@@ -362,193 +437,236 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(l10n.discovery)),
|
||||||
title: Text(l10n.discovery),
|
|
||||||
actions: [
|
|
||||||
Consumer<ConnectionProvider>(
|
|
||||||
builder: (context, connectionProvider, child) {
|
|
||||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
|
||||||
final repeatersBusy = _runningDiscoveryTypes.contains(
|
|
||||||
_repeaterAdvertType,
|
|
||||||
);
|
|
||||||
final sensorsBusy = _runningDiscoveryTypes.contains(
|
|
||||||
_sensorAdvertType,
|
|
||||||
);
|
|
||||||
|
|
||||||
return PopupMenuButton<_DiscoveryMenuAction>(
|
|
||||||
tooltip: 'Discovery tools',
|
|
||||||
onSelected: _handleMenuAction,
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem<_DiscoveryMenuAction>(
|
|
||||||
value: _DiscoveryMenuAction.repeaters,
|
|
||||||
enabled: isConnected && !repeatersBusy,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
repeatersBusy
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.router_outlined),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Text(l10n.discoverRepeaters),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuItem<_DiscoveryMenuAction>(
|
|
||||||
value: _DiscoveryMenuAction.sensors,
|
|
||||||
enabled: isConnected && !sensorsBusy,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
sensorsBusy
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.sensors_outlined),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Text(l10n.discoverSensors),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: FutureBuilder<List<MeshMapNode>>(
|
body: FutureBuilder<List<MeshMapNode>>(
|
||||||
future: _cachedNodesFuture,
|
future: _cachedNodesFuture,
|
||||||
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>(
|
builder: (context, nodesSnapshot) =>
|
||||||
builder: (context, contactsProvider, connectionProvider, child) {
|
Consumer2<ContactsProvider, ConnectionProvider>(
|
||||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
builder: (context, contactsProvider, connectionProvider, child) {
|
||||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
|
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||||
|
final repeatersBusy = _runningDiscoveryTypes.contains(
|
||||||
|
_repeaterAdvertType,
|
||||||
|
);
|
||||||
|
final sensorsBusy = _runningDiscoveryTypes.contains(
|
||||||
|
_sensorAdvertType,
|
||||||
|
);
|
||||||
|
|
||||||
// Track which pending adverts are already in the contacts list
|
// Track which pending adverts are already in the contacts list
|
||||||
final resolvedKeySet = <String>{};
|
final resolvedKeySet = <String>{};
|
||||||
for (final advert in pendingAdverts) {
|
for (final advert in pendingAdverts) {
|
||||||
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
|
if (contactsProvider.findContactByKey(advert.publicKey) !=
|
||||||
resolvedKeySet.add(advert.publicKeyHex);
|
null) {
|
||||||
}
|
resolvedKeySet.add(advert.publicKeyHex);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final totalCount = pendingAdverts.length;
|
final totalCount = pendingAdverts.length;
|
||||||
|
final filteredAdverts = _filteredPendingAdverts(
|
||||||
|
pendingAdverts,
|
||||||
|
contactsProvider,
|
||||||
|
cachedNodes,
|
||||||
|
);
|
||||||
|
final filteredCount = filteredAdverts.length;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.person_search),
|
Row(
|
||||||
const SizedBox(width: 12),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Expanded(
|
children: [
|
||||||
child: Text(
|
const Icon(Icons.person_search),
|
||||||
'Discovered nodes ($totalCount)',
|
const SizedBox(width: 12),
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_summaryTitle(
|
||||||
|
totalCount: totalCount,
|
||||||
|
filteredCount: filteredCount,
|
||||||
|
),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Resolve entries manually so they do not auto-populate contacts.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
if (totalCount > 0) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildInlineSearchField(context),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
context,
|
||||||
|
label: l10n.all,
|
||||||
|
filter: _DiscoveryListFilter.all,
|
||||||
|
),
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
context,
|
||||||
|
label: l10n.repeatersFilter,
|
||||||
|
filter: _DiscoveryListFilter.repeaters,
|
||||||
|
),
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
context,
|
||||||
|
label: l10n.sensors,
|
||||||
|
filter: _DiscoveryListFilter.sensors,
|
||||||
|
),
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
context,
|
||||||
|
label: l10n.others,
|
||||||
|
filter: _DiscoveryListFilter.others,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
const spacing = 10.0;
|
||||||
|
final useTwoColumns =
|
||||||
|
constraints.maxWidth >= 360;
|
||||||
|
final buttonWidth = useTwoColumns
|
||||||
|
? (constraints.maxWidth - spacing) / 2
|
||||||
|
: constraints.maxWidth;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
|
children: [
|
||||||
|
_buildHeaderActionButton(
|
||||||
|
width: buttonWidth,
|
||||||
|
onPressed: isConnected && !repeatersBusy
|
||||||
|
? () => _discoverNodeType(
|
||||||
|
_repeaterAdvertType,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
icon: repeatersBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.router_outlined),
|
||||||
|
label: Text(l10n.discoverRepeaters),
|
||||||
|
),
|
||||||
|
_buildHeaderActionButton(
|
||||||
|
width: buttonWidth,
|
||||||
|
onPressed: isConnected && !sensorsBusy
|
||||||
|
? () => _discoverNodeType(
|
||||||
|
_sensorAdvertType,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
icon: sensorsBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sensors_outlined),
|
||||||
|
label: Text(l10n.discoverSensors),
|
||||||
|
),
|
||||||
|
_buildHeaderActionButton(
|
||||||
|
width: buttonWidth,
|
||||||
|
onPressed:
|
||||||
|
isConnected &&
|
||||||
|
pendingAdverts.isNotEmpty &&
|
||||||
|
!_isResolvingAll
|
||||||
|
? () => _resolveAll(pendingAdverts)
|
||||||
|
: null,
|
||||||
|
icon: _isResolvingAll
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons
|
||||||
|
.download_for_offline_outlined,
|
||||||
|
),
|
||||||
|
label: Text(l10n.resolveAll),
|
||||||
|
),
|
||||||
|
_buildHeaderActionButton(
|
||||||
|
width: buttonWidth,
|
||||||
|
onPressed: pendingAdverts.isNotEmpty
|
||||||
|
? _clearAllDiscoveries
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.clear_all_rounded),
|
||||||
|
label: Text(l10n.clearAllLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Text(
|
),
|
||||||
'Resolve entries manually so they do not auto-populate contacts.',
|
const SizedBox(height: 16),
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
if (totalCount == 0)
|
||||||
),
|
Padding(
|
||||||
const SizedBox(height: 14),
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Icon(
|
||||||
child: OutlinedButton.icon(
|
Icons.person_search_outlined,
|
||||||
onPressed:
|
size: 64,
|
||||||
isConnected &&
|
color: Theme.of(context).disabledColor,
|
||||||
pendingAdverts.isNotEmpty &&
|
|
||||||
!_isResolvingAll
|
|
||||||
? () => _resolveAll(pendingAdverts)
|
|
||||||
: null,
|
|
||||||
icon: _isResolvingAll
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.download_for_offline_outlined,
|
|
||||||
),
|
|
||||||
label: Text(l10n.resolveAll),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Text(
|
||||||
child: OutlinedButton.icon(
|
'No discovered nodes',
|
||||||
onPressed: pendingAdverts.isNotEmpty
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
? _clearAllDiscoveries
|
),
|
||||||
: null,
|
const SizedBox(height: 8),
|
||||||
icon: Icon(Icons.clear_all_rounded),
|
Text(
|
||||||
label: Text(l10n.clearAllLabel),
|
'Use the discovery actions above to find repeaters and sensors on the mesh.',
|
||||||
),
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
if (_hasActiveInlineFilter &&
|
||||||
|
totalCount > 0 &&
|
||||||
|
filteredCount == 0)
|
||||||
|
_buildNoFilterResults(context),
|
||||||
|
...filteredAdverts.map(
|
||||||
|
(advert) => _buildPendingAdvertCard(
|
||||||
|
context,
|
||||||
|
advert: advert,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
isConnected: isConnected,
|
||||||
|
isResolved: resolvedKeySet.contains(
|
||||||
|
advert.publicKeyHex,
|
||||||
|
),
|
||||||
|
cachedNodes: cachedNodes,
|
||||||
|
l10n: l10n,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: 16),
|
},
|
||||||
if (totalCount == 0)
|
),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.person_search_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context).disabledColor,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No discovered nodes',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Use the menu to discover repeaters and sensors on the mesh.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...pendingAdverts.map((advert) =>
|
|
||||||
_buildPendingAdvertCard(
|
|
||||||
context,
|
|
||||||
advert: advert,
|
|
||||||
contactsProvider: contactsProvider,
|
|
||||||
isConnected: isConnected,
|
|
||||||
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
|
|
||||||
cachedNodes: cachedNodes,
|
|
||||||
l10n: l10n,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -563,7 +681,11 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
}) {
|
}) {
|
||||||
final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex);
|
final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex);
|
||||||
final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes);
|
final displayName = _displayNameForAdvert(
|
||||||
|
advert,
|
||||||
|
contactsProvider,
|
||||||
|
cachedNodes,
|
||||||
|
);
|
||||||
final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes);
|
final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes);
|
||||||
final downMetric = SignalMetric.fromValues(
|
final downMetric = SignalMetric.fromValues(
|
||||||
rssiDbm: advert.rxRssiDbm,
|
rssiDbm: advert.rxRssiDbm,
|
||||||
@@ -652,6 +774,180 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildInlineSearchField(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final hasFilter = _searchQuery.trim().isNotEmpty;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: hasFilter
|
||||||
|
? colorScheme.primary.withValues(alpha: 0.38)
|
||||||
|
: colorScheme.outline.withValues(alpha: 0.32),
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.025),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 42,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12, right: 8),
|
||||||
|
child: Icon(
|
||||||
|
Icons.search_rounded,
|
||||||
|
size: 17,
|
||||||
|
color: hasFilter
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cursorColor: colorScheme.primary,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search discovered nodes',
|
||||||
|
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.85,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.transparent,
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
disabledBorder: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasFilter)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: Material(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.10),
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTypeFilterChip(
|
||||||
|
BuildContext context, {
|
||||||
|
required String label,
|
||||||
|
required _DiscoveryListFilter filter,
|
||||||
|
}) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final selected = _selectedFilter == filter;
|
||||||
|
final color = selected ? scheme.primary : scheme.outline;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
if (_selectedFilter == filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_selectedFilter = filter;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: selected ? 0.45 : 0.22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNoFilterResults(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||||
|
child: Text(
|
||||||
|
'No matches',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderActionButton({
|
||||||
|
required double width,
|
||||||
|
required VoidCallback? onPressed,
|
||||||
|
required Widget icon,
|
||||||
|
required Widget label,
|
||||||
|
}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: icon,
|
||||||
|
label: label,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSignalSummary(
|
Widget _buildSignalSummary(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
SignalMetric? downMetric,
|
SignalMetric? downMetric,
|
||||||
|
|||||||
@@ -48,6 +48,110 @@ class MessagesTab extends StatefulWidget {
|
|||||||
State<MessagesTab> createState() => _MessagesTabState();
|
State<MessagesTab> createState() => _MessagesTabState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ComposerActionTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Color color;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const _ComposerActionTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.color,
|
||||||
|
this.enabled = true,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final effectiveColor = enabled
|
||||||
|
? color
|
||||||
|
: colorScheme.onSurfaceVariant.withValues(alpha: 0.45);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
enabled
|
||||||
|
? colorScheme.surfaceContainerLow
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
enabled
|
||||||
|
? effectiveColor.withValues(alpha: 0.08)
|
||||||
|
: colorScheme.surfaceContainerHigh,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: enabled
|
||||||
|
? effectiveColor.withValues(alpha: 0.16)
|
||||||
|
: colorScheme.outlineVariant.withValues(alpha: 0.16),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: effectiveColor.withValues(alpha: enabled ? 0.08 : 0.03),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(icon, color: effectiveColor, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: enabled
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MessagesTabState extends State<MessagesTab> {
|
class _MessagesTabState extends State<MessagesTab> {
|
||||||
static const int _maxContactMessageBytes = 156;
|
static const int _maxContactMessageBytes = 156;
|
||||||
static const int _maxChannelMessageBytes = 127;
|
static const int _maxChannelMessageBytes = 127;
|
||||||
@@ -1468,81 +1572,207 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
void _showComposerActions() {
|
void _showComposerActions() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
|
final theme = Theme.of(sheetContext);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
Future<void> runAction(Future<void> Function() action) async {
|
||||||
|
await _runAfterSheetDismissal(sheetContext, action);
|
||||||
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
constraints: BoxConstraints(
|
||||||
children: [
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.72,
|
||||||
ListTile(
|
),
|
||||||
leading: Icon(Icons.search),
|
child: Container(
|
||||||
title: Text(AppLocalizations.of(context)!.searchMessages),
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
onTap: () async {
|
decoration: BoxDecoration(
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
gradient: LinearGradient(
|
||||||
_showFilteredMessageSearch();
|
begin: Alignment.topCenter,
|
||||||
});
|
end: Alignment.bottomCenter,
|
||||||
},
|
colors: [
|
||||||
),
|
colorScheme.surface,
|
||||||
ListTile(
|
colorScheme.surfaceContainerLow,
|
||||||
leading: Icon(Icons.add_location_alt),
|
],
|
||||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
|
||||||
onTap: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
_showSarDialog();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (_voiceSupported)
|
|
||||||
ListTile(
|
|
||||||
enabled: !_isSendingVoice,
|
|
||||||
leading: Icon(_isRecording ? Icons.stop : Icons.mic),
|
|
||||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
|
||||||
onTap: _isSendingVoice
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
if (_isRecording) {
|
|
||||||
await _stopAndSendVoice();
|
|
||||||
} else {
|
|
||||||
await _startVoiceRecording();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
ListTile(
|
borderRadius: const BorderRadius.vertical(
|
||||||
enabled: !_isSendingImage,
|
top: Radius.circular(32),
|
||||||
leading: Icon(Icons.photo_library),
|
bottom: Radius.circular(28),
|
||||||
title: Text(AppLocalizations.of(context)!.sendImageFromGallery),
|
),
|
||||||
onTap: _isSendingImage
|
boxShadow: [
|
||||||
? null
|
BoxShadow(
|
||||||
: () async {
|
color: colorScheme.shadow.withValues(alpha: 0.14),
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
blurRadius: 28,
|
||||||
await _pickAndSendImage(source: ImageSource.gallery);
|
offset: const Offset(0, 10),
|
||||||
});
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 18),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'More actions',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: -0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Search, share, or start something from this chat.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(
|
||||||
|
alpha: 0.18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => Navigator.pop(sheetContext),
|
||||||
|
tooltip: l10n.close,
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final actions = <Widget>[
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.search_rounded,
|
||||||
|
title: l10n.searchMessages,
|
||||||
|
subtitle: 'Find text in the current conversation',
|
||||||
|
color: const Color(0xFF2B6CB0),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
_showFilteredMessageSearch();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.add_location_alt_rounded,
|
||||||
|
title: l10n.sendSarMarker,
|
||||||
|
subtitle: 'Share a marker with coordinates',
|
||||||
|
color: const Color(0xFFB45309),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
_showSarDialog();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (_voiceSupported)
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: _isRecording
|
||||||
|
? Icons.stop_rounded
|
||||||
|
: Icons.mic_rounded,
|
||||||
|
title: _isRecording
|
||||||
|
? 'Stop recording'
|
||||||
|
: 'Record voice',
|
||||||
|
subtitle: _isSendingVoice
|
||||||
|
? 'Voice message is sending'
|
||||||
|
: _isRecording
|
||||||
|
? 'Finish and send your clip'
|
||||||
|
: 'Capture and send a voice note',
|
||||||
|
color: const Color(0xFF7C3AED),
|
||||||
|
enabled: !_isSendingVoice,
|
||||||
|
onTap: !_isSendingVoice
|
||||||
|
? () => runAction(() async {
|
||||||
|
if (_isRecording) {
|
||||||
|
await _stopAndSendVoice();
|
||||||
|
} else {
|
||||||
|
await _startVoiceRecording();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.photo_library_rounded,
|
||||||
|
title: l10n.sendImageFromGallery,
|
||||||
|
subtitle: 'Choose an image from your library',
|
||||||
|
color: const Color(0xFF0F766E),
|
||||||
|
enabled: !_isSendingImage,
|
||||||
|
onTap: !_isSendingImage
|
||||||
|
? () => runAction(() async {
|
||||||
|
await _pickAndSendImage(
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.camera_alt_rounded,
|
||||||
|
title: l10n.takePhoto,
|
||||||
|
subtitle: 'Capture something right now',
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
enabled: !_isSendingImage,
|
||||||
|
onTap: !_isSendingImage
|
||||||
|
? () => runAction(() async {
|
||||||
|
await _pickAndSendImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: Icons.grid_3x3_rounded,
|
||||||
|
title: l10n.startTictactoe,
|
||||||
|
subtitle: l10n.dmOnly,
|
||||||
|
color: const Color(0xFFBE185D),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
await _startTicTacToeGame();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: actions.length,
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisExtent: 126,
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) => actions[index],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
),
|
||||||
enabled: !_isSendingImage,
|
|
||||||
leading: Icon(Icons.camera_alt),
|
|
||||||
title: Text(AppLocalizations.of(context)!.takePhoto),
|
|
||||||
onTap: _isSendingImage
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
await _pickAndSendImage(source: ImageSource.camera);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.grid_3x3),
|
|
||||||
title: Text(AppLocalizations.of(context)!.startTictactoe),
|
|
||||||
subtitle: Text(AppLocalizations.of(context)!.dmOnly),
|
|
||||||
onTap: () async {
|
|
||||||
await _runAfterSheetDismissal(sheetContext, () async {
|
|
||||||
await _startTicTacToeGame();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,74 +105,120 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
contactsProvider,
|
contactsProvider,
|
||||||
connectionProvider: context.read<ConnectionProvider>(),
|
connectionProvider: context.read<ConnectionProvider>(),
|
||||||
);
|
);
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
try {
|
||||||
context: context,
|
await showModalBottomSheet<void>(
|
||||||
showDragHandle: true,
|
context: context,
|
||||||
builder: (sheetContext) {
|
showDragHandle: true,
|
||||||
if (candidates.isEmpty) {
|
isScrollControlled: true,
|
||||||
return const SafeArea(
|
builder: (sheetContext) {
|
||||||
child: Padding(
|
if (candidates.isEmpty) {
|
||||||
padding: EdgeInsets.all(24),
|
return const SafeArea(
|
||||||
child: Text(
|
child: Padding(
|
||||||
'No eligible nodes available. Discover a relay or node first.',
|
padding: EdgeInsets.all(24),
|
||||||
),
|
child: Text(
|
||||||
),
|
'No eligible nodes available. Discover a relay or node first.',
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SafeArea(
|
|
||||||
child: ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
padding: const EdgeInsets.only(bottom: 20),
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
title: const Text(
|
|
||||||
'Add sensor node',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.pickARelayOrNodeToWatchInSensors,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...candidates.map(
|
);
|
||||||
(contact) => ListTile(
|
}
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
return StatefulBuilder(
|
||||||
vertical: 4,
|
builder: (context, setModalState) {
|
||||||
|
final query = searchController.text.trim().toLowerCase();
|
||||||
|
final filteredCandidates = candidates.where((contact) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return contact.displayName.toLowerCase().contains(query) ||
|
||||||
|
contact.publicKeyHex.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
),
|
),
|
||||||
leading: CircleAvatar(
|
child: ListView(
|
||||||
radius: 24,
|
shrinkWrap: true,
|
||||||
backgroundColor: const Color(0xFFDDEAF8),
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
child: Icon(
|
children: [
|
||||||
_typeIcon(contact),
|
ListTile(
|
||||||
color: const Color(0xFF1E4F7A),
|
title: const Text(
|
||||||
),
|
'Add sensor node',
|
||||||
),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
title: Text(contact.displayName),
|
),
|
||||||
subtitle: _SensorCandidatePreview(contact: contact),
|
subtitle: Text(
|
||||||
isThreeLine: true,
|
AppLocalizations.of(
|
||||||
onTap: () async {
|
context,
|
||||||
await sensorsProvider.addSensor(contact);
|
)!.pickARelayOrNodeToWatchInSensors,
|
||||||
if (!sheetContext.mounted) return;
|
|
||||||
Navigator.of(sheetContext).pop();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'${contact.displayName} added to Sensors',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
Padding(
|
||||||
},
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: searchController,
|
||||||
|
onChanged: (_) => setModalState(() {}),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
hintText: 'Search sensors',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (filteredCandidates.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No sensor candidates match your search.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...filteredCandidates.map(
|
||||||
|
(contact) => ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 24,
|
||||||
|
backgroundColor: const Color(0xFFDDEAF8),
|
||||||
|
child: Icon(
|
||||||
|
_typeIcon(contact),
|
||||||
|
color: const Color(0xFF1E4F7A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(contact.displayName),
|
||||||
|
subtitle: _SensorCandidatePreview(contact: contact),
|
||||||
|
isThreeLine: true,
|
||||||
|
onTap: () async {
|
||||||
|
await sensorsProvider.addSensor(contact);
|
||||||
|
if (!sheetContext.mounted) return;
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${contact.displayName} added to Sensors',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
);
|
} finally {
|
||||||
|
searchController.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showMetricSelector(
|
Future<void> _showMetricSelector(
|
||||||
@@ -292,14 +338,21 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
connectionProvider,
|
connectionProvider,
|
||||||
child,
|
child,
|
||||||
) {
|
) {
|
||||||
final displayKeys = sensorsProvider.displaySensorKeys(
|
final watchedKeys = sensorsProvider.watchedSensorKeys;
|
||||||
|
final hasPersistedSensors = watchedKeys.isNotEmpty;
|
||||||
|
final selfDisplayKey = sensorsProvider.displaySelfKey(
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
connectionProvider: connectionProvider,
|
connectionProvider: connectionProvider,
|
||||||
);
|
);
|
||||||
final hasPersistedSensors =
|
final hasAnyCards =
|
||||||
sensorsProvider.watchedSensorKeys.isNotEmpty;
|
selfDisplayKey != null || watchedKeys.isNotEmpty;
|
||||||
|
|
||||||
Widget buildSensorCard(String key, int index) {
|
Widget buildSensorCard(
|
||||||
|
String key, {
|
||||||
|
required int index,
|
||||||
|
required int totalCount,
|
||||||
|
required bool isWatchedCard,
|
||||||
|
}) {
|
||||||
final contact = sensorsProvider.contactForDisplay(
|
final contact = sensorsProvider.contactForDisplay(
|
||||||
key,
|
key,
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
@@ -314,106 +367,104 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
return Padding(
|
return Padding(
|
||||||
key: ValueKey<String>('sensor_card_$key'),
|
key: ValueKey<String>('sensor_card_$key'),
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: index == displayKeys.length - 1 ? 0 : 12,
|
bottom: index == totalCount - 1 ? 0 : 12,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: ReorderableDelayedDragStartListener(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
enabled: isWatchedCard,
|
||||||
children: [
|
index: index,
|
||||||
Expanded(
|
child: SensorTelemetryCard(
|
||||||
child: SensorTelemetryCard(
|
contact: contact,
|
||||||
contact: contact,
|
state: sensorsProvider.stateFor(key),
|
||||||
state: sensorsProvider.stateFor(key),
|
visibleFields: visibleFields,
|
||||||
visibleFields: visibleFields,
|
fieldOrder: sensorsProvider.metricOrderFor(
|
||||||
fieldOrder: sensorsProvider.metricOrderFor(
|
key,
|
||||||
key,
|
availableFieldKeys,
|
||||||
availableFieldKeys,
|
|
||||||
),
|
|
||||||
labelOverrides: sensorsProvider.labelOverridesFor(
|
|
||||||
key,
|
|
||||||
),
|
|
||||||
fieldSpans: {
|
|
||||||
for (final field in visibleFields)
|
|
||||||
field: sensorsProvider.fieldSpanFor(key, field),
|
|
||||||
},
|
|
||||||
onRemove: hasPersistedSensors
|
|
||||||
? () async {
|
|
||||||
await sensorsProvider.removeSensor(key);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onCustomize: () =>
|
|
||||||
_showMetricSelector(context, key, contact),
|
|
||||||
onShowMetHistory: (contact) =>
|
|
||||||
showBTHomeMetHistorySheet(
|
|
||||||
context,
|
|
||||||
contact: contact,
|
|
||||||
),
|
|
||||||
onRefresh: () => sensorsProvider.refreshSensor(
|
|
||||||
publicKeyHex: key,
|
|
||||||
contactsProvider: contactsProvider,
|
|
||||||
connectionProvider: connectionProvider,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (hasPersistedSensors) ...[
|
labelOverrides: sensorsProvider.labelOverridesFor(key),
|
||||||
const SizedBox(width: 8),
|
fieldSpans: {
|
||||||
Padding(
|
for (final field in visibleFields)
|
||||||
padding: const EdgeInsets.only(top: 20),
|
field: sensorsProvider.fieldSpanFor(key, field),
|
||||||
child: ReorderableDragStartListener(
|
},
|
||||||
index: index,
|
onRemove: isWatchedCard
|
||||||
child: Tooltip(
|
? () async {
|
||||||
message: 'Move card',
|
await sensorsProvider.removeSensor(key);
|
||||||
child: Container(
|
}
|
||||||
decoration: BoxDecoration(
|
: null,
|
||||||
color: Theme.of(
|
onCustomize: () =>
|
||||||
context,
|
_showMetricSelector(context, key, contact),
|
||||||
).colorScheme.surfaceContainerHighest,
|
onShowMetHistory: (contact) =>
|
||||||
borderRadius: BorderRadius.circular(12),
|
showBTHomeMetHistorySheet(context, contact: contact),
|
||||||
),
|
onMoveUp: isWatchedCard && index > 0
|
||||||
padding: const EdgeInsets.symmetric(
|
? () =>
|
||||||
horizontal: 8,
|
sensorsProvider.reorderSensors(index, index - 1)
|
||||||
vertical: 12,
|
: null,
|
||||||
),
|
onMoveDown: isWatchedCard && index < totalCount - 1
|
||||||
child: Icon(
|
? () =>
|
||||||
Icons.drag_indicator,
|
sensorsProvider.reorderSensors(index, index + 2)
|
||||||
color: Theme.of(
|
: null,
|
||||||
context,
|
onRefresh: () => sensorsProvider.refreshSensor(
|
||||||
).colorScheme.onSurfaceVariant,
|
publicKeyHex: key,
|
||||||
),
|
contactsProvider: contactsProvider,
|
||||||
),
|
connectionProvider: connectionProvider,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => _refreshAll(context),
|
onRefresh: () => _refreshAll(context),
|
||||||
child: displayKeys.isEmpty
|
child: !hasAnyCards
|
||||||
? ListView(
|
? ListView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||||
children: const [_EmptySensorsState()],
|
children: const [_EmptySensorsState()],
|
||||||
)
|
)
|
||||||
: hasPersistedSensors
|
: hasPersistedSensors && selfDisplayKey == null
|
||||||
? ReorderableListView.builder(
|
? ReorderableListView.builder(
|
||||||
buildDefaultDragHandles: false,
|
buildDefaultDragHandles: false,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||||
itemCount: displayKeys.length,
|
itemCount: watchedKeys.length,
|
||||||
onReorder: (oldIndex, newIndex) =>
|
onReorder: (oldIndex, newIndex) =>
|
||||||
sensorsProvider.reorderSensors(oldIndex, newIndex),
|
sensorsProvider.reorderSensors(oldIndex, newIndex),
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) => buildSensorCard(
|
||||||
buildSensorCard(displayKeys[index], index),
|
watchedKeys[index],
|
||||||
|
index: index,
|
||||||
|
totalCount: watchedKeys.length,
|
||||||
|
isWatchedCard: true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: ListView(
|
: ListView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < displayKeys.length; i++)
|
if (selfDisplayKey != null)
|
||||||
buildSensorCard(displayKeys[i], i),
|
buildSensorCard(
|
||||||
|
selfDisplayKey,
|
||||||
|
index: 0,
|
||||||
|
totalCount: watchedKeys.isEmpty ? 1 : 2,
|
||||||
|
isWatchedCard: false,
|
||||||
|
),
|
||||||
|
if (selfDisplayKey != null && watchedKeys.isEmpty)
|
||||||
|
_SelfOnlySensorsCta(
|
||||||
|
onAddSensor: () => _showAddSensorSheet(context),
|
||||||
|
),
|
||||||
|
if (watchedKeys.isNotEmpty)
|
||||||
|
ReorderableListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
buildDefaultDragHandles: false,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: watchedKeys.length,
|
||||||
|
onReorder: (oldIndex, newIndex) => sensorsProvider
|
||||||
|
.reorderSensors(oldIndex, newIndex),
|
||||||
|
itemBuilder: (context, index) => buildSensorCard(
|
||||||
|
watchedKeys[index],
|
||||||
|
index: index,
|
||||||
|
totalCount: watchedKeys.length,
|
||||||
|
isWatchedCard: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1011,6 +1062,81 @@ class _EmptySensorsStateState extends State<_EmptySensorsState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SelfOnlySensorsCta extends StatelessWidget {
|
||||||
|
final VoidCallback onAddSensor;
|
||||||
|
|
||||||
|
const _SelfOnlySensorsCta({required this.onAddSensor});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.add_chart_rounded,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Add another device',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Bring in weather stations, repeaters, or other devices to watch them here.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: onAddSensor,
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text('Choose'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IconData _typeIcon(Contact contact) {
|
IconData _typeIcon(Contact contact) {
|
||||||
if (contact.isSensor) {
|
if (contact.isSensor) {
|
||||||
return Icons.sensors;
|
return Icons.sensors;
|
||||||
|
|||||||
@@ -110,9 +110,44 @@ class BTHomeMetHistoryParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isTruthyCapabilityValue(Object? value) {
|
||||||
|
if (value is num) {
|
||||||
|
return value > 0;
|
||||||
|
}
|
||||||
|
if (value is bool) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasBTHomeMetCapability(Contact? contact) {
|
||||||
|
final extraSensorData = contact?.telemetry?.extraSensorData;
|
||||||
|
if (extraSensorData == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MET history is only enabled when the node advertises an explicit
|
||||||
|
// capability marker on channel 1. Accept a small set of channel-1 marker
|
||||||
|
// keys so the app remains tolerant while firmware-side encoding settles.
|
||||||
|
const capabilityKeys = <String>[
|
||||||
|
'met_capability',
|
||||||
|
'met_capability_1',
|
||||||
|
'generic_sensor_1',
|
||||||
|
'light_level_1',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final key in capabilityKeys) {
|
||||||
|
if (_isTruthyCapabilityValue(extraSensorData[key])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
|
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
|
||||||
final telemetry = contact?.telemetry;
|
final telemetry = contact?.telemetry;
|
||||||
if (telemetry == null) {
|
if (telemetry == null || !_hasBTHomeMetCapability(contact)) {
|
||||||
return const <BTHomeMetMeasurement>[];
|
return const <BTHomeMetMeasurement>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,10 +88,13 @@ class CayenneLppParser {
|
|||||||
LatLng? gpsLocation;
|
LatLng? gpsLocation;
|
||||||
double? batteryPercentage;
|
double? batteryPercentage;
|
||||||
double? batteryMilliVolts;
|
double? batteryMilliVolts;
|
||||||
|
double? deferredBatteryMilliVolts;
|
||||||
|
double? deferredBatteryPercentage;
|
||||||
double? temperature;
|
double? temperature;
|
||||||
double? humidity;
|
double? humidity;
|
||||||
double? pressure;
|
double? pressure;
|
||||||
final extraSensorData = <String, dynamic>{};
|
final extraSensorData = <String, dynamic>{};
|
||||||
|
bool sawNonSelfChannel = false;
|
||||||
|
|
||||||
int fieldCount = 0;
|
int fieldCount = 0;
|
||||||
while (reader.hasRemaining) {
|
while (reader.hasRemaining) {
|
||||||
@@ -112,6 +115,9 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
final channel = reader.readByte();
|
final channel = reader.readByte();
|
||||||
debugPrint(' Channel: $channel');
|
debugPrint(' Channel: $channel');
|
||||||
|
if (channel != _selfTelemetryChannel) {
|
||||||
|
sawNonSelfChannel = true;
|
||||||
|
}
|
||||||
|
|
||||||
final type = reader.readByte();
|
final type = reader.readByte();
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -136,7 +142,7 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Analog Input (raw): $rawValue');
|
debugPrint(' Analog Input (raw): $rawValue');
|
||||||
debugPrint(' Analog Input (volts): ${value}V');
|
debugPrint(' Analog Input (volts): ${value}V');
|
||||||
if (_isBatteryChannel(channel)) {
|
if (_isDedicatedBatteryChannel(channel)) {
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
@@ -144,6 +150,10 @@ class CayenneLppParser {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
);
|
);
|
||||||
|
} else if (_isDeferredBatteryChannel(channel)) {
|
||||||
|
deferredBatteryMilliVolts = value * 1000;
|
||||||
|
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||||
|
extraSensorData['analog_input_$channel'] = value;
|
||||||
} else {
|
} else {
|
||||||
extraSensorData['analog_input_$channel'] = value;
|
extraSensorData['analog_input_$channel'] = value;
|
||||||
}
|
}
|
||||||
@@ -237,7 +247,7 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Voltage (raw): $rawValue');
|
debugPrint(' Voltage (raw): $rawValue');
|
||||||
debugPrint(' Voltage: ${value}V');
|
debugPrint(' Voltage: ${value}V');
|
||||||
if (_isBatteryChannel(channel)) {
|
if (_isDedicatedBatteryChannel(channel)) {
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
@@ -245,6 +255,10 @@ class CayenneLppParser {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
);
|
);
|
||||||
|
} else if (_isDeferredBatteryChannel(channel)) {
|
||||||
|
deferredBatteryMilliVolts = value * 1000;
|
||||||
|
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||||
|
extraSensorData['voltage_$channel'] = value;
|
||||||
} else {
|
} else {
|
||||||
extraSensorData['voltage_$channel'] = value;
|
extraSensorData['voltage_$channel'] = value;
|
||||||
}
|
}
|
||||||
@@ -327,7 +341,7 @@ class CayenneLppParser {
|
|||||||
case _lppPercentage:
|
case _lppPercentage:
|
||||||
final value = reader.readByte().toDouble();
|
final value = reader.readByte().toDouble();
|
||||||
debugPrint(' Percentage: $value%');
|
debugPrint(' Percentage: $value%');
|
||||||
if (_isBatteryChannel(channel)) {
|
if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) {
|
||||||
batteryPercentage = value;
|
batteryPercentage = value;
|
||||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
} else {
|
} else {
|
||||||
@@ -631,6 +645,21 @@ class CayenneLppParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batteryMilliVolts == null &&
|
||||||
|
batteryPercentage == null &&
|
||||||
|
deferredBatteryMilliVolts != null &&
|
||||||
|
deferredBatteryPercentage != null &&
|
||||||
|
!sawNonSelfChannel) {
|
||||||
|
batteryMilliVolts = deferredBatteryMilliVolts;
|
||||||
|
batteryPercentage = deferredBatteryPercentage;
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel;
|
||||||
|
extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel;
|
||||||
|
debugPrint(
|
||||||
|
' Promoted self-channel voltage to battery: '
|
||||||
|
'${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(' Parsed $fieldCount fields');
|
debugPrint(' Parsed $fieldCount fields');
|
||||||
debugPrint(' ✅ [CayenneLPP] Parsing complete');
|
debugPrint(' ✅ [CayenneLPP] Parsing complete');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -677,8 +706,10 @@ class CayenneLppParser {
|
|||||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isBatteryChannel(int channel) =>
|
static bool _isDedicatedBatteryChannel(int channel) => channel == 0;
|
||||||
channel == 0 || channel == _selfTelemetryChannel;
|
|
||||||
|
static bool _isDeferredBatteryChannel(int channel) =>
|
||||||
|
channel == _selfTelemetryChannel;
|
||||||
|
|
||||||
static int _readUInt32BE(BufferReader reader) {
|
static int _readUInt32BE(BufferReader reader) {
|
||||||
final bytes = reader.readBytes(4);
|
final bytes = reader.readBytes(4);
|
||||||
|
|||||||
@@ -513,7 +513,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
showDragHandle: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (sheetContext) => _ContactActionSheet(
|
builder: (sheetContext) => _ContactActionSheet(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
@@ -1252,10 +1251,21 @@ class _ContactActionSheetState extends State<_ContactActionSheet> {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomInset),
|
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -1363,17 +1373,26 @@ class _ContactActionSheetState extends State<_ContactActionSheet> {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final columnCount = widget.primaryActions.length <= 1
|
const spacing = 12.0;
|
||||||
? 1
|
const minTileWidth = 104.0;
|
||||||
: widget.primaryActions.length == 2
|
final actionCount = widget.primaryActions.length;
|
||||||
? 2
|
final maxColumnsByWidth =
|
||||||
: 3;
|
((constraints.maxWidth + spacing) /
|
||||||
|
(minTileWidth + spacing))
|
||||||
|
.floor()
|
||||||
|
.clamp(1, 4);
|
||||||
|
var columnCount = actionCount.clamp(1, maxColumnsByWidth);
|
||||||
|
if (actionCount > 3 &&
|
||||||
|
columnCount > 2 &&
|
||||||
|
actionCount % columnCount == 1) {
|
||||||
|
columnCount -= 1;
|
||||||
|
}
|
||||||
final itemWidth =
|
final itemWidth =
|
||||||
(constraints.maxWidth - (12 * (columnCount - 1))) /
|
(constraints.maxWidth - (spacing * (columnCount - 1))) /
|
||||||
columnCount;
|
columnCount;
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 12,
|
spacing: spacing,
|
||||||
runSpacing: 12,
|
runSpacing: spacing,
|
||||||
children: [
|
children: [
|
||||||
for (final action in widget.primaryActions)
|
for (final action in widget.primaryActions)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
return lastSeenCompare;
|
return lastSeenCompare;
|
||||||
}
|
}
|
||||||
} else if (_sortMode == _RecipientSortMode.activity) {
|
} else if (_sortMode == _RecipientSortMode.activity) {
|
||||||
|
if (a.isFavourite != b.isFavourite) {
|
||||||
|
return a.isFavourite ? -1 : 1;
|
||||||
|
}
|
||||||
final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a));
|
final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a));
|
||||||
if (unreadCompare != 0) {
|
if (unreadCompare != 0) {
|
||||||
return unreadCompare;
|
return unreadCompare;
|
||||||
@@ -147,6 +150,32 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _sectionDescription(AppLocalizations l10n, String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'channel':
|
||||||
|
return 'Broadcast lanes for nearby mesh traffic';
|
||||||
|
case 'room':
|
||||||
|
return 'Shared spaces for ongoing team coordination';
|
||||||
|
case 'contact':
|
||||||
|
return 'Direct people and devices you can reach';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _channelSubtitle(BuildContext context, Contact channel) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
if (channel.isPublicChannel) {
|
||||||
|
return l10n.broadcastToAllNearby;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
||||||
|
final shortKey = channel.publicKeyShort.toUpperCase();
|
||||||
|
return '${l10n.channel} $channelIdx • $shortKey';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isDenseSection(String type) => type == 'channel' || type == 'contact';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -194,21 +223,45 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
l10n.selectRecipient,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
children: [
|
||||||
fontWeight: FontWeight.w900,
|
Text(
|
||||||
letterSpacing: -0.5,
|
l10n.selectRecipient,
|
||||||
),
|
style: Theme.of(context).textTheme.titleLarge
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Pick a channel, room, or direct contact.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
IconButton.filledTonal(
|
Container(
|
||||||
onPressed: () => Navigator.pop(context),
|
decoration: BoxDecoration(
|
||||||
tooltip: l10n.close,
|
color: colorScheme.surfaceContainerHigh,
|
||||||
icon: const Icon(Icons.close_rounded),
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(
|
||||||
|
alpha: 0.18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
tooltip: l10n.close,
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -278,11 +331,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
type: 'channel',
|
type: 'channel',
|
||||||
contact: channel,
|
contact: channel,
|
||||||
title: channel.getLocalizedDisplayName(context),
|
title: channel.getLocalizedDisplayName(context),
|
||||||
subtitle: channel.isPublicChannel
|
subtitle: _channelSubtitle(context, channel),
|
||||||
? l10n.broadcastToAllNearby
|
|
||||||
: '${l10n.channel} ${channel.publicKey[1]}',
|
|
||||||
unreadCount: _unreadFor(channel),
|
unreadCount: _unreadFor(channel),
|
||||||
isSelected: _isSelected('channel', channel),
|
isSelected: _isSelected('channel', channel),
|
||||||
|
compact: true,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
widget.onSelect('channel', channel);
|
widget.onSelect('channel', channel);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -308,6 +360,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
subtitle: contact.publicKeyShort,
|
subtitle: contact.publicKeyShort,
|
||||||
unreadCount: _unreadFor(contact),
|
unreadCount: _unreadFor(contact),
|
||||||
isSelected: _isSelected('contact', contact),
|
isSelected: _isSelected('contact', contact),
|
||||||
|
compact: true,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
widget.onSelect('contact', contact);
|
widget.onSelect('contact', contact);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -455,10 +508,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final accentColor = _sectionColor(context, type);
|
final accentColor = _sectionColor(context, type);
|
||||||
|
final compact = _isDenseSection(type);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: EdgeInsets.all(compact ? 12 : 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -470,16 +524,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 38,
|
width: compact ? 34 : 38,
|
||||||
height: 38,
|
height: compact ? 34 : 38,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accentColor.withValues(alpha: 0.12),
|
color: accentColor.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Icon(icon, size: 18, color: accentColor),
|
child: Icon(icon, size: compact ? 16 : 18, color: accentColor),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
SizedBox(width: compact ? 8 : 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
@@ -490,9 +544,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: compact ? 9 : 10,
|
||||||
vertical: 5,
|
vertical: compact ? 4 : 5,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
@@ -511,7 +565,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_sectionDescription(AppLocalizations.of(context)!, type),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: compact ? 10 : 12),
|
||||||
if (children.isEmpty)
|
if (children.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
@@ -526,7 +587,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
else ...[
|
else ...[
|
||||||
for (var i = 0; i < children.length; i++) ...[
|
for (var i = 0; i < children.length; i++) ...[
|
||||||
children[i],
|
children[i],
|
||||||
if (i != children.length - 1) const SizedBox(height: 8),
|
if (i != children.length - 1) SizedBox(height: compact ? 6 : 8),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -623,6 +684,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
required String subtitle,
|
required String subtitle,
|
||||||
required int unreadCount,
|
required int unreadCount,
|
||||||
required bool isSelected,
|
required bool isSelected,
|
||||||
|
bool compact = false,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -631,14 +693,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(compact ? 18 : 20),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? accentColor.withValues(alpha: 0.12)
|
? accentColor.withValues(alpha: 0.12)
|
||||||
: colorScheme.surface.withValues(alpha: 0.82),
|
: colorScheme.surface.withValues(alpha: 0.82),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(compact ? 18 : 20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? accentColor.withValues(alpha: 0.24)
|
? accentColor.withValues(alpha: 0.24)
|
||||||
@@ -646,7 +708,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: EdgeInsets.all(compact ? 10 : 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
Stack(
|
||||||
@@ -654,15 +716,15 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
children: [
|
children: [
|
||||||
ContactAvatar(
|
ContactAvatar(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
radius: 22,
|
radius: compact ? 20 : 22,
|
||||||
displayName: title,
|
displayName: title,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -3,
|
right: -3,
|
||||||
bottom: -3,
|
bottom: -3,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 18,
|
width: compact ? 16 : 18,
|
||||||
height: 18,
|
height: compact ? 16 : 18,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
@@ -673,28 +735,56 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_typeIcon(contact),
|
_typeIcon(contact),
|
||||||
size: 10,
|
size: compact ? 9 : 10,
|
||||||
color: accentColor,
|
color: accentColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
SizedBox(width: compact ? 10 : 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
title,
|
children: [
|
||||||
maxLines: 1,
|
Flexible(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
title,
|
||||||
fontWeight: FontWeight.w800,
|
maxLines: 1,
|
||||||
letterSpacing: -0.2,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: Theme.of(context).textTheme.titleSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (contact.isPublicChannel) ...[
|
||||||
|
SizedBox(width: compact ? 6 : 8),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 7 : 8,
|
||||||
|
vertical: compact ? 2 : 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Public',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: accentColor,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
SizedBox(height: compact ? 2 : 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -707,7 +797,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
SizedBox(width: compact ? 8 : 10),
|
||||||
_buildTrailing(
|
_buildTrailing(
|
||||||
context,
|
context,
|
||||||
unreadCount: unreadCount,
|
unreadCount: unreadCount,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
@@ -547,12 +548,15 @@ SensorMetricCardData? _buildOptionPreviewCardData(
|
|||||||
fieldKey: fieldKey,
|
fieldKey: fieldKey,
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.explore_outlined,
|
||||||
label: label,
|
label: label,
|
||||||
value: previewValue,
|
value: degrees == null
|
||||||
|
? previewValue
|
||||||
|
: '${_formatPreviewNumber(degrees, maxFractionDigits: 0)}°',
|
||||||
secondaryValue: degrees == null
|
secondaryValue: degrees == null
|
||||||
? null
|
? null
|
||||||
: _previewFormatCardinalDirection(degrees),
|
: _previewFormatCardinalDirection(degrees),
|
||||||
accent: const Color(0xFF8A5A44),
|
accent: const Color(0xFF8A5A44),
|
||||||
channel: metricKey.channel,
|
channel: metricKey.channel,
|
||||||
|
directionDegrees: degrees,
|
||||||
);
|
);
|
||||||
case 'unixtime':
|
case 'unixtime':
|
||||||
return SensorMetricCardData(
|
return SensorMetricCardData(
|
||||||
@@ -1007,6 +1011,13 @@ String _formatPreviewNumber(num value, {int maxFractionDigits = 2}) {
|
|||||||
? math.min(maxFractionDigits, 1)
|
? math.min(maxFractionDigits, 1)
|
||||||
: maxFractionDigits;
|
: maxFractionDigits;
|
||||||
final text = value.toStringAsFixed(digits);
|
final text = value.toStringAsFixed(digits);
|
||||||
|
return _trimFractionZeros(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _trimFractionZeros(String text) {
|
||||||
|
if (!text.contains('.')) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
return text.replaceFirst(RegExp(r'\.?0+$'), '');
|
return text.replaceFirst(RegExp(r'\.?0+$'), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,6 +1041,8 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
final Future<void> Function()? onRefresh;
|
final Future<void> Function()? onRefresh;
|
||||||
final VoidCallback? onCustomize;
|
final VoidCallback? onCustomize;
|
||||||
final Future<void> Function(Contact contact)? onShowMetHistory;
|
final Future<void> Function(Contact contact)? onShowMetHistory;
|
||||||
|
final Future<void> Function()? onMoveUp;
|
||||||
|
final Future<void> Function()? onMoveDown;
|
||||||
final EdgeInsetsGeometry margin;
|
final EdgeInsetsGeometry margin;
|
||||||
final String emptyMetricsMessage;
|
final String emptyMetricsMessage;
|
||||||
final Map<String, String> labelOverrides;
|
final Map<String, String> labelOverrides;
|
||||||
@@ -1045,6 +1058,8 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
this.onCustomize,
|
this.onCustomize,
|
||||||
this.onShowMetHistory,
|
this.onShowMetHistory,
|
||||||
|
this.onMoveUp,
|
||||||
|
this.onMoveDown,
|
||||||
this.margin = const EdgeInsets.only(bottom: 16),
|
this.margin = const EdgeInsets.only(bottom: 16),
|
||||||
this.emptyMetricsMessage =
|
this.emptyMetricsMessage =
|
||||||
'All fields are hidden. Use Visible fields to choose what to show.',
|
'All fields are hidden. Use Visible fields to choose what to show.',
|
||||||
@@ -1055,6 +1070,9 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
onRefresh != null ||
|
onRefresh != null ||
|
||||||
onCustomize != null ||
|
onCustomize != null ||
|
||||||
onRemove != null ||
|
onRemove != null ||
|
||||||
|
onMoveUp != null ||
|
||||||
|
onMoveDown != null ||
|
||||||
|
_rawTelemetryHex(contact?.telemetry) != null ||
|
||||||
(contact != null &&
|
(contact != null &&
|
||||||
onShowMetHistory != null &&
|
onShowMetHistory != null &&
|
||||||
supportsBTHomeMetHistory(contact));
|
supportsBTHomeMetHistory(contact));
|
||||||
@@ -1136,21 +1154,21 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (state == SensorRefreshState.refreshing)
|
if (state == SensorRefreshState.refreshing)
|
||||||
_InlineStateMeta(
|
_InlineStateMeta(
|
||||||
label: l10n.refreshing,
|
|
||||||
color: Color(0xFF266AC2),
|
color: Color(0xFF266AC2),
|
||||||
spinning: true,
|
spinning: true,
|
||||||
|
tooltip: l10n.refreshing,
|
||||||
),
|
),
|
||||||
if (state == SensorRefreshState.success)
|
if (state == SensorRefreshState.success)
|
||||||
const _InlineStateMeta(
|
const _InlineStateMeta(
|
||||||
label: 'Updated',
|
|
||||||
color: Color(0xFF218B63),
|
color: Color(0xFF218B63),
|
||||||
icon: Icons.check_circle,
|
icon: Icons.check_circle,
|
||||||
|
tooltip: 'Updated',
|
||||||
),
|
),
|
||||||
if (state == SensorRefreshState.unavailable)
|
if (state == SensorRefreshState.unavailable)
|
||||||
_InlineStateMeta(
|
_InlineStateMeta(
|
||||||
label: l10n.unavailable,
|
|
||||||
color: Color(0xFFB13B55),
|
color: Color(0xFFB13B55),
|
||||||
icon: Icons.error_outline,
|
icon: Icons.error_outline,
|
||||||
|
tooltip: l10n.unavailable,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1163,6 +1181,26 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'refresh' && onRefresh != null) {
|
if (value == 'refresh' && onRefresh != null) {
|
||||||
await onRefresh!();
|
await onRefresh!();
|
||||||
|
} else if (value == 'move_up' && onMoveUp != null) {
|
||||||
|
await onMoveUp!();
|
||||||
|
} else if (value == 'move_down' && onMoveDown != null) {
|
||||||
|
await onMoveDown!();
|
||||||
|
} else if (value == 'copy_raw') {
|
||||||
|
final rawTelemetry = _rawTelemetryHex(
|
||||||
|
contact?.telemetry,
|
||||||
|
);
|
||||||
|
if (rawTelemetry != null && context.mounted) {
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: rawTelemetry),
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Raw response copied'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (value == 'remove' && onRemove != null) {
|
} else if (value == 'remove' && onRemove != null) {
|
||||||
await onRemove!();
|
await onRemove!();
|
||||||
} else if (value == 'customize' && onCustomize != null) {
|
} else if (value == 'customize' && onCustomize != null) {
|
||||||
@@ -1183,6 +1221,30 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (onMoveUp != null) {
|
||||||
|
items.add(
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'move_up',
|
||||||
|
child: Text('Move up'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (onMoveDown != null) {
|
||||||
|
items.add(
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'move_down',
|
||||||
|
child: Text('Move down'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_rawTelemetryHex(contact?.telemetry) != null) {
|
||||||
|
items.add(
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'copy_raw',
|
||||||
|
child: Text('Copy raw response'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (onCustomize != null) {
|
if (onCustomize != null) {
|
||||||
items.add(
|
items.add(
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
@@ -1811,10 +1873,11 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
fieldKey: _extraFieldKey(rawKey),
|
fieldKey: _extraFieldKey(rawKey),
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.explore_outlined,
|
||||||
label: label,
|
label: label,
|
||||||
value: '${_formatNumber(degrees, maxFractionDigits: 0)} deg',
|
value: '${_formatNumber(degrees, maxFractionDigits: 0)}°',
|
||||||
secondaryValue: _formatCardinalDirection(degrees),
|
secondaryValue: _formatCardinalDirection(degrees),
|
||||||
accent: const Color(0xFF8A5A44),
|
accent: const Color(0xFF8A5A44),
|
||||||
channel: metricKey.channel,
|
channel: metricKey.channel,
|
||||||
|
directionDegrees: degrees,
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'rotation':
|
case 'rotation':
|
||||||
@@ -2054,7 +2117,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
? math.min(maxFractionDigits, 1)
|
? math.min(maxFractionDigits, 1)
|
||||||
: maxFractionDigits;
|
: maxFractionDigits;
|
||||||
final text = value.toStringAsFixed(digits);
|
final text = value.toStringAsFixed(digits);
|
||||||
return text.replaceFirst(RegExp(r'\.?0+$'), '');
|
return _trimFractionZeros(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
_Vector3? _asVector3(dynamic value) {
|
_Vector3? _asVector3(dynamic value) {
|
||||||
@@ -2118,31 +2181,28 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _InlineStateMeta extends StatelessWidget {
|
class _InlineStateMeta extends StatelessWidget {
|
||||||
final String label;
|
|
||||||
final Color color;
|
final Color color;
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final bool spinning;
|
final bool spinning;
|
||||||
|
final String? tooltip;
|
||||||
|
|
||||||
const _InlineStateMeta({
|
const _InlineStateMeta({
|
||||||
required this.label,
|
|
||||||
required this.color,
|
required this.color,
|
||||||
this.icon,
|
this.icon,
|
||||||
this.spinning = false,
|
this.spinning = false,
|
||||||
|
this.tooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final badge = Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.10),
|
color: color.withValues(alpha: 0.10),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: spinning
|
||||||
mainAxisSize: MainAxisSize.min,
|
? SizedBox(
|
||||||
children: [
|
|
||||||
if (spinning)
|
|
||||||
SizedBox(
|
|
||||||
width: 11,
|
width: 11,
|
||||||
height: 11,
|
height: 11,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
@@ -2150,18 +2210,20 @@ class _InlineStateMeta extends StatelessWidget {
|
|||||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (icon != null)
|
: Icon(
|
||||||
Icon(icon, size: 11, color: color),
|
icon ?? Icons.info_outline,
|
||||||
const SizedBox(width: 4),
|
size: 11,
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: color,
|
color: color,
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
|
||||||
),
|
if (tooltip == null || tooltip!.isEmpty) {
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip!,
|
||||||
|
child: badge,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2292,7 +2354,40 @@ class SensorMetricTile extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
|
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
|
||||||
),
|
),
|
||||||
child: data.mapLocation == null || !allowMapPreview
|
child: data.directionDegrees != null
|
||||||
|
? Stack(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_DirectionDial(
|
||||||
|
accent: data.accent,
|
||||||
|
degrees: data.directionDegrees!,
|
||||||
|
cardinal: data.secondaryValue ?? '',
|
||||||
|
compact: true,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _MetricText(data: data, keyPrefix: keyPrefix),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (data.channel != null)
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Text(
|
||||||
|
'ch${data.channel}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: data.accent.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: data.mapLocation == null || !allowMapPreview
|
||||||
? Stack(
|
? Stack(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -2456,6 +2551,10 @@ class _MetricText extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (data.directionDegrees != null) {
|
||||||
|
return _DirectionMetricText(data: data);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -2495,12 +2594,138 @@ class _MetricText extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DirectionMetricText extends StatelessWidget {
|
||||||
|
final SensorMetricCardData data;
|
||||||
|
|
||||||
|
const _DirectionMetricText({required this.data});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final directionLabel = data.secondaryValue ?? '';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
data.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: data.accent,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
data.value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (directionLabel.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
directionLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: data.accent.withValues(alpha: 0.9),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DirectionDial extends StatelessWidget {
|
||||||
|
final Color accent;
|
||||||
|
final double degrees;
|
||||||
|
final String cardinal;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
const _DirectionDial({
|
||||||
|
required this.accent,
|
||||||
|
required this.degrees,
|
||||||
|
required this.cardinal,
|
||||||
|
this.compact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final normalized = ((degrees % 360) + 360) % 360;
|
||||||
|
final size = compact ? 34.0 : 44.0;
|
||||||
|
final innerSize = compact ? 24.0 : 30.0;
|
||||||
|
final arrowSize = compact ? 14.0 : 16.0;
|
||||||
|
final northTop = compact ? 1.0 : 3.0;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.08),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: accent.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: innerSize,
|
||||||
|
height: innerSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: accent.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Transform.rotate(
|
||||||
|
angle: normalized * math.pi / 180,
|
||||||
|
child: Icon(
|
||||||
|
Icons.navigation_rounded,
|
||||||
|
size: arrowSize,
|
||||||
|
color: accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: northTop,
|
||||||
|
child: Text(
|
||||||
|
'N',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: accent.withValues(alpha: 0.65),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: compact ? 7.5 : 9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SensorMetricCardData {
|
class SensorMetricCardData {
|
||||||
final String fieldKey;
|
final String fieldKey;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
final String? secondaryValue;
|
final String? secondaryValue;
|
||||||
|
final double? directionDegrees;
|
||||||
final Color accent;
|
final Color accent;
|
||||||
final bool wide;
|
final bool wide;
|
||||||
final LatLng? mapLocation;
|
final LatLng? mapLocation;
|
||||||
@@ -2512,6 +2737,7 @@ class SensorMetricCardData {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
this.secondaryValue,
|
this.secondaryValue,
|
||||||
|
this.directionDegrees,
|
||||||
required this.accent,
|
required this.accent,
|
||||||
this.wide = false,
|
this.wide = false,
|
||||||
this.mapLocation,
|
this.mapLocation,
|
||||||
@@ -2543,13 +2769,20 @@ class _RgbColor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const String _telemetrySourceChannelPrefix = '__source_channel:';
|
const String _telemetrySourceChannelPrefix = '__source_channel:';
|
||||||
|
const String _rawTelemetryHexKey = '__raw_lpp_hex';
|
||||||
|
|
||||||
String _extraFieldKey(String label) {
|
String _extraFieldKey(String label) {
|
||||||
return 'extra:$label';
|
return 'extra:$label';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isTelemetryMetadataKey(String key) {
|
bool _isTelemetryMetadataKey(String key) {
|
||||||
return key.startsWith(_telemetrySourceChannelPrefix);
|
return key.startsWith(_telemetrySourceChannelPrefix) ||
|
||||||
|
key == _rawTelemetryHexKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _rawTelemetryHex(ContactTelemetry? telemetry) {
|
||||||
|
final value = telemetry?.extraSensorData?[_rawTelemetryHexKey];
|
||||||
|
return value is String && value.trim().isNotEmpty ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _telemetrySourceChannelKey(String fieldKey) {
|
String _telemetrySourceChannelKey(String fieldKey) {
|
||||||
|
|||||||
@@ -828,7 +828,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: "3dbc82170583f616b590e5204a307a02f2b8833d"
|
resolved-ref: "71902401e9a6bc7d2ed1193f8961e7256646ab76"
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0321.1+37
|
version: 2026.0322.1+38
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
@@ -907,6 +907,56 @@ void main() {
|
|||||||
expect(restored.savedGroupsForSection('teamMembers'), hasLength(1));
|
expect(restored.savedGroupsForSection('teamMembers'), hasLength(1));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'retains cached telemetry when sync re-adds a contact without telemetry',
|
||||||
|
() async {
|
||||||
|
final key = createPublicKey(141);
|
||||||
|
final contact = createContact(
|
||||||
|
key: key,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
name: 'Sparse Sync Sensor',
|
||||||
|
).copyWith(
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
temperature: 18.5,
|
||||||
|
humidity: 64.0,
|
||||||
|
timestamp: DateTime(2026, 3, 22, 8, 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(contact);
|
||||||
|
await provider.prepareForDeviceContactSync();
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: key,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
name: 'Sparse Sync Sensor',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = provider.findContactByKey(key);
|
||||||
|
expect(restored, isNotNull);
|
||||||
|
expect(restored!.telemetry, isNotNull);
|
||||||
|
expect(restored.telemetry!.temperature, closeTo(18.5, 0.01));
|
||||||
|
expect(restored.telemetry!.humidity, closeTo(64.0, 0.01));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('keeps self telemetry cached across reconnect for the same device', () async {
|
||||||
|
final selfKey = createPublicKey(200);
|
||||||
|
|
||||||
|
await provider.initialize(devicePublicKey: selfKey);
|
||||||
|
provider.updateTelemetry(
|
||||||
|
selfKey.sublist(0, 6),
|
||||||
|
CayenneLppParser.createTemperatureData(23.5, channel: 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
await provider.prepareForDeviceContactSync(devicePublicKey: selfKey);
|
||||||
|
|
||||||
|
expect(provider.selfTelemetry, isNotNull);
|
||||||
|
expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ContactsProvider self telemetry', () {
|
group('ContactsProvider self telemetry', () {
|
||||||
@@ -935,6 +985,24 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stores raw telemetry hex in metadata for verification', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final provider = ContactsProvider();
|
||||||
|
final telemetryKey = createPublicKey(150);
|
||||||
|
final contact = createContact(
|
||||||
|
key: telemetryKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
name: 'Telemetry Node',
|
||||||
|
);
|
||||||
|
provider.addOrUpdateContact(contact);
|
||||||
|
|
||||||
|
final payload = Uint8List.fromList(<int>[0x01, 0x67, 0x00, 0xEB]);
|
||||||
|
provider.updateTelemetry(telemetryKey.sublist(0, 6), payload);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(telemetryKey)!;
|
||||||
|
expect(updated.telemetry?.extraSensorData?['__raw_lpp_hex'], '01 67 00 eb');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String publicKeyHex(Uint8List publicKey) {
|
String publicKeyHex(Uint8List publicKey) {
|
||||||
|
|||||||
@@ -356,6 +356,43 @@ void main() {
|
|||||||
expect(connectionProvider.pingCalls, 2);
|
expect(connectionProvider.pingCalls, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refreshDueSensors refreshes self every minute', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final selfKey = Uint8List(32)..[0] = 0x66;
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
await contactsProvider.initialize(devicePublicKey: selfKey);
|
||||||
|
final connectionProvider = _FakeConnectionProvider(
|
||||||
|
isConnected: true,
|
||||||
|
publicKey: selfKey,
|
||||||
|
selfName: 'My Device',
|
||||||
|
);
|
||||||
|
final start = DateTime(2026, 3, 22, 9, 0);
|
||||||
|
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
|
||||||
|
await provider.refreshDueSensors(
|
||||||
|
now: start,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
expect(connectionProvider.pingCalls, 1);
|
||||||
|
|
||||||
|
await provider.refreshDueSensors(
|
||||||
|
now: start.add(const Duration(seconds: 30)),
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
expect(connectionProvider.pingCalls, 1);
|
||||||
|
|
||||||
|
await provider.refreshDueSensors(
|
||||||
|
now: start.add(const Duration(minutes: 1)),
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
expect(connectionProvider.pingCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'addSensor includes available extra telemetry fields by default',
|
'addSensor includes available extra telemetry fields by default',
|
||||||
() async {
|
() async {
|
||||||
@@ -411,4 +448,32 @@ void main() {
|
|||||||
expect(selfContact!.telemetry, isNotNull);
|
expect(selfContact!.telemetry, isNotNull);
|
||||||
expect(selfContact.telemetry!.temperature, closeTo(19.5, 0.1));
|
expect(selfContact.telemetry!.temperature, closeTo(19.5, 0.1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'displaySelfKey still returns self when watched sensors exist',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final selfKey = Uint8List(32)..[0] = 0x66;
|
||||||
|
final sensor = buildSensorContact(firstByte: 0x44, name: 'Weather');
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
await contactsProvider.initialize(devicePublicKey: selfKey);
|
||||||
|
|
||||||
|
final connectionProvider = _FakeConnectionProvider(
|
||||||
|
isConnected: true,
|
||||||
|
publicKey: selfKey,
|
||||||
|
selfName: 'My Device',
|
||||||
|
);
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
await provider.addSensor(sensor);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.displaySelfKey(
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
),
|
||||||
|
selfKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
190
test/screens/discovery_screen_test.dart
Normal file
190
test/screens/discovery_screen_test.dart
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/device_info.dart' as device_info;
|
||||||
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/discovery_screen.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class _FakeConnectionProvider extends ConnectionProvider {
|
||||||
|
_FakeConnectionProvider({required bool isConnected})
|
||||||
|
: _isConnected = isConnected;
|
||||||
|
|
||||||
|
final bool _isConnected;
|
||||||
|
final List<int> discoveredAdvertTypes = <int>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo(
|
||||||
|
connectionState: _isConnected
|
||||||
|
? device_info.ConnectionState.connected
|
||||||
|
: device_info.ConnectionState.disconnected,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> discoverNodeType({
|
||||||
|
required int advertType,
|
||||||
|
bool prefixOnly = false,
|
||||||
|
int since = 0,
|
||||||
|
}) async {
|
||||||
|
discoveredAdvertTypes.add(advertType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpDiscoveryScreen(
|
||||||
|
WidgetTester tester, {
|
||||||
|
ContactsProvider? contactsProvider,
|
||||||
|
ConnectionProvider? connectionProvider,
|
||||||
|
}) async {
|
||||||
|
final resolvedContactsProvider = contactsProvider ?? ContactsProvider();
|
||||||
|
final resolvedConnectionProvider =
|
||||||
|
connectionProvider ?? _FakeConnectionProvider(isConnected: true);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<ContactsProvider>.value(
|
||||||
|
value: resolvedContactsProvider,
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<ConnectionProvider>.value(
|
||||||
|
value: resolvedConnectionProvider,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const DiscoveryScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _publicKey(int seed) {
|
||||||
|
final bytes = Uint8List(32);
|
||||||
|
bytes[0] = seed;
|
||||||
|
bytes[1] = seed + 1;
|
||||||
|
bytes[2] = seed + 2;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'discovery actions are shown in the summary card instead of overflow menu',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(375, 812);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
final connectionProvider = _FakeConnectionProvider(isConnected: true);
|
||||||
|
|
||||||
|
await _pumpDiscoveryScreen(
|
||||||
|
tester,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('Discover repeaters'), findsOneWidget);
|
||||||
|
expect(find.text('Discover sensors'), findsOneWidget);
|
||||||
|
expect(find.text('Resolve all'), findsOneWidget);
|
||||||
|
expect(find.text('Clear all'), findsOneWidget);
|
||||||
|
expect(find.text('Search discovered nodes'), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.more_vert), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Use the discovery actions above to find repeaters and sensors on the mesh.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Discover repeaters'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(connectionProvider.discoveredAdvertTypes, [2]);
|
||||||
|
expect(find.text('Repeater discovery sent'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('search and inline type filters narrow discovered nodes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 1400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
final contactsProvider = ContactsProvider()
|
||||||
|
..addOrUpdatePendingAdvertMetadata(
|
||||||
|
publicKey: _publicKey(0x10),
|
||||||
|
typeValue: 2,
|
||||||
|
advName: 'Relay Alpha',
|
||||||
|
)
|
||||||
|
..addOrUpdatePendingAdvertMetadata(
|
||||||
|
publicKey: _publicKey(0x20),
|
||||||
|
typeValue: 4,
|
||||||
|
advName: 'WX Station',
|
||||||
|
)
|
||||||
|
..addOrUpdatePendingAdvertMetadata(
|
||||||
|
publicKey: _publicKey(0x30),
|
||||||
|
typeValue: 3,
|
||||||
|
advName: 'Ops Room',
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pumpDiscoveryScreen(
|
||||||
|
tester,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: _FakeConnectionProvider(isConnected: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Search discovered nodes'), findsOneWidget);
|
||||||
|
expect(find.text('All'), findsOneWidget);
|
||||||
|
expect(find.text('Repeaters'), findsOneWidget);
|
||||||
|
expect(find.text('Sensors'), findsOneWidget);
|
||||||
|
expect(find.text('Others'), findsOneWidget);
|
||||||
|
|
||||||
|
expect(find.text('Relay Alpha'), findsOneWidget);
|
||||||
|
expect(find.text('WX Station'), findsOneWidget);
|
||||||
|
expect(find.text('Ops Room'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sensors'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Relay Alpha'), findsNothing);
|
||||||
|
expect(find.text('WX Station'), findsOneWidget);
|
||||||
|
expect(find.text('Ops Room'), findsNothing);
|
||||||
|
expect(find.text('Discovered nodes (1/3)'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Others'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Relay Alpha'), findsNothing);
|
||||||
|
expect(find.text('WX Station'), findsNothing);
|
||||||
|
expect(find.text('Ops Room'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'relay');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No matches'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('All'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Relay Alpha'), findsOneWidget);
|
||||||
|
expect(find.text('Ops Room'), findsNothing);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'ops');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Relay Alpha'), findsNothing);
|
||||||
|
expect(find.text('WX Station'), findsNothing);
|
||||||
|
expect(find.text('Ops Room'), findsOneWidget);
|
||||||
|
expect(find.text('Discovered nodes (1/3)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,7 +38,12 @@ void main() {
|
|||||||
telemetry: ContactTelemetry(
|
telemetry: ContactTelemetry(
|
||||||
temperature: 20.1,
|
temperature: 20.1,
|
||||||
humidity: 52,
|
humidity: 52,
|
||||||
extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3},
|
extraSensorData: const {
|
||||||
|
'generic_sensor_1': 1,
|
||||||
|
'speed_2': 3.1,
|
||||||
|
'gust_2': 4.8,
|
||||||
|
'rain_2': 12.3,
|
||||||
|
},
|
||||||
timestamp: DateTime(2026, 3, 21, 12),
|
timestamp: DateTime(2026, 3, 21, 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -52,4 +57,28 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
expect(supportsBTHomeMetHistory(contact), isTrue);
|
expect(supportsBTHomeMetHistory(contact), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not enable BTHome MET history without channel 1 capability', () {
|
||||||
|
final contact = Contact(
|
||||||
|
publicKey: Uint8List(32),
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'WX',
|
||||||
|
lastAdvert: 0,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 0,
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
temperature: 20.1,
|
||||||
|
humidity: 52,
|
||||||
|
extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3},
|
||||||
|
timestamp: DateTime(2026, 3, 21, 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bTHomeMetMeasurementsForContact(contact), isEmpty);
|
||||||
|
expect(supportsBTHomeMetHistory(contact), isFalse);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,6 +488,39 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('weather station payload keeps generic percentage and does not invent battery from ch1 voltage', () {
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
0x01, 0x74, 0x00, 0x00,
|
||||||
|
0x02, 0x78, 0x64,
|
||||||
|
0x02, 0x73, 0x24, 0x32,
|
||||||
|
0x02, 0x65, 0x5A, 0x50,
|
||||||
|
0x02, 0x8A, 0xFF, 0xE4,
|
||||||
|
0x02, 0x74, 0x01, 0xE0,
|
||||||
|
0x02, 0x9D, 0x00,
|
||||||
|
0x02, 0x68, 0x76,
|
||||||
|
0x02, 0x81, 0x01, 0x4A,
|
||||||
|
0x02, 0x89, 0x01, 0xB8,
|
||||||
|
0x02, 0x67, 0x00, 0x2D,
|
||||||
|
0x02, 0xAD, 0x0A,
|
||||||
|
0x02, 0x84, 0x00, 0x56,
|
||||||
|
0x02, 0x8B, 0x00, 0xB3,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||||
|
]);
|
||||||
|
|
||||||
|
final decoded = CayenneLppParser.parse(payload);
|
||||||
|
|
||||||
|
expect(decoded.batteryPercentage, isNull);
|
||||||
|
expect(decoded.batteryMilliVolts, isNull);
|
||||||
|
expect(decoded.temperature, closeTo(4.5, 0.1));
|
||||||
|
expect(decoded.humidity, closeTo(59.0, 0.1));
|
||||||
|
expect(decoded.pressure, closeTo(926.6, 0.1));
|
||||||
|
expect(decoded.extraSensorData!['voltage_1'], closeTo(0.0, 0.001));
|
||||||
|
expect(decoded.extraSensorData!['percentage_2'], equals(100.0));
|
||||||
|
expect(decoded.extraSensorData!['uv_2'], equals(1.0));
|
||||||
|
expect(decoded.extraSensorData!['rain_2'], closeTo(17.9, 0.1));
|
||||||
|
expect(decoded.extraSensorData!.containsKey('__source_channel:battery'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
test('unknown sensor type is skipped gracefully', () {
|
test('unknown sensor type is skipped gracefully', () {
|
||||||
final buffer = ByteData(5);
|
final buffer = ByteData(5);
|
||||||
buffer.setUint8(0, 0);
|
buffer.setUint8(0, 0);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
||||||
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
|
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -190,6 +190,139 @@ void main() {
|
|||||||
expect(find.text('12.3 mm'), findsOneWidget);
|
expect(find.text('12.3 mm'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('renders generic percentage separately from UV for weather payload', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1600, 2600);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x49;
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
0x01, 0x74, 0x00, 0x00,
|
||||||
|
0x02, 0x78, 0x64,
|
||||||
|
0x02, 0x73, 0x24, 0x31,
|
||||||
|
0x02, 0x65, 0x67, 0xDE,
|
||||||
|
0x02, 0x8A, 0xFF, 0xE6,
|
||||||
|
0x02, 0x74, 0x01, 0xE0,
|
||||||
|
0x02, 0x9D, 0x00,
|
||||||
|
0x02, 0x68, 0x76,
|
||||||
|
0x02, 0x81, 0x00, 0xDC,
|
||||||
|
0x02, 0x89, 0x01, 0x36,
|
||||||
|
0x02, 0x67, 0x00, 0x2F,
|
||||||
|
0x02, 0xAD, 0x0A,
|
||||||
|
0x02, 0x84, 0x00, 0x50,
|
||||||
|
0x02, 0x8B, 0x00, 0xB3,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||||
|
]);
|
||||||
|
|
||||||
|
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: CayenneLppParser.parse(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: SensorRefreshState.idle,
|
||||||
|
visibleFields: const {
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'extra:percentage_2',
|
||||||
|
'extra:dew_2',
|
||||||
|
'extra:speed_2',
|
||||||
|
'extra:gust_2',
|
||||||
|
'extra:uv_2',
|
||||||
|
'extra:direction_2',
|
||||||
|
'extra:rain_2',
|
||||||
|
},
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(const {
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'extra:percentage_2',
|
||||||
|
'extra:dew_2',
|
||||||
|
'extra:speed_2',
|
||||||
|
'extra:gust_2',
|
||||||
|
'extra:uv_2',
|
||||||
|
'extra:direction_2',
|
||||||
|
'extra:rain_2',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('sensor_metric_extra:percentage_2')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('sensor_metric_extra:uv_2')), findsOneWidget);
|
||||||
|
expect(find.text('100%'), findsOneWidget);
|
||||||
|
expect(find.text('1%'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
expect(find.text('80°'), findsOneWidget);
|
||||||
|
expect(find.text('8°'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders direction metric with compact compass layout', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x47;
|
||||||
|
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 {'direction_2': 111.0},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: SensorRefreshState.idle,
|
||||||
|
visibleFields: const {'extra:direction_2'},
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(const {'extra:direction_2'}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Direction'), findsOneWidget);
|
||||||
|
expect(find.text('111°'), findsOneWidget);
|
||||||
|
expect(find.text('E'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.navigation_rounded), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('long pressing a telemetry bubble triggers refresh', (
|
testWidgets('long pressing a telemetry bubble triggers refresh', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -221,4 +354,92 @@ void main() {
|
|||||||
|
|
||||||
expect(refreshCount, 1);
|
expect(refreshCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('overflow menu exposes move actions', (tester) async {
|
||||||
|
final contact = buildContact();
|
||||||
|
var moveUpCount = 0;
|
||||||
|
var moveDownCount = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: SensorRefreshState.idle,
|
||||||
|
visibleFields: const {'temperature'},
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
|
||||||
|
onMoveUp: () async {
|
||||||
|
moveUpCount += 1;
|
||||||
|
},
|
||||||
|
onMoveDown: () async {
|
||||||
|
moveDownCount += 1;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Move up'), findsOneWidget);
|
||||||
|
expect(find.text('Move down'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Move down'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(moveUpCount, 0);
|
||||||
|
expect(moveDownCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('overflow menu copies raw response', (tester) async {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x48;
|
||||||
|
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(
|
||||||
|
temperature: 21.5,
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
extraSensorData: const {'__raw_lpp_hex': '01 67 00 d7'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: SensorRefreshState.idle,
|
||||||
|
visibleFields: const {'temperature'},
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Copy raw response'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Copy raw response'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
expect(clipboardData?.text, '01 67 00 d7');
|
||||||
|
expect(find.text('Raw response copied'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user