feat: норм отправка кружком как дфывйцждуозйшо / dev 18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEefQULnwjVZt2yzvK8CWn
This commit is contained in:
Jganenokk
2026-08-08 14:54:45 +07:00
co-authored by Claude Opus 5
parent 7bbc77192a
commit 08d29ef996
21 changed files with 1067 additions and 319 deletions
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -5,7 +7,7 @@ import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart';
import '../../main.dart' show KometApp;
enum UploadKind { photo, video, file }
enum UploadKind { photo, video, videoNote, voice, file }
class _NotificationJob {
_NotificationJob({required this.kind, required this.count, this.filename});
@@ -41,6 +43,8 @@ class _NotificationJob {
return switch (kind) {
UploadKind.photo => l10n.uploadNotificationPhotos(count),
UploadKind.video => l10n.uploadNotificationVideo,
UploadKind.videoNote => l10n.uploadNotificationVideoNote,
UploadKind.voice => l10n.uploadNotificationVoice,
UploadKind.file =>
name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
};
@@ -52,8 +56,10 @@ class UploadNotificationService {
'ru.komet.app/upload_service',
);
static const int _minIntervalMs = 350;
static const Duration _startDelay = Duration(milliseconds: 700);
static final Map<String, _NotificationJob> _jobs = {};
static Timer? _startTimer;
static bool _running = false;
static String? _lastTitle;
static String? _lastBody;
@@ -75,7 +81,14 @@ class UploadNotificationService {
count: count < 1 ? 1 : count,
filename: filename,
);
_push(force: true);
if (_running) {
_push(force: true);
return;
}
_startTimer ??= Timer(_startDelay, () {
_startTimer = null;
_push(force: true);
});
}
static void report(
@@ -102,16 +115,20 @@ class UploadNotificationService {
}
static void _stop() {
_startTimer?.cancel();
_startTimer = null;
final wasRunning = _running;
_running = false;
_lastTitle = null;
_lastBody = null;
_lastPercent = -1;
_lastPushAt = 0;
_invoke('stop', const <String, dynamic>{});
if (wasRunning) _invoke('stop', const <String, dynamic>{});
}
static void _push({bool force = false}) {
if (_jobs.isEmpty) return;
if (!_running && _startTimer != null) return;
var sumSent = 0;
var sumTotal = 0;
+100
View File
@@ -291,6 +291,106 @@ class UploadService {
);
}
Future<void> sendVoice({
required int accountId,
required int chatId,
required String tempId,
required File file,
required int durationMs,
required Uint8List wave,
CachedMessage? placeholder,
}) {
return _sendRecording(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: file,
kind: UploadKind.voice,
placeholder: placeholder,
requestUpload: () async {
final info = await messagesModule.requestAudioUploadUrl();
return info == null ? null : (url: info.url, token: info.token);
},
send: (token) => messagesModule.sendAudioMessage(
chatId,
token,
duration: durationMs,
wave: wave,
),
);
}
Future<void> sendVideoNote({
required int accountId,
required int chatId,
required String tempId,
required File file,
required int durationMs,
CachedMessage? placeholder,
}) {
return _sendRecording(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: file,
kind: UploadKind.videoNote,
placeholder: placeholder,
requestUpload: () async {
final info = await messagesModule.requestVideoNoteUploadUrl();
return info == null ? null : (url: info.url, token: info.token);
},
send: (token) => messagesModule.sendVideoNoteMessage(
chatId,
token,
duration: durationMs,
),
);
}
Future<void> _sendRecording({
required int accountId,
required int chatId,
required String tempId,
required File file,
required UploadKind kind,
required Future<({String url, String token})?> Function() requestUpload,
required Future<Map<String, dynamic>?> Function(String token) send,
CachedMessage? placeholder,
}) {
return _run(
UploadJob._(
id: tempId,
accountId: accountId,
chatId: chatId,
kind: kind,
slots: 1,
placeholder: placeholder,
),
(job) async {
try {
final info = await requestUpload();
if (info == null || info.url.isEmpty) {
throw const UploadFailure('no_upload_url');
}
final ok = await fileUploader.uploadMediaFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) => job.report(0, sent, total),
);
if (!ok) throw const UploadFailure('upload_failed');
job.markUploaded();
final sent = await send(info.token);
if (sent == null) return null;
return CachedMessage.fromPushPayload(accountId, chatId, sent);
} finally {
try {
await file.delete();
} catch (_) {}
}
},
);
}
Future<void> sendFile({
required int accountId,
required int chatId,
@@ -6,6 +6,7 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:lottie/lottie.dart' show AssetLottie;
import 'package:path_provider/path_provider.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -16,8 +17,11 @@ import '../../../../core/media/native_video_note_recorder.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/logger.dart';
import '../../../widgets/custom_notification.dart';
import '../../../widgets/lottie_slash_icon.dart';
import 'voice_record_controller.dart';
const String _flashIcon = 'assets/lottie/ic_flash_on_to_off.json';
class VideoNoteController {
VideoNoteController({
required this.contextOf,
@@ -89,6 +93,7 @@ class VideoNoteController {
bool get _stub => !_rec.isAvailable;
Future<void> _initCamera() async {
unawaited(AssetLottie(_flashIcon).load());
if (_stub) {
_camReady.value = true;
_textureId.value = null;
@@ -457,17 +462,24 @@ class _CameraControls extends StatelessWidget {
children: [
if (controller.cameraControlsAvailable)
_ControlButton(
icon: Symbols.flip_camera_ios,
color: cs.onSurface,
onTap: controller.flipCamera,
child: Icon(
Symbols.flip_camera_ios,
size: 24,
color: cs.onSurface,
fill: 1,
),
),
if (controller.flashAvailable)
ValueListenableBuilder<bool>(
valueListenable: controller.flashOn,
builder: (context, on, _) => _ControlButton(
icon: on ? Symbols.flash_on : Symbols.flash_off,
color: on ? cs.primary : cs.onSurface,
onTap: controller.toggleFlash,
child: LottieSlashIcon(
asset: _flashIcon,
slashed: !on,
color: on ? cs.primary : cs.onSurface,
),
),
),
],
@@ -477,14 +489,9 @@ class _CameraControls extends StatelessWidget {
}
class _ControlButton extends StatelessWidget {
const _ControlButton({
required this.icon,
required this.color,
required this.onTap,
});
const _ControlButton({required this.child, required this.onTap});
final IconData icon;
final Color color;
final Widget child;
final VoidCallback onTap;
@override
@@ -492,10 +499,7 @@ class _ControlButton extends StatelessWidget {
return InkResponse(
onTap: onTap,
radius: 26,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(icon, size: 24, color: color, fill: 1),
),
child: Padding(padding: const EdgeInsets.all(8), child: child),
);
}
}
+46 -158
View File
@@ -337,7 +337,6 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<ReactionAnimationEvent?> _reactionAnimation =
ValueNotifier(null);
int _reactionAnimationToken = 0;
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
late final VoiceRecordController _voiceRec = VoiceRecordController(
@@ -358,7 +357,7 @@ class _ChatScreenState extends State<ChatScreen>
StreamSubscription<UploadJobEvent>? _uploadEventSub;
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
_photoUploadProgress[m.id] ?? UploadService.instance.progressFor(m.id);
UploadService.instance.progressFor(m.id);
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
final existing = _reactionNotifiers[m.id];
@@ -2088,10 +2087,6 @@ class _ChatScreenState extends State<ChatScreen>
}
_reactionNotifiers.clear();
_reactionAnimation.dispose();
for (final n in _photoUploadProgress.values) {
n.dispose();
}
_photoUploadProgress.clear();
ChatActivityStore.instance
.listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus);
@@ -6022,77 +6017,24 @@ class _ChatScreenState extends State<ChatScreen>
return;
}
final wave = _buildWave(amps);
final tempId = _nextTempId();
final progress = ValueNotifier<List<double>>(const [0]);
_photoUploadProgress[tempId] = progress;
_messages.add(
CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sending',
attachments: [AudioAttachment(duration: durationMs)],
final placeholder = _addOptimisticMediaMessage(
AudioAttachment(
duration: durationMs,
waveform: String.fromCharCodes(wave),
),
);
_lastSentId = tempId;
_bumpMessages();
Haptics.send();
_scrollToBottom();
try {
final info = await messagesModule.requestAudioUploadUrl();
if (info == null || info.url.isEmpty) throw Exception('no_url');
final ok = await fileUploader.uploadMediaFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) {
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
},
);
if (!ok) throw Exception('upload_failed');
if (!mounted) {
_disposePhotoProgress(tempId);
return;
}
final serverMsg = await messagesModule.sendAudioMessage(
widget.chatId,
info.token,
duration: durationMs,
unawaited(
UploadService.instance.sendVoice(
accountId: _myId,
chatId: widget.chatId,
tempId: placeholder.id,
file: file,
durationMs: durationMs,
wave: wave,
);
if (!mounted) {
_disposePhotoProgress(tempId);
return;
}
if (serverMsg == null) throw Exception('send_failed');
final real = CachedMessage.fromPushPayload(
_myId,
widget.chatId,
serverMsg,
);
final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx != -1) {
_messages[idx] = real;
_bumpMessages();
unawaited(_persistOutgoing(real, removeId: tempId));
}
_disposePhotoProgress(tempId);
} catch (_) {
if (mounted) {
_failPhotoMessage(tempId);
} else {
_disposePhotoProgress(tempId);
}
} finally {
try {
await file.delete();
} catch (_) {}
}
placeholder: placeholder,
),
);
}
Future<void> _sendVideoNote(File file, int durationMs) async {
@@ -6102,85 +6044,23 @@ class _ChatScreenState extends State<ChatScreen>
} catch (_) {}
return;
}
final tempId = _nextTempId();
final progress = ValueNotifier<List<double>>(const [0]);
_photoUploadProgress[tempId] = progress;
_messages.add(
CachedMessage(
id: tempId,
final placeholder = _addOptimisticMediaMessage(
VideoAttachment(duration: durationMs, videoType: 1, localPath: file.path),
);
unawaited(
UploadService.instance.sendVideoNote(
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sending',
attachments: [VideoAttachment(duration: durationMs, videoType: 1)],
tempId: placeholder.id,
file: file,
durationMs: durationMs,
placeholder: placeholder,
),
);
_lastSentId = tempId;
_bumpMessages();
Haptics.send();
_scrollToBottom();
try {
final info = await messagesModule.requestVideoNoteUploadUrl();
if (info == null || info.url.isEmpty) throw Exception('no_url');
final ok = await fileUploader.uploadMediaFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) {
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
},
);
if (!ok) throw Exception('upload_failed');
if (!mounted) {
_disposePhotoProgress(tempId);
return;
}
final serverMsg = await messagesModule.sendVideoNoteMessage(
widget.chatId,
info.token,
duration: durationMs,
);
if (!mounted) {
_disposePhotoProgress(tempId);
return;
}
if (serverMsg == null) throw Exception('send_failed');
final real = CachedMessage.fromPushPayload(
_myId,
widget.chatId,
serverMsg,
);
final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx != -1) {
_messages[idx] = real;
_bumpMessages();
unawaited(_persistOutgoing(real, removeId: tempId));
}
_disposePhotoProgress(tempId);
} catch (e) {
logger.w('sendVideoNote failed: $e');
if (mounted) {
_failPhotoMessage(tempId);
final reason = e is PacketError
? '${e.errorKey ?? ''} ${e.message}'.trim()
: e is Exception && e.toString().contains('send_failed')
? 'сервер не обработал видео'
: e is Exception && e.toString().contains('upload_failed')
? 'загрузка отклонена'
: e.toString();
showCustomNotification(context, 'Кружок не отправлен: $reason');
} else {
_disposePhotoProgress(tempId);
}
} finally {
try {
await file.delete();
} catch (_) {}
}
}
CachedMessage _addOptimisticFileMessage(FileAttachment attachment) {
CachedMessage _addOptimisticMediaMessage(MessageAttachment attachment) {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = _nextTempId();
final msg = CachedMessage(
@@ -6225,7 +6105,7 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
final tempId = _addOptimisticFileMessage(
final tempId = _addOptimisticMediaMessage(
FileAttachment(
fileId: entry.fileId,
fileToken: entry.token,
@@ -6251,7 +6131,7 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<bool> _sendFileById(int fileId) async {
final tempId = _addOptimisticFileMessage(
final tempId = _addOptimisticMediaMessage(
FileAttachment(fileId: fileId),
).id;
try {
@@ -6478,12 +6358,26 @@ class _ChatScreenState extends State<ChatScreen>
return;
}
_failPhotoMessage(event.tempId);
if (event.kind == UploadKind.file) {
showCustomNotification(context, 'Ошибка: ${event.reason}');
}
final text = _uploadFailureText(event.kind, event.reason);
if (text != null) showCustomNotification(context, text);
}
}
String? _uploadFailureText(UploadKind kind, String reason) {
final detail = switch (reason) {
'no_upload_url' => 'сервер не выдал ссылку',
'upload_failed' => 'загрузка отклонена',
'send_failed' => 'сервер не принял сообщение',
_ => reason,
};
return switch (kind) {
UploadKind.file => 'Ошибка: $reason',
UploadKind.videoNote => 'Кружок не отправлен: $detail',
UploadKind.voice => 'Голосовое не отправлено: $detail',
UploadKind.photo || UploadKind.video => null,
};
}
void _syncUploadStatus() {
final job = UploadService.instance.activeFileJob(widget.chatId);
if (job?.id == _uploadStatusJobId) return;
@@ -6735,21 +6629,15 @@ class _ChatScreenState extends State<ChatScreen>
);
}
void _failPhotoMessage(String tempId) {
final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx != -1) {
_messages[idx] = _messages[idx].copyWith(status: 'error');
_bumpMessages();
}
_disposePhotoProgress(tempId);
Haptics.error();
}
void _disposePhotoProgress(String tempId) {
_photoUploadProgress.remove(tempId)?.dispose();
}
void _refuseUnencrypted(String what) {
if (!mounted) return;
_showAttachmentPanel.value = false;
@@ -6836,7 +6724,7 @@ class _ChatScreenState extends State<ChatScreen>
final placeholder = scheduledTime != null
? null
: _addOptimisticFileMessage(
: _addOptimisticMediaMessage(
FileAttachment(name: filename, size: size),
);
@@ -33,6 +33,7 @@ class VideoBubble extends StatelessWidget {
cs: ctx.cs,
textColor: ctx.text,
meta: ctx.meta(),
uploadProgress: ctx.uploadProgress,
);
}
final hasCaption = message.text != null && message.text!.isNotEmpty;
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -15,6 +17,7 @@ import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/logger.dart';
import '../../../../models/attachment.dart';
import '../../small_spinner.dart';
import '../../upload_progress_ring.dart';
class VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment;
@@ -26,6 +29,7 @@ class VideoNoteBubble extends StatefulWidget {
final ColorScheme cs;
final Color textColor;
final Widget meta;
final ValueListenable<List<double>>? uploadProgress;
const VideoNoteBubble({
super.key,
@@ -38,6 +42,7 @@ class VideoNoteBubble extends StatefulWidget {
required this.cs,
required this.textColor,
required this.meta,
this.uploadProgress,
});
@override
@@ -57,6 +62,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
final ValueNotifier<double> _ringProgress = ValueNotifier(0);
Uint8List? _preview;
VideoPlayerController? _controller;
VideoPlayerController? _local;
Future<void>? _initializing;
Duration? _pendingSeek;
double? _lastAngle;
@@ -70,6 +76,19 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
int? get _videoId => widget.attachment.videoId;
String get _cacheName => 'videonote_$_videoId.mp4';
int get _attachmentDurationMs => widget.attachment.duration ?? 0;
String? get _localPath => widget.attachment.localPath;
String? get _posterUrl {
for (final candidate in [
widget.attachment.thumbnail,
widget.attachment.baseUrl,
]) {
if (candidate == null || candidate.isEmpty) continue;
if (candidate.startsWith('data:')) continue;
return candidate;
}
return null;
}
bool get _ready {
final controller = _controller;
@@ -81,7 +100,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
super.initState();
_expand = AnimationController(vsync: this, duration: _expandDuration);
_preview = _previewBytes(widget.attachment.previewData);
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) _preload();
final local = _localPath;
if (local != null) {
unawaited(_openLocalPreview(File(local)));
return;
}
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) {
unawaited(_warmCache());
}
}
@override
@@ -90,6 +116,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
if (old.attachment.previewData != widget.attachment.previewData) {
_preview = _previewBytes(widget.attachment.previewData);
}
if (old.attachment.localPath != _localPath) _dropLocalPreview();
}
@override
@@ -98,6 +125,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
_PreviewPool.unregister(this);
_expand.dispose();
_ringProgress.dispose();
_dropLocalPreview();
final controller = _controller;
_controller = null;
if (controller != null) {
@@ -107,6 +135,34 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
super.dispose();
}
Future<void> _openLocalPreview(File file) async {
final controller = VideoPlayerController.file(file);
try {
await controller.initialize();
} catch (e) {
logger.w('VideoNoteBubble: локальное превью не открылось: $e');
await controller.dispose();
return;
}
if (!mounted || file.path != _localPath) {
await controller.dispose();
return;
}
await controller.setVolume(0);
if (!mounted) {
await controller.dispose();
return;
}
setState(() => _local = controller);
}
void _dropLocalPreview() {
final local = _local;
if (local == null) return;
_local = null;
unawaited(local.dispose());
}
void _claimPlayback() {
final controller = _controller;
if (controller == null) return;
@@ -152,11 +208,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
}
Future<void> _preload() async {
final file = await _fetch(priority: false);
if (file == null || !mounted) return;
await _ensureController(file);
}
Future<void> _warmCache() => _fetch(priority: false);
Future<File?> _fetch({required bool priority}) {
final videoId = _videoId;
@@ -176,6 +228,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
Future<VideoPlayerController?> _ensureController(File file) async {
if (!mounted) return null;
if (_controller != null) return _controller;
final live = MediaPlayback.instance.liveVideoNote(_cacheName);
if (live != null) {
@@ -230,6 +283,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
Future<void> _toggle() async {
if (_videoId == null) return;
if (_ready) {
if (_controller!.value.isPlaying) {
await _pause();
@@ -388,13 +442,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
Widget _buildCircle(double size) {
final controller = _controller;
final ready = _ready;
final playing = ready && _playing && !_scrubbing;
final preview = _preview;
final local = _local;
final uploading = widget.uploadProgress;
return GestureDetector(
onTap: _toggle,
onTap: uploading == null ? _toggle : null,
child: SizedBox(
width: size,
height: size,
@@ -409,63 +464,112 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
child: AnimatedSwitcher(
duration: _swapDuration,
child: ready
? SizedBox.expand(
key: const ValueKey('note-video'),
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: controller!.value.size.width,
height: controller.value.size.height,
child: VideoPlayer(controller),
),
),
? _videoSurface(
_controller!,
const ValueKey('note-video'),
)
: preview != null
? SizedBox.expand(
key: const ValueKey('note-preview'),
child: Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
),
)
: SizedBox.expand(
key: const ValueKey('note-empty'),
child: ColoredBox(
color: widget.cs.surfaceContainerHighest,
),
),
: local != null && local.value.isInitialized
? _videoSurface(local, const ValueKey('note-local'))
: _buildPoster(preview),
),
),
),
),
if (ready) _buildRing(size),
if (!playing)
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
if (uploading != null) ...[
Positioned.fill(
child: ClipOval(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35),
),
),
child: _loading
? const Padding(
padding: EdgeInsets.all(14),
child: SmallSpinner(size: 36, color: Colors.white),
)
: Icon(
_error ? Symbols.error : Symbols.play_arrow,
color: Colors.white,
size: 30,
),
),
UploadProgressRing(
progress: uploading,
color: Colors.white,
trackColor: Colors.white24,
),
] else ...[
if (ready) _buildRing(size),
if (!playing)
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
child: _loading
? const Padding(
padding: EdgeInsets.all(14),
child: SmallSpinner(size: 36, color: Colors.white),
)
: Icon(
_error ? Symbols.error : Symbols.play_arrow,
color: Colors.white,
size: 30,
),
),
],
],
),
),
);
}
Widget _buildPoster(Uint8List? preview) {
final url = _posterUrl;
if (url == null) {
return _inlinePreview(preview, const ValueKey('note-preview'));
}
final dpr = MediaQuery.devicePixelRatioOf(context);
return SizedBox.expand(
key: const ValueKey('note-poster'),
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
memCacheWidth: (_baseSize * dpr).round(),
fadeInDuration: _swapDuration,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, _) => _inlinePreview(preview, null),
errorWidget: (_, _, _) => _inlinePreview(preview, null),
),
);
}
Widget _inlinePreview(Uint8List? preview, Key? key) {
if (preview == null) {
return SizedBox.expand(
key: key,
child: ColoredBox(color: widget.cs.surfaceContainerHighest),
);
}
return SizedBox.expand(
key: key,
child: Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
filterQuality: FilterQuality.medium,
),
);
}
Widget _videoSurface(VideoPlayerController controller, Key key) {
return SizedBox.expand(
key: key,
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: controller.value.size.width,
height: controller.value.size.height,
child: VideoPlayer(controller),
),
),
);
}
Widget _buildRing(double size) {
return GestureDetector(
onTapUp: (details) => _ringTap(details.localPosition, size),
@@ -29,6 +29,7 @@ class VoiceMessageBubble extends StatefulWidget {
final int senderId;
final int? audioId;
final String? preloadedText;
final ValueListenable<List<double>>? uploadProgress;
const VoiceMessageBubble({
super.key,
@@ -47,6 +48,7 @@ class VoiceMessageBubble extends StatefulWidget {
required this.senderId,
this.audioId,
this.preloadedText,
this.uploadProgress,
});
@override
@@ -101,7 +103,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
);
}
bool get _uploading => widget.uploadProgress != null;
void _toggle() {
if (_uploading) return;
_claimPlayback();
_audio.toggle();
}
@@ -176,8 +181,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary;
Widget _buildPlayButton() {
final uploading = widget.uploadProgress;
return GestureDetector(
onTap: _toggle,
onTap: uploading == null ? _toggle : null,
child: Container(
width: 32,
height: 32,
@@ -187,46 +193,73 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
: widget.cs.primaryContainer,
shape: BoxShape.circle,
),
child: AnimatedBuilder(
animation: Listenable.merge([
_audio.downloaded,
_audio.downloadProgress,
_audio.playing,
]),
builder: (context, _) {
final progress = _audio.downloadProgress.value;
if (progress != null) {
return Padding(
padding: const EdgeInsets.all(4),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: _accent,
backgroundColor: _accent.withValues(alpha: 0.2),
),
);
}
final IconData icon;
if (_audio.playing.value) {
icon = Symbols.pause;
} else if (_audio.downloaded.value) {
icon = Symbols.play_arrow;
} else {
icon = Symbols.arrow_downward;
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
child: Icon(
icon,
key: ValueKey(icon),
color: _accent,
size: 18,
child: uploading != null
? _buildUploadIndicator(uploading)
: AnimatedBuilder(
animation: Listenable.merge([
_audio.downloaded,
_audio.downloadProgress,
_audio.playing,
]),
builder: (context, _) {
final progress = _audio.downloadProgress.value;
if (progress != null) {
return Padding(
padding: const EdgeInsets.all(4),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: _accent,
backgroundColor: _accent.withValues(alpha: 0.2),
),
);
}
final IconData icon;
if (_audio.playing.value) {
icon = Symbols.pause;
} else if (_audio.downloaded.value) {
icon = Symbols.play_arrow;
} else {
icon = Symbols.arrow_downward;
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
child: Icon(
icon,
key: ValueKey(icon),
color: _accent,
size: 18,
),
);
},
),
);
},
),
),
);
}
Widget _buildUploadIndicator(ValueListenable<List<double>> progress) {
return Padding(
padding: const EdgeInsets.all(4),
child: ValueListenableBuilder<List<double>>(
valueListenable: progress,
builder: (context, values, _) {
final value = values.isEmpty
? 0.0
: values.reduce((a, b) => a + b) / values.length;
return TweenAnimationBuilder<double>(
tween: Tween<double>(end: value.clamp(0.0, 1.0)),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
builder: (context, shown, _) => CircularProgressIndicator(
strokeWidth: 2,
value: shown >= 1.0 ? null : shown,
color: _accent,
backgroundColor: _accent.withValues(alpha: 0.2),
),
);
},
),
);
}
@@ -626,7 +659,10 @@ class _WaveformPainter extends CustomPainter {
void _paintKnob(Canvas canvas, Size size, double center) {
if (!knob) return;
final x = (size.width * progress.clamp(0.0, 1.0)).clamp(1.5, size.width - 1.5);
final x = (size.width * progress.clamp(0.0, 1.0)).clamp(
1.5,
size.width - 1.5,
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x - 1.5, 0, 3, size.height),
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
class LottieSlashIcon extends StatefulWidget {
const LottieSlashIcon({
super.key,
required this.asset,
required this.slashed,
required this.color,
this.size = 24,
this.duration = const Duration(milliseconds: 320),
});
final String asset;
final bool slashed;
final Color color;
final double size;
final Duration duration;
@override
State<LottieSlashIcon> createState() => _LottieSlashIconState();
}
class _LottieSlashIconState extends State<LottieSlashIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: widget.duration,
value: widget.slashed ? 1 : 0,
);
@override
void didUpdateWidget(LottieSlashIcon old) {
super.didUpdateWidget(old);
_controller.duration = widget.duration;
if (widget.slashed == old.slashed) return;
if (widget.slashed) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: widget.size,
child: Lottie.asset(
widget.asset,
controller: _controller,
fit: BoxFit.contain,
delegates: LottieDelegates(
values: [
ValueDelegate.color(const ['**'], value: widget.color),
],
),
),
);
}
}
+1
View File
@@ -1936,6 +1936,7 @@ class MessageBubble extends StatelessWidget {
senderId: message.senderId,
audioId: audioId,
preloadedText: cachedTranscription?.text,
uploadProgress: ctx.uploadProgress,
);
}
}
+2
View File
@@ -1070,6 +1070,8 @@
}
},
"uploadNotificationVideo": "Video",
"uploadNotificationVideoNote": "Video message",
"uploadNotificationVoice": "Voice message",
"uploadNotificationFile": "File",
"uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}",
"@uploadNotificationMultiple": {
+12
View File
@@ -4706,6 +4706,18 @@ abstract class AppLocalizations {
/// **'Video'**
String get uploadNotificationVideo;
/// No description provided for @uploadNotificationVideoNote.
///
/// In en, this message translates to:
/// **'Video message'**
String get uploadNotificationVideoNote;
/// No description provided for @uploadNotificationVoice.
///
/// In en, this message translates to:
/// **'Voice message'**
String get uploadNotificationVoice;
/// No description provided for @uploadNotificationFile.
///
/// In en, this message translates to:
+6
View File
@@ -2464,6 +2464,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get uploadNotificationVideo => 'Video';
@override
String get uploadNotificationVideoNote => 'Video message';
@override
String get uploadNotificationVoice => 'Voice message';
@override
String get uploadNotificationFile => 'File';
+6
View File
@@ -2478,6 +2478,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get uploadNotificationVideo => 'Видео';
@override
String get uploadNotificationVideoNote => 'Кружок';
@override
String get uploadNotificationVoice => 'Голосовое сообщение';
@override
String get uploadNotificationFile => 'Файл';
+2
View File
@@ -814,6 +814,8 @@
}
},
"uploadNotificationVideo": "Видео",
"uploadNotificationVideoNote": "Кружок",
"uploadNotificationVoice": "Голосовое сообщение",
"uploadNotificationFile": "Файл",
"uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}",
"@uploadNotificationMultiple": {