mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-11 18:26:27 +10:00
783 lines
24 KiB
Dart
783 lines
24 KiB
Dart
import 'dart:typed_data';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../connector/meshcore_connector.dart';
|
||
import '../l10n/l10n.dart';
|
||
import '../utils/lora_airtime.dart';
|
||
import 'image_send_codec_binding.dart';
|
||
|
||
/// Estimated wall clock above which the sheet warns the user explicitly.
|
||
const Duration kImageSendLongAirtime = Duration(seconds: 5);
|
||
|
||
/// What the user committed to when they tapped Send.
|
||
class ImageSendPreviewResult {
|
||
/// The encoded bitstream, ready to be chunked onto the air.
|
||
final Uint8List payload;
|
||
|
||
/// The rate point the payload was encoded at. Always [kImageSendRatePoint] in
|
||
/// this build; carried through because the transport writes it on the wire.
|
||
final ImageCodecRatePoint rate;
|
||
|
||
/// Whether an XOR parity packet should be appended.
|
||
final bool includeParity;
|
||
|
||
/// Packets that will actually be transmitted, parity included.
|
||
final int packetCount;
|
||
|
||
/// Estimated transmitter occupancy, or null if the radio settings were
|
||
/// unknown. This is *not* how long the send takes — see [wallClock].
|
||
final Duration? airtime;
|
||
|
||
/// Estimated wall clock including per-chunk pacing — the figure the user was
|
||
/// actually shown. Null when the radio settings were unknown.
|
||
final Duration? wallClock;
|
||
|
||
const ImageSendPreviewResult({
|
||
required this.payload,
|
||
required this.rate,
|
||
required this.includeParity,
|
||
required this.packetCount,
|
||
required this.airtime,
|
||
required this.wallClock,
|
||
});
|
||
}
|
||
|
||
/// Shows the pre-send image preview.
|
||
///
|
||
/// Returns null if the user cancelled, or an [ImageSendPreviewResult] carrying
|
||
/// the encoded payload if they confirmed. The caller owns the actual send.
|
||
///
|
||
/// [imageBytes] is the raw picked file (JPEG/PNG/…). [originalFileBytes] is its
|
||
/// on-disk size, shown against the transmitted size.
|
||
///
|
||
/// [radio] overrides the live radio parameters; when omitted they are read from
|
||
/// the [MeshCoreConnector] in the widget tree. Pass it explicitly to preview
|
||
/// this sheet without a connector.
|
||
///
|
||
/// There is no quality/rate argument: ft32 ([kImageSendRatePoint]) is the only
|
||
/// shipping model, so the sheet no longer offers a choice.
|
||
Future<ImageSendPreviewResult?> showImageSendPreviewSheet({
|
||
required BuildContext context,
|
||
required Uint8List imageBytes,
|
||
required int originalFileBytes,
|
||
required ImageSendCodec codec,
|
||
ImageSendRadio? radio,
|
||
bool initialParity = true,
|
||
}) {
|
||
return showModalBottomSheet<ImageSendPreviewResult>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
builder: (context) => ImageSendPreviewSheet(
|
||
imageBytes: imageBytes,
|
||
originalFileBytes: originalFileBytes,
|
||
codec: codec,
|
||
radio: radio,
|
||
initialParity: initialParity,
|
||
),
|
||
);
|
||
}
|
||
|
||
class ImageSendPreviewSheet extends StatefulWidget {
|
||
final Uint8List imageBytes;
|
||
final int originalFileBytes;
|
||
final ImageSendCodec codec;
|
||
final ImageSendRadio? radio;
|
||
final bool initialParity;
|
||
|
||
const ImageSendPreviewSheet({
|
||
super.key,
|
||
required this.imageBytes,
|
||
required this.originalFileBytes,
|
||
required this.codec,
|
||
this.radio,
|
||
this.initialParity = true,
|
||
});
|
||
|
||
@override
|
||
State<ImageSendPreviewSheet> createState() => _ImageSendPreviewSheetState();
|
||
}
|
||
|
||
/// The live radio parameters, exactly as the connector exposes them: each may
|
||
/// be null before the device has reported its SELF_INFO frame.
|
||
class ImageSendRadio {
|
||
final int? spreadingFactor;
|
||
final int? bandwidthHz;
|
||
|
||
/// Raw firmware coding rate; [estimateSendFromRadioParams] normalises it.
|
||
final int? rawCodingRate;
|
||
|
||
const ImageSendRadio({
|
||
this.spreadingFactor,
|
||
this.bandwidthHz,
|
||
this.rawCodingRate,
|
||
});
|
||
|
||
bool get isKnown =>
|
||
spreadingFactor != null && bandwidthHz != null && rawCodingRate != null;
|
||
}
|
||
|
||
/// Packet/byte/airtime figures for one rate point.
|
||
///
|
||
/// Before an encode has finished the payload size is only known as a measured
|
||
/// range, so [best] and [worst] differ; afterwards they are identical.
|
||
class _RateEstimate {
|
||
/// Exact payload size once encoded, null while still estimating.
|
||
final int? payloadBytes;
|
||
final SendEstimate best;
|
||
final SendEstimate worst;
|
||
|
||
const _RateEstimate({
|
||
required this.payloadBytes,
|
||
required this.best,
|
||
required this.worst,
|
||
});
|
||
|
||
bool get isExact => payloadBytes != null;
|
||
}
|
||
|
||
class _ImageSendPreviewSheetState extends State<ImageSendPreviewSheet> {
|
||
/// The encoded ft32 bitstream, once the encode finishes.
|
||
Uint8List? _encoded;
|
||
|
||
late bool _parity;
|
||
bool _encoding = false;
|
||
bool _sending = false;
|
||
bool _failed = false;
|
||
|
||
/// Latest radio params seen in build; used by [_onSend] so the confirm path
|
||
/// does not have to touch an InheritedWidget after an await.
|
||
ImageSendRadio? _radio;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_parity = widget.initialParity;
|
||
if (widget.codec.availability == ImageCodecAvailability.ready) {
|
||
_encode();
|
||
}
|
||
}
|
||
|
||
Future<Uint8List?> _encode() async {
|
||
final cached = _encoded;
|
||
if (cached != null) return cached;
|
||
setState(() {
|
||
_encoding = true;
|
||
_failed = false;
|
||
});
|
||
try {
|
||
final bytes =
|
||
await widget.codec.encode(widget.imageBytes, kImageSendRatePoint);
|
||
if (!mounted) return bytes;
|
||
setState(() {
|
||
_encoded = bytes;
|
||
_encoding = false;
|
||
});
|
||
return bytes;
|
||
} catch (_) {
|
||
if (!mounted) return null;
|
||
setState(() {
|
||
_encoding = false;
|
||
_failed = true;
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
ImageSendRadio _radioParams(BuildContext context) {
|
||
final override = widget.radio;
|
||
if (override != null) return override;
|
||
final connector = context.watch<MeshCoreConnector>();
|
||
return ImageSendRadio(
|
||
spreadingFactor: connector.currentSf,
|
||
bandwidthHz: connector.currentBwHz,
|
||
rawCodingRate: connector.currentCr,
|
||
);
|
||
}
|
||
|
||
_RateEstimate _estimateFor(ImageSendRadio radio) {
|
||
final bytes = _encoded;
|
||
if (bytes != null) {
|
||
final exact = _estimate(bytes.length, radio);
|
||
return _RateEstimate(
|
||
payloadBytes: bytes.length,
|
||
best: exact,
|
||
worst: exact,
|
||
);
|
||
}
|
||
// Nothing encoded yet: show the measured ft32 range so the user sees a
|
||
// figure immediately rather than a spinner.
|
||
final stats = ImageCodecRateStats.forRate(kImageSendRatePoint);
|
||
return _RateEstimate(
|
||
payloadBytes: null,
|
||
best: _estimate(stats.minBytes, radio),
|
||
worst: _estimate(stats.maxBytes, radio),
|
||
);
|
||
}
|
||
|
||
SendEstimate _estimate(int payloadBytes, ImageSendRadio radio) {
|
||
return estimateSendFromRadioParams(
|
||
payloadBytes: payloadBytes,
|
||
spreadingFactor: radio.spreadingFactor,
|
||
bandwidthHz: radio.bandwidthHz,
|
||
codingRate: radio.rawCodingRate,
|
||
parity: _parity,
|
||
);
|
||
}
|
||
|
||
Future<void> _onSend() async {
|
||
setState(() => _sending = true);
|
||
final payload = await _encode();
|
||
if (!mounted) return;
|
||
if (payload == null) {
|
||
setState(() => _sending = false);
|
||
return;
|
||
}
|
||
final radio = _radio ?? const ImageSendRadio();
|
||
final estimate = _estimate(payload.length, radio);
|
||
Navigator.of(context).pop(
|
||
ImageSendPreviewResult(
|
||
payload: payload,
|
||
rate: kImageSendRatePoint,
|
||
includeParity: estimate.includesParity,
|
||
packetCount: estimate.chunkCount,
|
||
airtime: estimate.totalAirtime,
|
||
wallClock: estimate.pacedWallClock,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colors = theme.colorScheme;
|
||
final l10n = context.l10n;
|
||
final radio = _radioParams(context);
|
||
_radio = radio;
|
||
final selected = _estimateFor(radio);
|
||
final ready = widget.codec.availability == ImageCodecAvailability.ready;
|
||
|
||
return SafeArea(
|
||
top: false,
|
||
child: DraggableScrollableSheet(
|
||
expand: false,
|
||
initialChildSize: 0.9,
|
||
minChildSize: 0.5,
|
||
maxChildSize: 0.95,
|
||
builder: (context, scrollController) => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_grabHandle(colors),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 8, 8),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
l10n.imageSend_title,
|
||
style: theme.textTheme.titleLarge,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
tooltip: l10n.imageSend_cancel,
|
||
onPressed: _sending
|
||
? null
|
||
: () => Navigator.of(context).maybePop(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView(
|
||
controller: scrollController,
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||
children: [
|
||
_preview(theme, colors),
|
||
const SizedBox(height: 16),
|
||
if (!ready) _unavailableBanner(theme, colors),
|
||
if (ready) ...[
|
||
// Packet count and time on air lead: with the quality
|
||
// selector gone they are the only decision the user makes.
|
||
_headlineStats(theme, colors, selected),
|
||
const SizedBox(height: 16),
|
||
_sizeRow(theme, colors, selected),
|
||
const SizedBox(height: 12),
|
||
// Warnings sit directly under the figures they qualify.
|
||
..._warnings(theme, colors, radio, selected),
|
||
const SizedBox(height: 4),
|
||
_parityTile(theme, colors),
|
||
if (_failed) ...[
|
||
const SizedBox(height: 12),
|
||
_banner(
|
||
colors: colors,
|
||
theme: theme,
|
||
icon: Icons.error_outline,
|
||
background: colors.errorContainer,
|
||
foreground: colors.onErrorContainer,
|
||
title: l10n.imageSend_encodeFailed,
|
||
body: null,
|
||
),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
),
|
||
_actions(theme, ready),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _grabHandle(ColorScheme colors) => Container(
|
||
width: 36,
|
||
height: 4,
|
||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: colors.onSurfaceVariant.withValues(alpha: 0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
);
|
||
|
||
/// The square render. [BoxFit.fill] on a 1:1 box is exactly the 512x512
|
||
/// centre crop the codec will take, so what is shown is what is sent.
|
||
///
|
||
/// The preview is deliberately capped in height: the packet count and airtime
|
||
/// below it are the reason this screen exists and must not be pushed off the
|
||
/// first screenful by a full-width square.
|
||
Widget _preview(ThemeData theme, ColorScheme colors) {
|
||
final maxSide = MediaQuery.sizeOf(context).height * 0.28;
|
||
return Column(
|
||
children: [
|
||
ConstrainedBox(
|
||
constraints: BoxConstraints(maxHeight: maxSide, maxWidth: maxSide),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: AspectRatio(
|
||
aspectRatio: 1,
|
||
child: Container(
|
||
color: colors.surfaceContainerHighest,
|
||
child: Image.memory(
|
||
widget.imageBytes,
|
||
fit: BoxFit.fill,
|
||
alignment: Alignment.center,
|
||
gaplessPlayback: true,
|
||
errorBuilder: (context, error, stack) => Center(
|
||
child: Icon(
|
||
Icons.broken_image_outlined,
|
||
size: 48,
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
context.l10n.imageSend_cropNote,
|
||
textAlign: TextAlign.center,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _sizeRow(
|
||
ThemeData theme,
|
||
ColorScheme colors,
|
||
_RateEstimate estimate,
|
||
) {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_sizeChip(
|
||
theme,
|
||
colors,
|
||
context.l10n.imageSend_originalSize,
|
||
_formatBytes(widget.originalFileBytes),
|
||
strikethrough: true,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Icon(Icons.arrow_forward, size: 18, color: colors.onSurfaceVariant),
|
||
),
|
||
_sizeChip(
|
||
theme,
|
||
colors,
|
||
context.l10n.imageSend_onAirSize,
|
||
_onAirBytesText(estimate),
|
||
emphasised: true,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _sizeChip(
|
||
ThemeData theme,
|
||
ColorScheme colors,
|
||
String label,
|
||
String value, {
|
||
bool strikethrough = false,
|
||
bool emphasised = false,
|
||
}) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
Text(
|
||
value,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
color: emphasised ? colors.primary : colors.onSurface,
|
||
decoration: strikethrough ? TextDecoration.lineThrough : null,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// The point of the whole screen: what this send costs, in big type.
|
||
///
|
||
/// Only two figures, both large: the packet count and the realistic time the
|
||
/// send will take. The byte sizes moved down to [_sizeRow] — with the quality
|
||
/// selector gone there is room to give these two the whole width.
|
||
Widget _headlineStats(
|
||
ThemeData theme,
|
||
ColorScheme colors,
|
||
_RateEstimate estimate,
|
||
) {
|
||
final localizations = context.l10n;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: _stat(
|
||
theme,
|
||
colors,
|
||
localizations.imageSend_packetsLabel,
|
||
_packetCountText(estimate),
|
||
),
|
||
),
|
||
_statDivider(colors),
|
||
Expanded(
|
||
child: _stat(
|
||
theme,
|
||
colors,
|
||
localizations.imageSend_airtimeLabel,
|
||
_airtimeText(estimate),
|
||
emphasised: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _statDivider(ColorScheme colors) => Container(
|
||
width: 1,
|
||
height: 44,
|
||
color: colors.outlineVariant,
|
||
);
|
||
|
||
Widget _stat(
|
||
ThemeData theme,
|
||
ColorScheme colors,
|
||
String label,
|
||
String value, {
|
||
bool emphasised = false,
|
||
}) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
value,
|
||
textAlign: TextAlign.center,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
color: emphasised ? colors.primary : colors.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
label,
|
||
textAlign: TextAlign.center,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _parityTile(ThemeData theme, ColorScheme colors) {
|
||
final localizations = context.l10n;
|
||
return SwitchListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
value: _parity,
|
||
onChanged: _sending ? null : (v) => setState(() => _parity = v),
|
||
secondary: const Icon(Icons.shield_outlined, size: 20),
|
||
title: Text(localizations.imageSend_parityTitle),
|
||
subtitle: Text(
|
||
localizations.imageSend_paritySubtitle,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _warnings(
|
||
ThemeData theme,
|
||
ColorScheme colors,
|
||
ImageSendRadio radio,
|
||
_RateEstimate estimate,
|
||
) {
|
||
final localizations = context.l10n;
|
||
final widgets = <Widget>[];
|
||
if (!radio.isKnown) {
|
||
widgets.add(
|
||
_banner(
|
||
colors: colors,
|
||
theme: theme,
|
||
icon: Icons.settings_input_antenna,
|
||
background: colors.errorContainer,
|
||
foreground: colors.onErrorContainer,
|
||
title: localizations.imageSend_radioUnknownTitle,
|
||
body: localizations.imageSend_radioUnknownBody,
|
||
),
|
||
);
|
||
} else {
|
||
// Compared against the paced wall clock, not raw airtime: the warning is
|
||
// about how long the user waits, which is the figure shown above it.
|
||
final worst = estimate.worst.pacedWallClock;
|
||
if (worst != null && worst >= kImageSendLongAirtime) {
|
||
widgets.add(
|
||
_banner(
|
||
colors: colors,
|
||
theme: theme,
|
||
icon: Icons.hourglass_bottom,
|
||
background: colors.tertiaryContainer,
|
||
foreground: colors.onTertiaryContainer,
|
||
title: localizations.imageSend_longSendTitle,
|
||
body: localizations.imageSend_longSendBody(
|
||
_formatDuration(worst),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
widgets.add(const SizedBox(height: 8));
|
||
widgets.add(
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(Icons.hub_outlined, size: 16, color: colors.onSurfaceVariant),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
localizations.imageSend_floodNote,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return widgets;
|
||
}
|
||
|
||
/// The "why can't I send?" banner.
|
||
///
|
||
/// For [ImageCodecAvailability.unavailable] the generic localised string says
|
||
/// only that the codec cannot run, which leaves the user with no idea whether
|
||
/// to wait, download something, or give up. The codec knows exactly which
|
||
/// permanent property of the build it hit, so
|
||
/// [ImageSendCodec.unavailableReason] REPLACES the generic string whenever it
|
||
/// is non-empty. It falls back to the localised generic only when the codec
|
||
/// declined to say why.
|
||
Widget _unavailableBanner(ThemeData theme, ColorScheme colors) {
|
||
final localizations = context.l10n;
|
||
final String message;
|
||
switch (widget.codec.availability) {
|
||
case ImageCodecAvailability.disabled:
|
||
message = localizations.imageSend_codecDisabled;
|
||
break;
|
||
case ImageCodecAvailability.downloading:
|
||
message = localizations.imageSend_codecDownloading;
|
||
break;
|
||
case ImageCodecAvailability.unavailable:
|
||
final reason = widget.codec.unavailableReason?.trim();
|
||
message = (reason == null || reason.isEmpty)
|
||
? localizations.imageSend_codecUnavailable
|
||
: reason;
|
||
break;
|
||
case ImageCodecAvailability.ready:
|
||
return const SizedBox.shrink();
|
||
}
|
||
return _banner(
|
||
colors: colors,
|
||
theme: theme,
|
||
icon: Icons.info_outline,
|
||
background: colors.surfaceContainerHighest,
|
||
foreground: colors.onSurfaceVariant,
|
||
title: message,
|
||
body: null,
|
||
);
|
||
}
|
||
|
||
Widget _banner({
|
||
required ColorScheme colors,
|
||
required ThemeData theme,
|
||
required IconData icon,
|
||
required Color background,
|
||
required Color foreground,
|
||
required String title,
|
||
required String? body,
|
||
}) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: background,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(icon, size: 20, color: foreground),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
color: foreground,
|
||
),
|
||
),
|
||
if (body != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
body,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: foreground,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _actions(ThemeData theme, bool ready) {
|
||
final localizations = context.l10n;
|
||
final busy = _sending || _encoding;
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed:
|
||
_sending ? null : () => Navigator.of(context).maybePop(),
|
||
child: Text(localizations.imageSend_cancel),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: FilledButton.icon(
|
||
onPressed: (!ready || busy) ? null : _onSend,
|
||
icon: busy
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.send),
|
||
label: Text(localizations.imageSend_send),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Total bytes actually put on the air (headers + payload + parity).
|
||
String _onAirBytesText(_RateEstimate estimate) {
|
||
final lo = estimate.best.totalBytes;
|
||
final hi = estimate.worst.totalBytes;
|
||
if (lo == hi) return _formatBytes(lo);
|
||
return _rangeText(_formatBytes(lo), _formatBytes(hi));
|
||
}
|
||
|
||
/// Bare packet count, e.g. "3" or "2–3".
|
||
String _packetCountText(_RateEstimate estimate) {
|
||
final lo = estimate.best.chunkCount;
|
||
final hi = estimate.worst.chunkCount;
|
||
if (lo == hi) return '$lo';
|
||
return _rangeText('$lo', '$hi');
|
||
}
|
||
|
||
/// The headline time figure.
|
||
///
|
||
/// This is [SendEstimate.pacedWallClock], NOT raw airtime: a 2-3 packet image
|
||
/// is paced, so raw transmitter occupancy would understate the wait the user
|
||
/// is being asked to accept. Renders `imageSend_unknownValue`
|
||
/// ("—") whenever the radio parameters are unknown — areLoRaParamsValid() has
|
||
/// already forced the estimate's time fields to null in that case, and a
|
||
/// fabricated ETA is worse than none on a screen whose whole purpose is
|
||
/// informed consent.
|
||
String _airtimeText(_RateEstimate estimate) {
|
||
final l10n = context.l10n;
|
||
final min = estimate.best.pacedWallClock;
|
||
final max = estimate.worst.pacedWallClock;
|
||
if (min == null || max == null) return l10n.imageSend_unknownValue;
|
||
if (min == max) return _formatDuration(max);
|
||
return _rangeText(_formatDuration(min), _formatDuration(max));
|
||
}
|
||
|
||
String _rangeText(String min, String max) =>
|
||
context.l10n.imageSend_range(min, max);
|
||
|
||
String _formatDuration(Duration d) {
|
||
final l10n = context.l10n;
|
||
final totalSeconds = d.inMilliseconds / 1000.0;
|
||
if (totalSeconds < 60) {
|
||
final text = totalSeconds < 10
|
||
? totalSeconds.toStringAsFixed(1)
|
||
: totalSeconds.round().toString();
|
||
return l10n.imageSend_secondsValue(text);
|
||
}
|
||
final minutes = d.inMinutes;
|
||
final seconds = d.inSeconds - minutes * 60;
|
||
return l10n.imageSend_minutesSecondsValue('$minutes', '$seconds');
|
||
}
|
||
|
||
String _formatBytes(int bytes) {
|
||
if (bytes < 1024) return '$bytes B';
|
||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} kB';
|
||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||
}
|
||
}
|