mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: Reuse Ping sheet for Sensors #123
This commit is contained in:
@@ -15,6 +15,7 @@ import '../../services/location_tracking_service.dart';
|
||||
import '../../services/message_destination_preferences.dart';
|
||||
import '../../services/path_history_service.dart';
|
||||
import 'contact_route_dialog.dart';
|
||||
import 'ping_contact_sheet.dart';
|
||||
import 'contact_trace_sheet.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
@@ -684,7 +685,7 @@ class ContactTile extends StatelessWidget {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => _PingRelaySheet(contact: contact),
|
||||
builder: (context) => PingContactSheet(contact: contact),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
629
lib/widgets/contacts/ping_contact_sheet.dart
Normal file
629
lib/widgets/contacts/ping_contact_sheet.dart
Normal file
@@ -0,0 +1,629 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../services/location_tracking_service.dart';
|
||||
import '../../utils/link_quality.dart';
|
||||
import '../../utils/time_ago_extensions.dart';
|
||||
|
||||
class PingContactSheet extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const PingContactSheet({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<PingContactSheet> createState() => _PingContactSheetState();
|
||||
}
|
||||
|
||||
class _PingEntry {
|
||||
final RelayPingResult result;
|
||||
final DateTime timestamp;
|
||||
final String? distance;
|
||||
|
||||
const _PingEntry({
|
||||
required this.result,
|
||||
required this.timestamp,
|
||||
this.distance,
|
||||
});
|
||||
}
|
||||
|
||||
class _PingContactSheetState extends State<PingContactSheet> {
|
||||
static const Duration _autoPingInterval = Duration(seconds: 2);
|
||||
|
||||
bool _pinging = false;
|
||||
bool _autoPingEnabled = false;
|
||||
final List<_PingEntry> _history = [];
|
||||
final _PingCuePlayer _cuePlayer = _PingCuePlayer();
|
||||
Timer? _autoPingTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_doPing();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoPingTimer?.cancel();
|
||||
_cuePlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _doPing() async {
|
||||
if (_pinging) {
|
||||
return;
|
||||
}
|
||||
setState(() => _pinging = true);
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final distance = _distanceText();
|
||||
final result = await connectionProvider.pingRelay(widget.contact);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pinging = false;
|
||||
_history.insert(
|
||||
0,
|
||||
_PingEntry(
|
||||
result: result,
|
||||
timestamp: DateTime.now(),
|
||||
distance: distance,
|
||||
),
|
||||
);
|
||||
});
|
||||
await _maybePlayCue(result);
|
||||
}
|
||||
|
||||
Future<void> _maybePlayCue(RelayPingResult result) async {
|
||||
if (!_autoPingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previous = _history.length >= 2 ? _history[1].result : null;
|
||||
await _cuePlayer.playCue(result, previous: previous);
|
||||
}
|
||||
|
||||
void _setAutoPingEnabled(bool enabled) {
|
||||
setState(() {
|
||||
_autoPingEnabled = enabled;
|
||||
});
|
||||
_autoPingTimer?.cancel();
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
_autoPingTimer = Timer.periodic(_autoPingInterval, (_) {
|
||||
unawaited(_doPing());
|
||||
});
|
||||
}
|
||||
|
||||
String? _distanceText() {
|
||||
final location = widget.contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
final currentPosition = LocationTrackingService().currentPosition;
|
||||
if (currentPosition == null) return null;
|
||||
final meters = Geolocator.distanceBetween(
|
||||
currentPosition.latitude,
|
||||
currentPosition.longitude,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
);
|
||||
if (meters < 1000) return '${meters.round()} m';
|
||||
if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
return '${(meters / 1000).toStringAsFixed(1)} km';
|
||||
}
|
||||
|
||||
Widget _buildPill(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: iconColor ?? colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSnrPill(BuildContext context, String direction, double snrDb) {
|
||||
final quality = linkQualityLabel(null, snrDb);
|
||||
final color = linkQualityColor(quality);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
direction == 'there'
|
||||
? Icons.arrow_upward_rounded
|
||||
: Icons.arrow_downward_rounded,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${snrDb.toStringAsFixed(1)} dB',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultRow(BuildContext context, _PingEntry entry, int seq) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final r = entry.result;
|
||||
final age = DateTime.now().difference(entry.timestamp);
|
||||
final timeAgo = age.toLocalizedTimeAgoWithSeconds(context);
|
||||
|
||||
if (!r.success) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: colorScheme.error.withValues(alpha: 0.15),
|
||||
child: Text(
|
||||
'$seq',
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Timeout',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
timeAgo,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
'$seq',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_buildPill(
|
||||
context,
|
||||
icon: Icons.timer_outlined,
|
||||
label: '${r.durationMs} ms',
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildSnrPill(context, 'there', r.snrThere),
|
||||
const SizedBox(width: 6),
|
||||
_buildSnrPill(context, 'back', r.snrBack),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 34, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.distance != null) ...[
|
||||
Icon(
|
||||
Icons.straighten,
|
||||
size: 10,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
entry.distance!,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 10,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
timeAgo,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.alt_route,
|
||||
size: 10,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${r.hopCount} hop${r.hopCount == 1 ? '' : 's'}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ping ${widget.contact.displayName}',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _pinging ? null : _doPing,
|
||||
icon: _pinging
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.network_ping, size: 18),
|
||||
label: Text(_pinging ? 'Pinging...' : 'Ping again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.contact.publicKeyShort,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_autoPingEnabled
|
||||
? Icons.radar_rounded
|
||||
: Icons.radar_outlined,
|
||||
color: _autoPingEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Auto ping',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'2s sonar cues for there/back SNR',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: _autoPingEnabled,
|
||||
onChanged: _setAutoPingEnabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_history.isEmpty && !_pinging)
|
||||
Text(
|
||||
'No ping results yet.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _history.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildResultRow(context, _history[index], index + 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PingCuePlayer {
|
||||
static const double _fairSnrThresholdDb = 0;
|
||||
|
||||
AudioPlayer? _player;
|
||||
|
||||
Future<void> playCue(
|
||||
RelayPingResult result, {
|
||||
RelayPingResult? previous,
|
||||
}) async {
|
||||
_player ??= AudioPlayer();
|
||||
final player = _player!;
|
||||
final filePath = await _writeCueFile(
|
||||
result,
|
||||
previous: previous,
|
||||
);
|
||||
await player.stop();
|
||||
await player.setVolume(1.0);
|
||||
await player.play(DeviceFileSource(filePath));
|
||||
}
|
||||
|
||||
Future<String> _writeCueFile(
|
||||
RelayPingResult result, {
|
||||
RelayPingResult? previous,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/ping_sonar_alert.wav');
|
||||
final samples = _buildCueSamples(result, previous: previous);
|
||||
final wavBytes = _buildWav(samples, sampleRate: 16000);
|
||||
await file.writeAsBytes(wavBytes, flush: true);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Int16List _buildCueSamples(
|
||||
RelayPingResult result, {
|
||||
RelayPingResult? previous,
|
||||
}) {
|
||||
const sampleRate = 16000;
|
||||
final segments = <double>[];
|
||||
if (!result.success) {
|
||||
segments.addAll(
|
||||
_timeoutAlert(sampleRate),
|
||||
);
|
||||
} else {
|
||||
segments.addAll(
|
||||
_tone(
|
||||
sampleRate,
|
||||
frequencyHz: _frequencyForSnr(result.snrThere),
|
||||
durationMs: 90,
|
||||
amplitude: 0.42,
|
||||
),
|
||||
);
|
||||
segments.addAll(_silence(sampleRate, durationMs: 35));
|
||||
segments.addAll(
|
||||
_tone(
|
||||
sampleRate,
|
||||
frequencyHz: _frequencyForSnr(result.snrBack),
|
||||
durationMs: 90,
|
||||
amplitude: 0.42,
|
||||
),
|
||||
);
|
||||
final belowFair =
|
||||
result.snrThere < _fairSnrThresholdDb &&
|
||||
result.snrBack < _fairSnrThresholdDb;
|
||||
final worsening =
|
||||
previous != null &&
|
||||
result.snrThere < previous.snrThere &&
|
||||
result.snrBack < previous.snrBack;
|
||||
if (belowFair && worsening) {
|
||||
segments.addAll(_silence(sampleRate, durationMs: 40));
|
||||
segments.addAll(
|
||||
_descendingAlert(sampleRate, baseFrequencyHz: 520),
|
||||
);
|
||||
}
|
||||
}
|
||||
final pcm = Int16List(segments.length);
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
pcm[i] = (segments[i] * 32767).round().clamp(-32768, 32767);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
double _frequencyForSnr(double snrDb) {
|
||||
final clamped = snrDb.clamp(-12.0, 12.0);
|
||||
final normalized = (clamped + 12.0) / 24.0;
|
||||
return 320 + (normalized * 900);
|
||||
}
|
||||
|
||||
List<double> _descendingAlert(int sampleRate, {required double baseFrequencyHz}) {
|
||||
return <double>[
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: baseFrequencyHz,
|
||||
durationMs: 80,
|
||||
amplitude: 0.44,
|
||||
),
|
||||
..._silence(sampleRate, durationMs: 30),
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: baseFrequencyHz * 0.82,
|
||||
durationMs: 110,
|
||||
amplitude: 0.40,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<double> _timeoutAlert(int sampleRate) {
|
||||
return <double>[
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: 240,
|
||||
durationMs: 180,
|
||||
amplitude: 0.46,
|
||||
),
|
||||
..._silence(sampleRate, durationMs: 60),
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: 180,
|
||||
durationMs: 220,
|
||||
amplitude: 0.52,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<double> _tone(
|
||||
int sampleRate, {
|
||||
required double frequencyHz,
|
||||
required int durationMs,
|
||||
required double amplitude,
|
||||
}) {
|
||||
final sampleCount = (sampleRate * durationMs / 1000).round();
|
||||
return List<double>.generate(sampleCount, (index) {
|
||||
final t = index / sampleRate;
|
||||
final envelope = math.sin(math.pi * index / sampleCount);
|
||||
return math.sin(2 * math.pi * frequencyHz * t) * amplitude * envelope;
|
||||
});
|
||||
}
|
||||
|
||||
List<double> _silence(int sampleRate, {required int durationMs}) {
|
||||
final sampleCount = (sampleRate * durationMs / 1000).round();
|
||||
return List<double>.filled(sampleCount, 0);
|
||||
}
|
||||
|
||||
Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
|
||||
const numChannels = 1;
|
||||
const bitsPerSample = 16;
|
||||
const audioFormat = 1;
|
||||
final dataSize = samples.length * 2;
|
||||
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
|
||||
final blockAlign = numChannels * bitsPerSample ~/ 8;
|
||||
final totalSize = 36 + dataSize;
|
||||
final buffer = ByteData(44 + dataSize);
|
||||
var offset = 0;
|
||||
|
||||
void writeString(String value) {
|
||||
for (final codeUnit in value.codeUnits) {
|
||||
buffer.setUint8(offset++, codeUnit);
|
||||
}
|
||||
}
|
||||
|
||||
void writeUint32(int value) {
|
||||
buffer.setUint32(offset, value, Endian.little);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
void writeUint16(int value) {
|
||||
buffer.setUint16(offset, value, Endian.little);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
writeString('RIFF');
|
||||
writeUint32(totalSize);
|
||||
writeString('WAVE');
|
||||
writeString('fmt ');
|
||||
writeUint32(16);
|
||||
writeUint16(audioFormat);
|
||||
writeUint16(numChannels);
|
||||
writeUint32(sampleRate);
|
||||
writeUint32(byteRate);
|
||||
writeUint16(blockAlign);
|
||||
writeUint16(bitsPerSample);
|
||||
writeString('data');
|
||||
writeUint32(dataSize);
|
||||
|
||||
for (final sample in samples) {
|
||||
buffer.setInt16(offset, sample, Endian.little);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return buffer.buffer.asUint8List();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_player?.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1176,18 +1176,6 @@ class SensorTelemetryCard extends StatelessWidget {
|
||||
label: 'Ping',
|
||||
onTap: () => _handleAction(context, 'ping'),
|
||||
),
|
||||
if (onMoveUp != null)
|
||||
_SensorSheetAction(
|
||||
icon: Icons.arrow_upward_rounded,
|
||||
label: 'Move up',
|
||||
onTap: () => _handleAction(context, 'move_up'),
|
||||
),
|
||||
if (onMoveDown != null)
|
||||
_SensorSheetAction(
|
||||
icon: Icons.arrow_downward_rounded,
|
||||
label: 'Move down',
|
||||
onTap: () => _handleAction(context, 'move_down'),
|
||||
),
|
||||
if (_rawTelemetryHex(contact?.telemetry) != null)
|
||||
_SensorSheetAction(
|
||||
icon: Icons.copy_all_outlined,
|
||||
|
||||
Reference in New Issue
Block a user