mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
* feat: Enhance privacy settings and telemetry - Implemented telemetry options for contacts, allowing users to enable or disable telemetry data sharing. - Introduced a clear chat option in the chat interface for better message management. - Updated the telemetry screen to handle telemetry data for contacts, including battery level. - Refactored contact settings to include telemetry options and improved UI for better user experience. * feat: Refactor repeater resolution logic across multiple screens
703 lines
22 KiB
Dart
703 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../models/contact.dart';
|
|
import '../models/path_selection.dart';
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import '../services/repeater_command_service.dart';
|
|
import '../utils/battery_utils.dart';
|
|
import '../widgets/path_management_dialog.dart';
|
|
|
|
class RepeaterStatusScreen extends StatefulWidget {
|
|
final Contact repeater;
|
|
final String password;
|
|
|
|
const RepeaterStatusScreen({
|
|
super.key,
|
|
required this.repeater,
|
|
required this.password,
|
|
});
|
|
|
|
@override
|
|
State<RepeaterStatusScreen> createState() => _RepeaterStatusScreenState();
|
|
}
|
|
|
|
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|
static const int _statusPayloadOffset = 8;
|
|
static const int _statusStatsSize = 52;
|
|
static const int _statusResponseBytes =
|
|
_statusPayloadOffset + _statusStatsSize;
|
|
|
|
bool _isLoading = false;
|
|
StreamSubscription<Uint8List>? _frameSubscription;
|
|
RepeaterCommandService? _commandService;
|
|
Timer? _statusTimeout;
|
|
DateTime? _statusRequestedAt;
|
|
int? _batteryMv;
|
|
int? _uptimeSecs;
|
|
int? _queueLen;
|
|
int? _debugFlags;
|
|
int? _lastRssi;
|
|
double? _lastSnr;
|
|
int? _noiseFloor;
|
|
int? _txAirSecs;
|
|
int? _rxAirSecs;
|
|
int? _packetsSent;
|
|
int? _packetsRecv;
|
|
int? _floodTx;
|
|
int? _directTx;
|
|
int? _floodRx;
|
|
int? _directRx;
|
|
int? _dupFlood;
|
|
int? _dupDirect;
|
|
PathSelection? _pendingStatusSelection;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
_commandService = RepeaterCommandService(connector);
|
|
_setupMessageListener();
|
|
_loadStatus();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_frameSubscription?.cancel();
|
|
_commandService?.dispose();
|
|
_statusTimeout?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _setupMessageListener() {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
|
|
// Listen for incoming text messages from the repeater
|
|
_frameSubscription = connector.receivedFrames.listen((frame) {
|
|
if (frame.isEmpty) return;
|
|
|
|
// Check if it's a text message response
|
|
if (frame[0] == pushCodeStatusResponse) {
|
|
_handleStatusResponse(frame);
|
|
} else if (frame[0] == respCodeContactMsgRecv ||
|
|
frame[0] == respCodeContactMsgRecvV3) {
|
|
_handleTextMessageResponse(frame);
|
|
}
|
|
});
|
|
}
|
|
|
|
int _resolveRepeaterIndex = -1;
|
|
|
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
|
if (_resolveRepeaterIndex >= 0 &&
|
|
_resolveRepeaterIndex < connector.contacts.length &&
|
|
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
|
widget.repeater.publicKeyHex) {
|
|
return connector.contacts[_resolveRepeaterIndex];
|
|
}
|
|
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
|
);
|
|
if (_resolveRepeaterIndex == -1) {
|
|
return widget.repeater;
|
|
}
|
|
return connector.contacts[_resolveRepeaterIndex];
|
|
}
|
|
|
|
void _handleTextMessageResponse(Uint8List frame) {
|
|
final parsed = parseContactMessageText(frame);
|
|
if (parsed == null) return;
|
|
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
|
|
|
// Notify command service of response (for retry handling)
|
|
_commandService?.handleResponse(widget.repeater, parsed.text);
|
|
|
|
// Parse status responses
|
|
_parseStatusResponse(parsed.text);
|
|
_recordStatusResult(true);
|
|
}
|
|
|
|
void _handleStatusResponse(Uint8List frame) {
|
|
if (frame.length < 8) return;
|
|
final prefix = frame.sublist(2, 8);
|
|
if (!_matchesRepeaterPrefix(prefix)) return;
|
|
|
|
if (frame.length < _statusResponseBytes) return;
|
|
|
|
final data = ByteData.sublistView(
|
|
frame,
|
|
_statusPayloadOffset,
|
|
_statusResponseBytes,
|
|
);
|
|
int offset = 0;
|
|
|
|
final batteryMv = data.getUint16(offset, Endian.little);
|
|
offset += 2;
|
|
final queueLen = data.getUint16(offset, Endian.little);
|
|
offset += 2;
|
|
final noiseFloor = data.getInt16(offset, Endian.little);
|
|
offset += 2;
|
|
final lastRssi = data.getInt16(offset, Endian.little);
|
|
offset += 2;
|
|
final packetsRecv = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final packetsSent = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final txAirSecs = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final uptimeSecs = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final floodTx = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final directTx = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final floodRx = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final directRx = data.getUint32(offset, Endian.little);
|
|
offset += 4;
|
|
final errEvents = data.getUint16(offset, Endian.little);
|
|
offset += 2;
|
|
final lastSnrRaw = data.getInt16(offset, Endian.little);
|
|
offset += 2;
|
|
final directDups = data.getUint16(offset, Endian.little);
|
|
offset += 2;
|
|
final floodDups = data.getUint16(offset, Endian.little);
|
|
offset += 2;
|
|
final rxAirSecs = data.getUint32(offset, Endian.little);
|
|
|
|
_statusTimeout?.cancel();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_batteryMv = batteryMv;
|
|
_queueLen = queueLen;
|
|
_noiseFloor = noiseFloor;
|
|
_lastRssi = lastRssi;
|
|
_packetsRecv = packetsRecv;
|
|
_packetsSent = packetsSent;
|
|
_txAirSecs = txAirSecs;
|
|
_rxAirSecs = rxAirSecs;
|
|
_uptimeSecs = uptimeSecs;
|
|
_floodTx = floodTx;
|
|
_directTx = directTx;
|
|
_floodRx = floodRx;
|
|
_directRx = directRx;
|
|
_debugFlags = errEvents;
|
|
_lastSnr = lastSnrRaw / 4.0;
|
|
_dupDirect = directDups;
|
|
_dupFlood = floodDups;
|
|
});
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
connector.updateRepeaterBatterySnapshot(
|
|
widget.repeater.publicKeyHex,
|
|
batteryMv,
|
|
source: 'status_binary',
|
|
);
|
|
_recordStatusResult(true);
|
|
}
|
|
|
|
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
|
final target = widget.repeater.publicKey;
|
|
if (target.length < 6 || prefix.length < 6) return false;
|
|
for (int i = 0; i < 6; i++) {
|
|
if (prefix[i] != target[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void _parseStatusResponse(String response) {
|
|
final trimmed = response.trim();
|
|
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
try {
|
|
final data = jsonDecode(trimmed) as Map<String, dynamic>;
|
|
if (data.containsKey('battery_mv')) {
|
|
_batteryMv = _asInt(data['battery_mv']);
|
|
_uptimeSecs = _asInt(data['uptime_secs']);
|
|
_queueLen = _asInt(data['queue_len']);
|
|
_debugFlags = _asInt(data['errors']);
|
|
final batteryMv = _batteryMv;
|
|
if (batteryMv != null) {
|
|
final connector = Provider.of<MeshCoreConnector>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
connector.updateRepeaterBatterySnapshot(
|
|
widget.repeater.publicKeyHex,
|
|
batteryMv,
|
|
source: 'status_text',
|
|
);
|
|
}
|
|
} else if (data.containsKey('noise_floor')) {
|
|
_noiseFloor = _asInt(data['noise_floor']);
|
|
_lastRssi = _asInt(data['last_rssi']);
|
|
_lastSnr = _asDouble(data['last_snr']);
|
|
_txAirSecs = _asInt(data['tx_air_secs']);
|
|
_rxAirSecs = _asInt(data['rx_air_secs']);
|
|
} else if (data.containsKey('recv') || data.containsKey('sent')) {
|
|
_packetsRecv = _asInt(data['recv']);
|
|
_packetsSent = _asInt(data['sent']);
|
|
_floodTx = _asInt(data['flood_tx']);
|
|
_directTx = _asInt(data['direct_tx']);
|
|
_floodRx = _asInt(data['flood_rx']);
|
|
_directRx = _asInt(data['direct_rx']);
|
|
_dupFlood = _asInt(data['dup_flood']);
|
|
_dupDirect = _asInt(data['dup_direct']);
|
|
}
|
|
} catch (_) {
|
|
// Ignore parse failures for non-JSON responses.
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
Future<void> _loadStatus() async {
|
|
if (_commandService == null) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_statusRequestedAt = DateTime.now();
|
|
_pendingStatusSelection = null;
|
|
_batteryMv = null;
|
|
_uptimeSecs = null;
|
|
_queueLen = null;
|
|
_debugFlags = null;
|
|
_lastRssi = null;
|
|
_lastSnr = null;
|
|
_noiseFloor = null;
|
|
_txAirSecs = null;
|
|
_rxAirSecs = null;
|
|
_packetsSent = null;
|
|
_packetsRecv = null;
|
|
_floodTx = null;
|
|
_directTx = null;
|
|
_floodRx = null;
|
|
_directRx = null;
|
|
_dupFlood = null;
|
|
_dupDirect = null;
|
|
});
|
|
|
|
try {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final repeater = _resolveRepeater(connector);
|
|
final selection = await connector.preparePathForContactSend(repeater);
|
|
_pendingStatusSelection = selection;
|
|
final frame = buildSendStatusRequestFrame(repeater.publicKey);
|
|
await connector.sendFrame(frame);
|
|
|
|
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
|
var messageBytes = frame.length >= _statusResponseBytes
|
|
? frame.length
|
|
: _statusResponseBytes;
|
|
if (messageBytes < maxFrameSize) {
|
|
messageBytes = maxFrameSize;
|
|
}
|
|
final timeoutMs = connector.calculateTimeout(
|
|
pathLength: pathLengthValue,
|
|
messageBytes: messageBytes,
|
|
);
|
|
_statusTimeout?.cancel();
|
|
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.repeater_statusRequestTimeout),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
_recordStatusResult(false);
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.repeater_errorLoadingStatus(e.toString()),
|
|
),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
_recordStatusResult(false);
|
|
}
|
|
}
|
|
|
|
void _recordStatusResult(bool success) {
|
|
final selection = _pendingStatusSelection;
|
|
if (selection == null) return;
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final repeater = _resolveRepeater(connector);
|
|
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
|
_pendingStatusSelection = null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
final repeater = _resolveRepeater(connector);
|
|
final isFloodMode = repeater.pathOverride == -1;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(l10n.repeater_statusTitle),
|
|
Text(
|
|
repeater.name,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
centerTitle: false,
|
|
actions: [
|
|
PopupMenuButton<String>(
|
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
|
tooltip: l10n.repeater_routingMode,
|
|
onSelected: (mode) async {
|
|
if (mode == 'flood') {
|
|
await connector.setPathOverride(repeater, pathLen: -1);
|
|
} else {
|
|
await connector.setPathOverride(repeater, pathLen: null);
|
|
}
|
|
},
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
value: 'auto',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.auto_mode,
|
|
size: 20,
|
|
color: !isFloodMode
|
|
? Theme.of(context).primaryColor
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_autoUseSavedPath,
|
|
style: TextStyle(
|
|
fontWeight: !isFloodMode
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'flood',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.waves,
|
|
size: 20,
|
|
color: isFloodMode
|
|
? Theme.of(context).primaryColor
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_forceFloodMode,
|
|
style: TextStyle(
|
|
fontWeight: isFloodMode
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.timeline),
|
|
tooltip: l10n.repeater_pathManagement,
|
|
onPressed: () =>
|
|
PathManagementDialog.show(context, contact: repeater),
|
|
),
|
|
IconButton(
|
|
icon: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
onPressed: _isLoading ? null : _loadStatus,
|
|
tooltip: l10n.repeater_refresh,
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: RefreshIndicator(
|
|
onRefresh: _loadStatus,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildSystemInfoCard(),
|
|
const SizedBox(height: 16),
|
|
_buildRadioStatsCard(),
|
|
const SizedBox(height: 16),
|
|
_buildPacketStatsCard(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSystemInfoCard() {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_systemInformation,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
_buildInfoRow(l10n.repeater_battery, _batteryText()),
|
|
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
|
|
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
|
|
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
|
|
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRadioStatsCard() {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.radio,
|
|
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_radioStatistics,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
_buildInfoRow(
|
|
l10n.repeater_lastRssi,
|
|
_formatValue(_lastRssi, suffix: ' dB'),
|
|
),
|
|
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
|
|
_buildInfoRow(
|
|
l10n.repeater_noiseFloor,
|
|
_formatValue(_noiseFloor, suffix: ' dB'),
|
|
),
|
|
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
|
|
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPacketStatsCard() {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.analytics,
|
|
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_packetStatistics,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
|
|
_buildInfoRow(l10n.repeater_received, _packetRxText()),
|
|
_buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 130,
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
int? _asInt(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is int) return value;
|
|
if (value is double) return value.round();
|
|
return int.tryParse(value.toString());
|
|
}
|
|
|
|
double? _asDouble(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is double) return value;
|
|
if (value is int) return value.toDouble();
|
|
return double.tryParse(value.toString());
|
|
}
|
|
|
|
String _batteryText() {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
final batteryMv =
|
|
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
|
|
_batteryMv;
|
|
if (batteryMv == null) return '—';
|
|
final percent = estimateBatteryPercentFromMillivolts(
|
|
batteryMv,
|
|
_batteryChemistry(),
|
|
);
|
|
final volts = (batteryMv / 1000.0).toStringAsFixed(2);
|
|
return '$percent% / ${volts}V';
|
|
}
|
|
|
|
String _batteryChemistry() {
|
|
final settingsService = context.read<AppSettingsService>();
|
|
return settingsService.batteryChemistryForRepeater(
|
|
widget.repeater.publicKeyHex,
|
|
);
|
|
}
|
|
|
|
String _clockText() {
|
|
if (_statusRequestedAt == null) return '—';
|
|
final dt = _statusRequestedAt!;
|
|
final date = '${dt.day}/${dt.month}/${dt.year}';
|
|
final time =
|
|
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
|
return '$date $time';
|
|
}
|
|
|
|
String _formatDuration(int? seconds) {
|
|
if (seconds == null) return '—';
|
|
final l10n = context.l10n;
|
|
final days = seconds ~/ 86400;
|
|
final hours = (seconds % 86400) ~/ 3600;
|
|
final minutes = (seconds % 3600) ~/ 60;
|
|
final secs = seconds % 60;
|
|
return l10n.repeater_daysHoursMinsSecs(days, hours, minutes, secs);
|
|
}
|
|
|
|
String _packetTxText() {
|
|
if (_packetsSent == null) return '—';
|
|
final l10n = context.l10n;
|
|
final flood = _formatValue(_floodTx);
|
|
final direct = _formatValue(_directTx);
|
|
return l10n.repeater_packetTxTotal(_packetsSent!, flood, direct);
|
|
}
|
|
|
|
String _packetRxText() {
|
|
if (_packetsRecv == null) return '—';
|
|
final l10n = context.l10n;
|
|
final flood = _formatValue(_floodRx);
|
|
final direct = _formatValue(_directRx);
|
|
return l10n.repeater_packetRxTotal(_packetsRecv!, flood, direct);
|
|
}
|
|
|
|
String _duplicateText() {
|
|
final l10n = context.l10n;
|
|
if (_dupFlood != null || _dupDirect != null) {
|
|
final flood = _formatValue(_dupFlood);
|
|
final direct = _formatValue(_dupDirect);
|
|
return l10n.repeater_duplicatesFloodDirect(flood, direct);
|
|
}
|
|
if (_packetsRecv == null || _floodRx == null || _directRx == null) {
|
|
return '—';
|
|
}
|
|
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
|
if (dupTotal < 0) return '—';
|
|
return l10n.repeater_duplicatesTotal(dupTotal);
|
|
}
|
|
|
|
String _formatValue(num? value, {String? suffix}) {
|
|
if (value == null) return '—';
|
|
return suffix == null ? value.toString() : '$value$suffix';
|
|
}
|
|
|
|
String _formatSnr(double? snr) {
|
|
if (snr == null) return '—';
|
|
return snr.toStringAsFixed(2);
|
|
}
|
|
}
|