Files
meshcore-open/lib/widgets/image_send_preview_sheet.dart
T
2026-08-10 23:20:46 -07:00

783 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 "23".
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';
}
}