mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix DM private media sending
This commit is contained in:
@@ -9,6 +9,7 @@ import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/image_provider.dart' as ip;
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import 'transfer_timeout.dart';
|
||||
|
||||
/// A message bubble that shows a received or sent image.
|
||||
///
|
||||
@@ -90,6 +91,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
received: received,
|
||||
total: total,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -131,6 +135,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required int received,
|
||||
required int total,
|
||||
required ImageEnvelope envelope,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
required int? radioCr,
|
||||
}) {
|
||||
if (isComplete && imageBytes != null) {
|
||||
return AspectRatio(
|
||||
@@ -167,7 +174,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
] else ...[
|
||||
// Tap-to-load icon.
|
||||
IconButton(
|
||||
onPressed: () => _requestAndFetch(envelope),
|
||||
onPressed: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: widget.message.pathLen,
|
||||
),
|
||||
icon: const Icon(Icons.download_rounded, size: 40),
|
||||
color: Colors.white70,
|
||||
tooltip: 'Load image',
|
||||
@@ -179,7 +192,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestAndFetch(ImageEnvelope envelope) async {
|
||||
Future<void> _requestAndFetch(
|
||||
ImageEnvelope envelope, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
var sender = _resolveSender(envelope);
|
||||
if (sender == null) {
|
||||
@@ -242,13 +261,26 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset after 30s so user can retry if transfer stalls.
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||
sizeBytes: missing.isEmpty
|
||||
? envelope.sizeBytes
|
||||
: (envelope.sizeBytes * missing.length / envelope.total).round(),
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
_requestTimeoutTimer?.cancel();
|
||||
_requestTimeoutTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) {
|
||||
setState(() => _isRequesting = false);
|
||||
}
|
||||
});
|
||||
_requestTimeoutTimer = TransferTimeout.start(
|
||||
txEstimate: txEstimate,
|
||||
onTimeout: () {
|
||||
if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) {
|
||||
setState(() => _isRequesting = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Contact? _resolveSender(ImageEnvelope envelope) {
|
||||
|
||||
24
lib/widgets/messages/transfer_timeout.dart
Normal file
24
lib/widgets/messages/transfer_timeout.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Calculates a transfer timeout as 2× the estimated LoRa airtime,
|
||||
/// with a minimum of 30 seconds.
|
||||
///
|
||||
/// Used by [ImageMessageBubble] and [VoiceMessageBubble] to reset the
|
||||
/// "loading" spinner when a transfer stalls, allowing the user to retry.
|
||||
class TransferTimeout {
|
||||
static const Duration _minimum = Duration(seconds: 30);
|
||||
|
||||
/// Start a one-shot timer based on [txEstimate] × 2 (min 30s).
|
||||
///
|
||||
/// [onTimeout] is called on the UI thread when the timer fires.
|
||||
/// Returns the [Timer] so the caller can cancel it (e.g. on dispose or
|
||||
/// when the transfer completes).
|
||||
static Timer start({
|
||||
required Duration txEstimate,
|
||||
required void Function() onTimeout,
|
||||
}) {
|
||||
final timeout = txEstimate * 2;
|
||||
final effective = timeout < _minimum ? _minimum : timeout;
|
||||
return Timer(effective, onTimeout);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -8,6 +9,7 @@ import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
import 'transfer_timeout.dart';
|
||||
|
||||
/// A message bubble that shows a voice recording with play/stop controls.
|
||||
class VoiceMessageBubble extends StatefulWidget {
|
||||
@@ -28,6 +30,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
bool _isRequesting = false;
|
||||
bool _autoPlayWhenReady = false;
|
||||
String? _errorText;
|
||||
Timer? _requestTimeoutTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_requestTimeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -106,7 +115,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
}
|
||||
await _requestAndPlayVoice(voiceId);
|
||||
await _requestAndPlayVoice(
|
||||
voiceId,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: widget.message.pathLen,
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
@@ -174,13 +190,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestAndPlayVoice(String sessionId) async {
|
||||
Future<void> _requestAndPlayVoice(
|
||||
String sessionId, {
|
||||
VoiceEnvelope? envelope,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
var sender = _resolveSenderContact();
|
||||
if (sender == null) {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
// Retry once after refreshing contacts; resumable sessions may outlive
|
||||
// the in-memory contact cache.
|
||||
await connectionProvider.getContacts();
|
||||
if (!mounted) return;
|
||||
sender = _resolveSenderContact();
|
||||
@@ -221,7 +242,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
);
|
||||
if (!sent) {
|
||||
_setUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final txEstimate = envelope != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
packetCount: envelope.total,
|
||||
mode: envelope.mode,
|
||||
durationMs: envelope.durationMs,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: const Duration(seconds: 15);
|
||||
_requestTimeoutTimer?.cancel();
|
||||
_requestTimeoutTimer = TransferTimeout.start(
|
||||
txEstimate: txEstimate,
|
||||
onTimeout: () {
|
||||
if (mounted && _isRequesting) {
|
||||
_setUnavailable();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _setUnavailable() {
|
||||
|
||||
Reference in New Issue
Block a user