diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 333df4f..2b99101 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1786,9 +1786,10 @@ class MessagesModule { }) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { - 'messageId': int.tryParse(messageId) ?? 0, - 'chatId': chatId, 'fileId': fileId, + 'chatId': chatId, + 'messageId': int.tryParse(messageId) ?? 0, + 'itemType': 'REGULAR', }); if (!response.isOk) return null; diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart index 8a783d3..c229f5c 100644 --- a/lib/core/utils/media_cache.dart +++ b/lib/core/utils/media_cache.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -18,6 +19,8 @@ class MediaCache { static Directory? _dir; static int? _cachedSize; static final Map> _inFlight = {}; + static final Set _present = {}; + static final Map> _presence = {}; static Future _cacheDir() async { final cached = _dir; @@ -49,8 +52,10 @@ class MediaCache { try { await file.setLastModified(DateTime.now()); } catch (_) {} + _markPresent(name, true); return file; } + _markPresent(name, false); return null; } @@ -103,6 +108,7 @@ class MediaCache { } await sink.close(); await part.rename(file.path); + _markPresent(name, true); final known = _cachedSize; if (known != null) { try { @@ -161,6 +167,10 @@ class MediaCache { } } _cachedSize = 0; + _present.clear(); + for (final notifier in _presence.values) { + notifier.value = false; + } return freed; } @@ -195,11 +205,35 @@ class MediaCache { try { total -= await file.length(); await file.delete(); + _markAbsentByBasename(p.basename(file.path)); } catch (_) {} } _cachedSize = total; } + /// Реактивный флаг наличия файла [name] в кэше (для UI-иконки «скачано»). + static ValueListenable presence(String name) { + final key = _sanitize(name); + return _presence.putIfAbsent(key, () { + final notifier = ValueNotifier(_present.contains(key)); + if (!notifier.value) existing(name).ignore(); + return notifier; + }); + } + + static void _markPresent(String name, bool present) { + final key = _sanitize(name); + final changed = present ? _present.add(key) : _present.remove(key); + if (!changed) return; + _presence[key]?.value = present; + } + + static void _markAbsentByBasename(String basename) { + if (_present.remove(basename)) { + _presence[basename]?.value = false; + } + } + static String _sanitize(String name) { final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); return cleaned.isEmpty ? 'file' : cleaned; diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index 8de7d78..b6bda97 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -53,6 +53,7 @@ class ComposerInputBar extends StatelessWidget { this.showAttachButton = true, this.forceSend = false, this.hintText = 'Message', + this.bottomSafe = true, }); final String chatType; @@ -87,6 +88,7 @@ class ComposerInputBar extends StatelessWidget { final bool showAttachButton; final bool forceSend; final String hintText; + final bool bottomSafe; @override Widget build(BuildContext context) { @@ -172,6 +174,7 @@ class ComposerInputBar extends StatelessWidget { } final bar = SafeArea( + bottom: bottomSafe, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1bff152..5c8df93 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -2353,9 +2353,12 @@ class _ChatScreenState extends State ); }, ), - ComposerInputBar( - chatType: _commentsMode ? 'CHAT' : widget.chatType, - chrome: _effectiveChrome, + AnimatedBuilder( + animation: _stickers.anim, + builder: (context, _) => ComposerInputBar( + bottomSafe: _stickers.anim.value == 0, + chatType: _commentsMode ? 'CHAT' : widget.chatType, + chrome: _effectiveChrome, style: AppComposerStyle.current.value, background: AppComposerBackground.current.value, backdropKey: _pillBackdrop, @@ -2385,8 +2388,9 @@ class _ChatScreenState extends State onSubscribe: _subscribeChannel, showStickerButton: !_commentsMode, showAttachButton: !_commentsMode, - forceSend: _commentsMode, - hintText: _commentsMode ? 'Комментарий' : 'Message', + forceSend: _commentsMode, + hintText: _commentsMode ? 'Комментарий' : 'Message', + ), ), StickerPanelView( stickers: _stickers, diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart index 0ee86af..2f87916 100644 --- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -5,6 +5,7 @@ import 'package:komet/main.dart'; import '../../../../core/utils/download_progress.dart'; import '../../../../core/utils/file_download.dart'; +import '../../../../core/utils/media_cache.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../models/attachment.dart'; @@ -113,38 +114,49 @@ class FileBubble extends StatelessWidget { ValueListenableBuilder( valueListenable: MediaDownloadProgress.notifier(cacheName), builder: (context, progress, _) { - final downloading = progress != null; - return GestureDetector( - onTap: downloading - ? null - : () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.systemTint - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, + final iconColor = isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary; + Widget circle(Widget child, VoidCallback? onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.systemTint + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: child, ), - child: downloading - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress > 0 ? progress : null, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - ), - ) - : Icon( - Symbols.download, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 18, - ), + ); + } + + if (progress != null) { + return circle( + Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: iconColor, + ), + ), + null, + ); + } + + return ValueListenableBuilder( + valueListenable: MediaCache.presence(cacheName), + builder: (context, cached, _) => circle( + Icon( + cached ? Symbols.check : Symbols.download, + color: iconColor, + size: 18, + ), + () => _downloadFile(ctx.context, file, name), ), ); }, @@ -171,8 +183,9 @@ class FileBubble extends StatelessWidget { Haptics.tap(); final cacheName = '${fileId}_$name'; + final cached = (await MediaCache.existing(cacheName)) != null; - MediaDownloadProgress.set(cacheName, 0); + if (!cached) MediaDownloadProgress.set(cacheName, 0); final result = await openCachedFile( cacheName, () => messagesModule.getFileUrl( @@ -182,7 +195,7 @@ class FileBubble extends StatelessWidget { ), onProgress: (p) => MediaDownloadProgress.set(cacheName, p), ); - MediaDownloadProgress.set(cacheName, null); + if (!cached) MediaDownloadProgress.set(cacheName, null); if (!context.mounted) return; if (!result.ok) { showCustomNotification(