diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 261ff7c..ec6aec3 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -13,6 +13,8 @@ class ImageSession { final int width; final int height; final List fragments; // indexed by fragment.index + DateTime? firstFragmentAt; + DateTime? lastFragmentAt; ImageSession({ required this.sessionId, @@ -25,6 +27,21 @@ class ImageSession { int get receivedCount => fragments.where((f) => f != null).length; bool get isComplete => receivedCount == total; + Duration? estimateRemaining() { + if (isComplete) return Duration.zero; + if (firstFragmentAt == null || lastFragmentAt == null) return null; + if (receivedCount < 2) return null; + + final elapsedMs = lastFragmentAt! + .difference(firstFragmentAt!) + .inMilliseconds; + if (elapsedMs <= 0) return null; + final avgMsPerFragment = elapsedMs / (receivedCount - 1); + final remaining = total - receivedCount; + if (remaining <= 0) return Duration.zero; + return Duration(milliseconds: (avgMsPerFragment * remaining).round()); + } + /// Reassemble the complete image bytes, or null if any fragment is missing. Uint8List? get imageBytes => reassembleImage(fragments); } @@ -62,6 +79,8 @@ class ImageProvider with ChangeNotifier { bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false; bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); + Duration? estimateRemainingTransferTime(String sessionId) => + _sessions[sessionId]?.estimateRemaining(); List missingFragmentIndices(String sessionId) { final session = _sessions[sessionId]; @@ -94,7 +113,13 @@ class ImageProvider with ChangeNotifier { final session = _sessions[fragment.sessionId]!; if (fragment.index < session.total) { + final wasMissing = session.fragments[fragment.index] == null; session.fragments[fragment.index] = fragment; + if (wasMissing) { + final now = DateTime.now(); + session.firstFragmentAt ??= now; + session.lastFragmentAt = now; + } } final justComplete = session.isComplete; diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 2861bdb..1101a25 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -13,6 +13,8 @@ class VoiceSession { final VoicePacketMode mode; final int total; final List packets; // indexed by packet.index + DateTime? firstPacketAt; + DateTime? lastPacketAt; VoiceSession({ required this.sessionId, @@ -23,6 +25,19 @@ class VoiceSession { int get receivedCount => packets.where((p) => p != null).length; bool get isComplete => receivedCount == total; + Duration? estimateRemaining() { + if (isComplete) return Duration.zero; + if (firstPacketAt == null || lastPacketAt == null) return null; + if (receivedCount < 2) return null; + + final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds; + if (elapsedMs <= 0) return null; + final avgMsPerPacket = elapsedMs / (receivedCount - 1); + final remaining = total - receivedCount; + if (remaining <= 0) return Duration.zero; + return Duration(milliseconds: (avgMsPerPacket * remaining).round()); + } + /// Total estimated audio duration in seconds (sum of all received packets). double get estimatedDurationSeconds { var ms = 0; @@ -93,6 +108,8 @@ class VoiceProvider with ChangeNotifier { bool hasOutgoingSession(String sessionId) => _outgoingSessions.containsKey(sessionId); + Duration? estimateRemainingTransferTime(String sessionId) => + _sessions[sessionId]?.estimateRemaining(); List missingPacketIndices(String sessionId) { final session = _sessions[sessionId]; @@ -120,7 +137,13 @@ class VoiceProvider with ChangeNotifier { final session = _sessions[packet.sessionId]!; if (packet.index < session.total) { + final wasMissing = session.packets[packet.index] == null; session.packets[packet.index] = packet; + if (wasMissing) { + final now = DateTime.now(); + session.firstPacketAt ??= now; + session.lastPacketAt = now; + } } final justComplete = session.isComplete; diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index fd37445..1244b1f 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -60,6 +60,9 @@ class _ImageMessageBubbleState extends State { builder: (context, imageProvider, _) { final session = imageProvider.session(envelope.sessionId); final isComplete = imageProvider.isComplete(envelope.sessionId); + final eta = imageProvider.estimateRemainingTransferTime( + envelope.sessionId, + ); if (_isRequesting && isComplete) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -112,6 +115,7 @@ class _ImageMessageBubbleState extends State { radioCr: radioCr, error: _errorText, isSentByMe: widget.isSentByMe, + eta: eta, ), style: TextStyle( fontSize: 11, @@ -374,6 +378,7 @@ class _ImageMessageBubbleState extends State { required int? radioCr, required String? error, required bool isSentByMe, + required Duration? eta, }) { final txEstimate = estimateImageTransmitDuration( fragmentCount: envelope.total, @@ -386,7 +391,10 @@ class _ImageMessageBubbleState extends State { final txEstimateLabel = _formatTransmitEstimate(txEstimate); if (error != null) return error; - if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel'; + if (isRequesting) { + final etaLabel = _formatEta(eta); + return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel'; + } if (isComplete) { final base = '🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}'; @@ -404,6 +412,14 @@ class _ImageMessageBubbleState extends State { return '~${minutes}m ${seconds}s tx'; } + static String _formatEta(Duration? eta) { + if (eta == null || eta <= Duration.zero) return 'ETA --'; + if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s'; + final minutes = eta.inMinutes; + final seconds = eta.inSeconds % 60; + return 'ETA ~${minutes}m ${seconds}s'; + } + void _showFullScreen(BuildContext context, Uint8List imageBytes) { showGeneralDialog( context: context, diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index f88494f..ac3a79e 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -102,6 +102,7 @@ class _VoiceMessageBubbleState extends State { radioCr: radioCr, ); final txEstimateLabel = _formatTransmitEstimate(txEstimate); + final eta = voiceProvider.estimateRemainingTransferTime(voiceId); return Row( mainAxisSize: MainAxisSize.min, @@ -175,6 +176,7 @@ class _VoiceMessageBubbleState extends State { requestingLabel: AppLocalizations.of( context, )!.requestingVoice, + eta: eta, ), style: TextStyle( fontSize: 11, @@ -378,11 +380,12 @@ class _VoiceMessageBubbleState extends State { required bool isRequesting, required String? errorText, required String requestingLabel, + required Duration? eta, }) { if (errorText != null) return errorText; final progress = total > 0 ? ' ($received/$total)' : ''; if (isRequesting) { - return '$requestingLabel$progress · $txEstimateLabel'; + return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel'; } if (!isComplete && total > 0) { return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel'; @@ -462,6 +465,14 @@ class _VoiceMessageBubbleState extends State { final seconds = value.inSeconds % 60; return '~${minutes}m ${seconds}s tx'; } + + static String _formatEta(Duration? eta) { + if (eta == null || eta <= Duration.zero) return 'ETA --'; + if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s'; + final minutes = eta.inMinutes; + final seconds = eta.inSeconds % 60; + return 'ETA ~${minutes}m ${seconds}s'; + } } /// Voice waveform rendered as a row of bars.