From 1a043f033d593e7d69caf34c90f1fd3b4482d998 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 27 Jul 2026 18:11:38 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/utils/download_history.dart | 338 ++++++++++++++ lib/core/utils/file_download.dart | 7 + lib/core/utils/media_cache.dart | 9 + lib/core/utils/media_saver.dart | 10 +- lib/core/utils/save_file_as.dart | 58 +++ .../screens/chats/chat_list_screen.dart | 199 +++++--- lib/frontend/screens/downloads_screen.dart | 440 ++++++++++++++++++ lib/frontend/widgets/adaptive_shell.dart | 10 +- .../attachment/bubbles/file_bubble.dart | 37 ++ .../chat_info/shared_content_tabs.dart | 102 +++- lib/frontend/widgets/photo_viewer.dart | 133 ++++-- lib/l10n/app_en.arb | 18 +- lib/l10n/app_localizations.dart | 96 ++++ lib/l10n/app_localizations_en.dart | 49 ++ lib/l10n/app_localizations_ru.dart | 49 ++ lib/l10n/app_ru.arb | 18 +- test/downloads_screen_test.dart | 94 ++++ test/media_viewer_video_test.dart | 1 + 18 files changed, 1566 insertions(+), 102 deletions(-) create mode 100644 lib/core/utils/download_history.dart create mode 100644 lib/core/utils/save_file_as.dart create mode 100644 lib/frontend/screens/downloads_screen.dart create mode 100644 test/downloads_screen_test.dart diff --git a/lib/core/utils/download_history.dart b/lib/core/utils/download_history.dart new file mode 100644 index 0000000..b399624 --- /dev/null +++ b/lib/core/utils/download_history.dart @@ -0,0 +1,338 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../storage/app_instance.dart'; +import 'media_cache.dart'; + +enum DownloadKind { photo, video, gif, audio, file } + +DownloadKind downloadKindForName( + String name, { + DownloadKind fallback = DownloadKind.file, +}) { + final extension = p.extension(name).toLowerCase(); + if (extension == '.gif') return DownloadKind.gif; + if (const { + '.mp4', + '.mkv', + '.mov', + '.webm', + '.avi', + '.m4v', + }.contains(extension)) { + return DownloadKind.video; + } + if (const { + '.mp3', + '.ogg', + '.opus', + '.wav', + '.m4a', + '.flac', + }.contains(extension)) { + return DownloadKind.audio; + } + if (const { + '.jpg', + '.jpeg', + '.png', + '.webp', + '.heic', + '.bmp', + }.contains(extension)) { + return DownloadKind.photo; + } + return fallback; +} + +class DownloadMetadata { + final String cacheName; + final String name; + final DownloadKind kind; + final String sourceName; + final String? thumbnailUrl; + final int expectedSize; + final int? chatId; + final String? messageId; + final int? messageTime; + + const DownloadMetadata({ + required this.cacheName, + this.name = '', + required this.kind, + this.sourceName = '', + this.thumbnailUrl, + this.expectedSize = 0, + this.chatId, + this.messageId, + this.messageTime, + }); +} + +class DownloadRecord { + final String cacheName; + final String name; + final DownloadKind kind; + final String sourceName; + final String? thumbnailUrl; + final int size; + final int downloadedAt; + final int? chatId; + final String? messageId; + final int? messageTime; + + const DownloadRecord({ + required this.cacheName, + required this.name, + required this.kind, + required this.sourceName, + required this.thumbnailUrl, + required this.size, + required this.downloadedAt, + required this.chatId, + required this.messageId, + required this.messageTime, + }); + + factory DownloadRecord.fromJson(Map json) { + final rawKind = json['kind']?.toString(); + final kind = DownloadKind.values.firstWhere( + (value) => value.name == rawKind, + orElse: () => DownloadKind.file, + ); + return DownloadRecord( + cacheName: json['cacheName']?.toString() ?? '', + name: json['name']?.toString() ?? '', + kind: kind, + sourceName: json['sourceName']?.toString() ?? '', + thumbnailUrl: json['thumbnailUrl']?.toString(), + size: (json['size'] as num?)?.toInt() ?? 0, + downloadedAt: (json['downloadedAt'] as num?)?.toInt() ?? 0, + chatId: switch (json['chatId']) { + final num value => value.toInt(), + final String value => int.tryParse(value), + _ => null, + }, + messageId: json['messageId']?.toString(), + messageTime: switch (json['messageTime']) { + final num value => value.toInt(), + final String value => int.tryParse(value), + _ => null, + }, + ); + } + + Map toJson() => { + 'cacheName': cacheName, + 'name': name, + 'kind': kind.name, + 'sourceName': sourceName, + 'thumbnailUrl': thumbnailUrl, + 'size': size, + 'downloadedAt': downloadedAt, + 'chatId': chatId, + 'messageId': messageId, + 'messageTime': messageTime, + }; + + DownloadRecord withSize(int value) => DownloadRecord( + cacheName: cacheName, + name: name, + kind: kind, + sourceName: sourceName, + thumbnailUrl: thumbnailUrl, + size: value, + downloadedAt: downloadedAt, + chatId: chatId, + messageId: messageId, + messageTime: messageTime, + ); +} + +class DownloadHistory { + static const int maxEntries = 200; + static final ValueNotifier> records = ValueNotifier( + const [], + ); + + static Future? _loading; + static Future _mutations = Future.value(); + + static String get _key => 'recent_downloads_v1${AppInstance.suffix}'; + + @visibleForTesting + static void resetForTesting() { + _loading = null; + _mutations = Future.value(); + records.value = const []; + } + + static Future load() => _loading ??= _load(); + + static Future refresh() => _enqueue(() async { + await load(); + final available = await _available(records.value); + if (listEquals(available, records.value)) return; + records.value = List.unmodifiable(available); + await _save(); + }); + + static Future record(DownloadMetadata metadata, File file) => + _enqueue(() async { + await load(); + if (!await file.exists()) return; + final actualSize = await file.length(); + final entry = DownloadRecord( + cacheName: metadata.cacheName, + name: metadata.name, + kind: metadata.kind, + sourceName: metadata.sourceName, + thumbnailUrl: metadata.thumbnailUrl, + size: actualSize > 0 ? actualSize : metadata.expectedSize, + downloadedAt: DateTime.now().millisecondsSinceEpoch, + chatId: metadata.chatId, + messageId: metadata.messageId, + messageTime: metadata.messageTime, + ); + final next = [ + entry, + ...records.value.where((item) => item.cacheName != entry.cacheName), + ]; + if (next.length > maxEntries) next.removeRange(maxEntries, next.length); + records.value = List.unmodifiable(next); + await _save(); + }); + + static Future remove(String cacheName) => _enqueue(() async { + await load(); + final next = records.value + .where((item) => item.cacheName != cacheName) + .toList(growable: false); + if (next.length == records.value.length) return; + records.value = List.unmodifiable(next); + await _save(); + }); + + static Future clear() => _enqueue(() async { + await load(); + records.value = const []; + await _save(); + }); + + static Future fileFor( + DownloadRecord record, { + bool touch = true, + }) async { + if (touch) return MediaCache.existing(record.cacheName); + final file = await MediaCache.fileFor(record.cacheName); + return await file.exists() && await file.length() > 0 ? file : null; + } + + static Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + List loaded; + if (raw == null) { + loaded = await _migrateCache(); + } else { + loaded = _decode(raw); + } + loaded = await _available(loaded); + loaded.sort((a, b) => b.downloadedAt.compareTo(a.downloadedAt)); + if (loaded.length > maxEntries) { + loaded = loaded.sublist(0, maxEntries); + } + records.value = List.unmodifiable(loaded); + await _save(); + } + + static List _decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded + .whereType() + .map( + (item) => DownloadRecord.fromJson(Map.from(item)), + ) + .where((item) => item.cacheName.isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + static Future> _available( + List source, + ) async { + final available = []; + for (final record in source) { + final file = await fileFor(record, touch: false); + if (file == null) continue; + final size = await file.length(); + available.add(size == record.size ? record : record.withSize(size)); + } + return available; + } + + static Future> _migrateCache() async { + final files = await MediaCache.files(); + final migrated = []; + for (final file in files) { + final cacheName = p.basename(file.path); + if (cacheName.startsWith('avatar_') || + cacheName.startsWith('decrypted_') || + cacheName.startsWith('download_thumb_')) { + continue; + } + final stat = await file.stat(); + final kind = cacheName.startsWith('photo_') + ? DownloadKind.photo + : cacheName.startsWith('video_') + ? DownloadKind.video + : downloadKindForName(cacheName); + migrated.add( + DownloadRecord( + cacheName: cacheName, + name: kind == DownloadKind.file ? _fileName(cacheName) : '', + kind: kind, + sourceName: '', + thumbnailUrl: null, + size: stat.size, + downloadedAt: stat.modified.millisecondsSinceEpoch, + chatId: null, + messageId: null, + messageTime: null, + ), + ); + } + return migrated; + } + + static String _fileName(String cacheName) { + final separator = cacheName.indexOf('_'); + if (separator <= 0) return cacheName; + final prefix = cacheName.substring(0, separator); + return int.tryParse(prefix) == null + ? cacheName + : cacheName.substring(separator + 1); + } + + static Future _save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key, + jsonEncode(records.value.map((item) => item.toJson()).toList()), + ); + } + + static Future _enqueue(Future Function() operation) { + final next = _mutations.then((_) => operation()); + _mutations = next.catchError((_) {}); + return next; + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart index 8edb88d..dc2f2b7 100644 --- a/lib/core/utils/file_download.dart +++ b/lib/core/utils/file_download.dart @@ -1,5 +1,6 @@ import 'package:open_filex/open_filex.dart'; +import 'download_history.dart'; import 'media_cache.dart'; class FileDownloadResult { @@ -23,6 +24,7 @@ Future openCachedFile( Future Function() resolveUrl, { void Function(double progress)? onProgress, void Function()? onReady, + DownloadMetadata? download, }) async { var readyFired = false; void ready() { @@ -52,6 +54,11 @@ Future openCachedFile( } ready(); + if (download != null) { + try { + await DownloadHistory.record(download, file); + } catch (_) {} + } final opened = await OpenFilex.open(file.path); return FileDownloadResult( ok: opened.type == ResultType.done, diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart index c229f5c..f911ce3 100644 --- a/lib/core/utils/media_cache.dart +++ b/lib/core/utils/media_cache.dart @@ -42,6 +42,15 @@ class MediaCache { return File(p.join(dir.path, _sanitize(name))); } + static Future> files() async { + final dir = await _cacheDir(); + final files = []; + await for (final entity in dir.list()) { + if (entity is File && !entity.path.endsWith('.part')) files.add(entity); + } + return files; + } + /// Существует ли непустой кэш-файл [name]. /// /// При попадании обновляет mtime файла — это делает вытеснение LRU diff --git a/lib/core/utils/media_saver.dart b/lib/core/utils/media_saver.dart index 9907a30..6ea6ebe 100644 --- a/lib/core/utils/media_saver.dart +++ b/lib/core/utils/media_saver.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; +import 'download_history.dart'; import 'media_cache.dart'; class MediaSaveResult { @@ -47,6 +48,7 @@ Future saveMediaFile({ required Future Function() resolveUrl, required String saveName, required SaveMediaKind kind, + DownloadMetadata? download, }) async { try { var file = await MediaCache.existing(cacheName); @@ -60,7 +62,13 @@ Future saveMediaFile({ if (file == null) { return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); } - return _persist(file, saveName: saveName, kind: kind); + final result = await _persist(file, saveName: saveName, kind: kind); + if (result.ok && download != null) { + try { + await DownloadHistory.record(download, file); + } catch (_) {} + } + return result; } catch (e) { return MediaSaveResult(ok: false, error: e.toString()); } diff --git a/lib/core/utils/save_file_as.dart b/lib/core/utils/save_file_as.dart new file mode 100644 index 0000000..e0ba479 --- /dev/null +++ b/lib/core/utils/save_file_as.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart' as p; + +class SaveFileAsResult { + final bool saved; + final bool cancelled; + final String? path; + final String? error; + + const SaveFileAsResult({ + required this.saved, + this.cancelled = false, + this.path, + this.error, + }); +} + +Future saveFileAs({ + required File source, + required String fileName, + required String dialogTitle, +}) async { + try { + final directory = await FilePicker.platform.getDirectoryPath( + dialogTitle: dialogTitle, + ); + if (directory == null) { + return const SaveFileAsResult(saved: false, cancelled: true); + } + final safeName = p.basename(fileName).trim(); + final target = await _availableTarget( + directory, + safeName.isEmpty ? p.basename(source.path) : safeName, + ); + if (p.equals(p.absolute(source.path), p.absolute(target.path))) { + return SaveFileAsResult(saved: true, path: target.path); + } + await source.copy(target.path); + return SaveFileAsResult(saved: true, path: target.path); + } catch (error) { + return SaveFileAsResult(saved: false, error: error.toString()); + } +} + +Future _availableTarget(String directory, String fileName) async { + var target = File(p.join(directory, fileName)); + if (!await target.exists()) return target; + final extension = p.extension(fileName); + final stem = p.basenameWithoutExtension(fileName); + var suffix = 2; + while (await target.exists()) { + target = File(p.join(directory, '$stem ($suffix)$extension')); + suffix++; + } + return target; +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index c92a349..6d72a0d 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -24,7 +24,9 @@ import '../../widgets/sliding_pill_nav.dart'; import '../../widgets/springy_tap.dart'; import '../../widgets/formatted_message_text.dart'; import '../../../core/utils/format.dart'; +import '../../../core/utils/download_history.dart'; import '../../../core/utils/text_format.dart'; +import '../../../l10n/app_localizations.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -65,6 +67,7 @@ import '../stories/story_composer_screen.dart'; import '../stories/story_owner_info.dart'; import '../stories/story_ring.dart'; import '../stories/story_viewer_screen.dart'; +import '../downloads_screen.dart'; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -1340,64 +1343,94 @@ class _ChatListScreenState extends State child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - if (AppStories.current.value && - _pullRatio < 0.8 && - storiesModule.hasAny) - Opacity( - opacity: 1.0 - _pullRatio, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _openStories(0), - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, - ), - child: FoldedStoryStack( - previews: storiesModule.previews, - opacity: 1.0 - _pullRatio, + Expanded( + child: Row( + children: [ + if (AppStories.current.value && + _pullRatio < 0.8 && + storiesModule.hasAny) + Opacity( + opacity: 1.0 - _pullRatio, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openStories(0), + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: FoldedStoryStack( + previews: + storiesModule.previews, + opacity: 1.0 - _pullRatio, + ), ), ), ), + Flexible( + child: Text( + connectionStatusLabel( + _sessionState, + ) ?? + (_profile?.firstName ?? 'Чат'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), ), - Text( - connectionStatusLabel(_sessionState) ?? - (_profile?.firstName ?? 'Чат'), - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], + ], + ), ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - onSelected: _onOverflowMenuSelected, - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Избранное', - Symbols.bookmark, - ), - _buildPopupMenuItem( - 2, - 'Прочитать всё', - Symbols.done_all, + const SizedBox(width: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!widget.forwardMode && + !widget.archiveMode) + IconButton( + key: const ValueKey('downloads-button'), + tooltip: AppLocalizations.of( + context, + )!.downloadsTooltip, + icon: Icon( + Symbols.download_for_offline, + color: cs.outline, + weight: 400, + ), + onPressed: () => + unawaited(_openDownloads()), + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + onSelected: _onOverflowMenuSelected, + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Избранное', + Symbols.bookmark, + ), + _buildPopupMenuItem( + 2, + 'Прочитать всё', + Symbols.done_all, + ), + ], ), ], ), @@ -2605,7 +2638,8 @@ class _ChatListScreenState extends State ), MessageDecryptionState.wrongKey => _buildPreviewLine( cs, - '$previewPrefix' 'неверный ключ', + '$previewPrefix' + 'неверный ключ', const [], draft, true, @@ -2971,6 +3005,63 @@ class _ChatListScreenState extends State } } + Future _openDownloads() async { + final record = await pushSwipeable( + context, + (_) => const DownloadsScreen(), + ); + if (record == null || !mounted) return; + await _openDownloadedMessage(record); + } + + Future _openDownloadedMessage(DownloadRecord record) async { + final chatId = record.chatId; + final messageId = record.messageId; + if (chatId == null || messageId == null || messageId.isEmpty) return; + final profile = _profile ?? await AppDatabase.loadActiveProfile(); + if (profile == null || !mounted) return; + + CachedChat? chat; + for (final item in _chats) { + if (item.id == chatId) { + chat = item; + break; + } + } + if (chat == null) { + await chats.ensureChatCached(api, profile.id, chatId); + final cached = await chats.getChat(profile.id, chatId); + if (cached.isNotEmpty) chat = cached.first; + } + if (!mounted) return; + + final selection = DesktopChatSelection( + chatId: chatId, + name: + chat?.title ?? + (record.sourceName.trim().isEmpty ? 'Чат' : record.sourceName.trim()), + imageUrl: chat?.iconUrl ?? '', + chatType: chat?.type ?? 'CHAT', + initialMessageId: messageId, + initialMessageTime: record.messageTime, + ); + if (widget.onChatSelected != null) { + widget.onChatSelected!(selection); + return; + } + pushSwipeable( + context, + (_) => ChatScreen( + chatId: selection.chatId, + name: selection.name, + imageUrl: selection.imageUrl, + chatType: selection.chatType, + initialMessageId: selection.initialMessageId, + initialMessageTime: selection.initialMessageTime, + ), + ); + } + void _openSavedMessages() { CachedChat? self; for (final c in _chats) { diff --git a/lib/frontend/screens/downloads_screen.dart b/lib/frontend/screens/downloads_screen.dart new file mode 100644 index 0000000..4c32fd8 --- /dev/null +++ b/lib/frontend/screens/downloads_screen.dart @@ -0,0 +1,440 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path/path.dart' as p; + +import '../../core/utils/download_history.dart'; +import '../../core/utils/format.dart'; +import '../../core/utils/save_file_as.dart'; +import '../../l10n/app_localizations.dart'; +import '../widgets/chat_menu_overlay.dart'; +import '../widgets/confirm_dialog.dart'; +import '../widgets/custom_notification.dart'; +import '../widgets/small_spinner.dart'; + +class DownloadsScreen extends StatefulWidget { + const DownloadsScreen({super.key}); + + @override + State createState() => _DownloadsScreenState(); +} + +class _DownloadsScreenState extends State { + late bool _loading = DownloadHistory.records.value.isEmpty; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + if (!_loading) { + DownloadHistory.refresh().ignore(); + return; + } + await DownloadHistory.load(); + if (mounted) setState(() => _loading = false); + await DownloadHistory.refresh(); + } + + Future _open(DownloadRecord record) async { + final file = await DownloadHistory.fileFor(record); + if (!mounted) return; + if (file == null) { + await DownloadHistory.remove(record.cacheName); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.downloadsOpenFailed, + ); + } + return; + } + final result = await OpenFilex.open(file.path); + if (!mounted || result.type == ResultType.done) return; + showCustomNotification( + context, + AppLocalizations.of(context)!.downloadsOpenFailed, + ); + } + + Future _saveAs(DownloadRecord record) async { + final file = await DownloadHistory.fileFor(record); + if (!mounted) return; + if (file == null) { + await DownloadHistory.remove(record.cacheName); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.downloadsOpenFailed, + ); + } + return; + } + final result = await saveFileAs( + source: file, + fileName: _saveName(record), + dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs, + ); + if (!mounted || result.cancelled) return; + showCustomNotification( + context, + result.saved ? 'Файл сохранён' : 'Не удалось сохранить файл', + ); + } + + void _goToMessage(DownloadRecord record) { + if (record.chatId == null || record.messageId?.isNotEmpty != true) return; + Navigator.of(context).pop(record); + } + + String _saveName(DownloadRecord record) { + final name = record.name.trim(); + if (name.isNotEmpty) return p.basename(name); + final extension = p.extension(record.cacheName); + final stamp = record.downloadedAt > 0 + ? record.downloadedAt + : DateTime.now().millisecondsSinceEpoch; + return switch (record.kind) { + DownloadKind.photo => + 'IMG_$stamp${extension.isEmpty ? '.jpg' : extension}', + DownloadKind.video => + 'VID_$stamp${extension.isEmpty ? '.mp4' : extension}', + DownloadKind.gif => 'GIF_$stamp${extension.isEmpty ? '.gif' : extension}', + DownloadKind.audio => + 'AUD_$stamp${extension.isEmpty ? '.ogg' : extension}', + DownloadKind.file => p.basename(record.cacheName), + }; + } + + Future _settings() async { + final l10n = AppLocalizations.of(context)!; + final clear = await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: ListTile( + leading: Icon( + Symbols.delete_sweep, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text(l10n.downloadsClearHistory), + onTap: () => Navigator.pop(sheetContext, true), + ), + ), + ), + ); + if (clear != true || !mounted) return; + final confirmed = await showConfirmDialog( + context, + title: l10n.downloadsClearTitle, + message: l10n.downloadsClearBody, + confirmLabel: l10n.downloadsClearConfirm, + cancelLabel: MaterialLocalizations.of(context).cancelButtonLabel, + destructive: true, + ); + if (!confirmed || !mounted) return; + await DownloadHistory.clear(); + if (mounted) showCustomNotification(context, l10n.downloadsHistoryCleared); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + titleSpacing: 4, + title: Text( + l10n.downloadsTitle, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + actions: [ + TextButton( + key: const ValueKey('downloads-settings'), + onPressed: _settings, + child: Text(l10n.downloadsSettings), + ), + const SizedBox(width: 8), + ], + ), + body: _loading + ? Center(child: SmallSpinner(size: 28, color: cs.primary)) + : ValueListenableBuilder>( + valueListenable: DownloadHistory.records, + builder: (context, records, _) { + if (records.isEmpty) { + return _DownloadsEmpty(label: l10n.downloadsEmpty); + } + return ListView.separated( + key: const ValueKey('downloads-list'), + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 32), + itemCount: records.length, + separatorBuilder: (_, _) => Divider( + height: 1, + indent: 92, + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + itemBuilder: (context, index) { + final record = records[index]; + return _DownloadTile( + record: record, + onTap: () => _open(record), + onSaveAs: () => _saveAs(record), + onGoToMessage: + record.chatId != null && + record.messageId?.isNotEmpty == true + ? () => _goToMessage(record) + : null, + ); + }, + ); + }, + ), + ); + } +} + +class _DownloadsEmpty extends StatelessWidget { + final String label; + + const _DownloadsEmpty({required this.label}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.download, + size: 52, + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + ), + const SizedBox(height: 12), + Text( + label, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + ], + ), + ), + ); + } +} + +class _DownloadTile extends StatelessWidget { + final DownloadRecord record; + final VoidCallback onTap; + final VoidCallback onSaveAs; + final VoidCallback? onGoToMessage; + + const _DownloadTile({ + required this.record, + required this.onTap, + required this.onSaveAs, + required this.onGoToMessage, + }); + + void _openMenu(BuildContext context) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final l10n = AppLocalizations.of(context)!; + showChatMenu( + context: context, + anchorRect: box.localToGlobal(Offset.zero) & box.size, + items: [ + if (onGoToMessage != null) + ChatMenuItem( + icon: Symbols.visibility, + label: l10n.sharedGoToMessage, + onTap: onGoToMessage, + ), + ChatMenuItem( + icon: Symbols.download, + label: l10n.photoViewerSaveAs, + onTap: onSaveAs, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final source = record.sourceName.trim().isEmpty + ? l10n.downloadsUnknownSource + : record.sourceName.trim(); + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 10, 16, 10), + child: Row( + children: [ + _DownloadPreview(record: record), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _title(l10n), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + height: 1.15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 5), + Text( + '${formatBytes(record.size)} · $source', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + ), + ), + Builder( + builder: (buttonContext) => IconButton( + key: ValueKey('download-more-${record.cacheName}'), + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + icon: Icon(Symbols.more_vert, color: cs.onSurfaceVariant), + onPressed: () => _openMenu(buttonContext), + ), + ), + ], + ), + ), + ); + } + + String _title(AppLocalizations l10n) { + if (record.name.trim().isNotEmpty) return record.name.trim(); + return switch (record.kind) { + DownloadKind.photo => l10n.downloadsPhoto, + DownloadKind.video => l10n.downloadsVideo, + DownloadKind.gif => l10n.downloadsGif, + DownloadKind.audio => l10n.downloadsAudio, + DownloadKind.file => l10n.downloadsFile, + }; + } +} + +class _DownloadPreview extends StatefulWidget { + final DownloadRecord record; + + const _DownloadPreview({required this.record}); + + @override + State<_DownloadPreview> createState() => _DownloadPreviewState(); +} + +class _DownloadPreviewState extends State<_DownloadPreview> { + late Future _file = DownloadHistory.fileFor( + widget.record, + touch: false, + ); + + @override + void didUpdateWidget(_DownloadPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.record.cacheName != widget.record.cacheName) { + _file = DownloadHistory.fileFor(widget.record, touch: false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final preview = widget.record.thumbnailUrl; + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 58, + height: 58, + child: preview != null && preview.isNotEmpty + ? CachedNetworkImage( + imageUrl: preview, + fit: BoxFit.cover, + memCacheWidth: 160, + errorWidget: (_, _, _) => _fallback(cs), + ) + : FutureBuilder( + future: _file, + builder: (context, snapshot) { + final file = snapshot.data; + if (file != null && + (widget.record.kind == DownloadKind.photo || + widget.record.kind == DownloadKind.gif)) { + return Image.file( + file, + fit: BoxFit.cover, + cacheWidth: 160, + errorBuilder: (_, _, _) => _fallback(cs), + ); + } + return _fallback(cs); + }, + ), + ), + ); + } + + Widget _fallback(ColorScheme cs) { + final extension = p + .extension(widget.record.name) + .replaceFirst('.', '') + .toUpperCase(); + final (color, icon) = switch (widget.record.kind) { + DownloadKind.photo => (const Color(0xFF3CA95E), Symbols.image), + DownloadKind.video => (const Color(0xFF4A8FE7), Symbols.movie), + DownloadKind.gif => (const Color(0xFFE684AE), Symbols.gif_box), + DownloadKind.audio => (const Color(0xFF8C68D8), Symbols.audio_file), + DownloadKind.file => (const Color(0xFFF2B735), Symbols.description), + }; + return ColoredBox( + color: color, + child: Stack( + alignment: Alignment.center, + children: [ + Icon(icon, color: Colors.white, size: 30), + if (extension.isNotEmpty && widget.record.kind == DownloadKind.file) + Positioned( + bottom: 4, + child: Text( + extension.length > 5 ? extension.substring(0, 5) : extension, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart index cc39617..dddedf2 100644 --- a/lib/frontend/widgets/adaptive_shell.dart +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -23,12 +23,16 @@ class DesktopChatSelection { final String name; final String imageUrl; final String chatType; + final String? initialMessageId; + final int? initialMessageTime; const DesktopChatSelection({ required this.chatId, required this.name, required this.imageUrl, required this.chatType, + this.initialMessageId, + this.initialMessageTime, }); } @@ -139,11 +143,15 @@ class _AdaptiveShellState extends State { child: _selected == null ? _EmptyChatPane(colorScheme: cs) : ChatScreen( - key: ValueKey(_selected!.chatId), + key: ValueKey( + '${_selected!.chatId}:${_selected!.initialMessageId ?? ''}', + ), chatId: _selected!.chatId, name: _selected!.name, imageUrl: _selected!.imageUrl, chatType: _selected!.chatType, + initialMessageId: _selected!.initialMessageId, + initialMessageTime: _selected!.initialMessageTime, embedded: true, onClose: _closeChat, ), diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart index 279244f..b3c6ef4 100644 --- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/main.dart'; import '../../../../core/utils/download_progress.dart'; +import '../../../../core/utils/download_history.dart'; import '../../../../core/utils/file_download.dart'; import '../../../../core/utils/media_cache.dart'; import '../../../../core/utils/format.dart'; @@ -219,6 +220,27 @@ class FileBubble extends StatelessWidget { return; } + final kind = downloadKindForName(name); + try { + await DownloadHistory.record( + DownloadMetadata( + cacheName: cacheName, + name: kind == DownloadKind.file ? name : '', + kind: kind, + sourceName: ctx.chatName ?? '', + thumbnailUrl: + file.preview?.baseUrl ?? + file.preview?.previewData ?? + file.previewData, + expectedSize: file.size ?? 0, + chatId: ctx.message.chatId, + messageId: ctx.message.id, + messageTime: ctx.message.time, + ), + local, + ); + } catch (_) {} + final shown = await _decryptIfNeeded(local, cacheName); if (!context.mounted) return; if (shown == null) { @@ -268,6 +290,7 @@ class FileBubble extends StatelessWidget { final cacheName = '${fileId}_$name'; final cached = (await MediaCache.existing(cacheName)) != null; + final kind = downloadKindForName(name); if (!cached) MediaDownloadProgress.set(cacheName, 0); final result = await openCachedFile( @@ -281,6 +304,20 @@ class FileBubble extends StatelessWidget { onReady: () { if (!cached) MediaDownloadProgress.set(cacheName, null); }, + download: DownloadMetadata( + cacheName: cacheName, + name: kind == DownloadKind.file ? name : '', + kind: kind, + sourceName: ctx.chatName ?? '', + thumbnailUrl: + file.preview?.baseUrl ?? + file.preview?.previewData ?? + file.previewData, + expectedSize: file.size ?? 0, + chatId: ctx.message.chatId, + messageId: ctx.message.id, + messageTime: ctx.message.time, + ), ); if (!context.mounted) return; if (!result.ok) { diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index e0dcb08..d5cb0da 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -7,9 +7,11 @@ import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:ogg_opus_player/ogg_opus_player.dart'; -import '../../../backend/modules/messages.dart' show CachedMessage, ContactCache; +import '../../../backend/modules/messages.dart' + show CachedMessage, ContactCache; import '../../../backend/modules/shared_content.dart'; import '../../../core/cache/info_cache.dart'; +import '../../../core/utils/download_history.dart'; import '../../../core/utils/download_progress.dart'; import '../../../core/utils/file_download.dart'; import '../../../core/utils/format.dart'; @@ -103,9 +105,7 @@ Widget _emptyState(ColorScheme cs, String label, IconData icon) { Widget _loadingState(ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(vertical: 56), - child: Center( - child: SmallSpinner(size: 26, color: cs.primary), - ), + child: Center(child: SmallSpinner(size: 26, color: cs.primary)), ); } @@ -231,6 +231,7 @@ void _notifySave(BuildContext context, MediaSaveResult result) { Future _downloadAttachment( BuildContext context, SharedMediaItem item, + String sourceName, ) async { final att = item.attachment; final now = DateTime.now().millisecondsSinceEpoch; @@ -243,6 +244,16 @@ Future _downloadAttachment( resolveUrl: () async => url, saveName: 'IMG_$now.jpg', kind: SaveMediaKind.image, + download: DownloadMetadata( + cacheName: 'photo_${att.photoId ?? url.hashCode}.jpg', + kind: DownloadKind.photo, + sourceName: sourceName, + thumbnailUrl: url, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); return; @@ -262,6 +273,16 @@ Future _downloadAttachment( }, saveName: 'VID_$now.mp4', kind: SaveMediaKind.video, + download: DownloadMetadata( + cacheName: 'video_${att.videoId ?? item.messageId}.mp4', + kind: DownloadKind.video, + sourceName: sourceName, + thumbnailUrl: att.thumbnail ?? att.baseUrl ?? att.previewData, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); return; @@ -271,6 +292,7 @@ Future _downloadAttachment( final fileId = att.fileId; if (fileId == null) return; final name = att.name ?? 'file_$now'; + final downloadKind = downloadKindForName(name); final result = await saveMediaFile( cacheName: '${fileId}_$name', resolveUrl: () => messagesModule.getFileUrl( @@ -280,6 +302,18 @@ Future _downloadAttachment( ), saveName: name, kind: SaveMediaKind.file, + download: DownloadMetadata( + cacheName: '${fileId}_$name', + name: downloadKind == DownloadKind.file ? name : '', + kind: downloadKind, + sourceName: sourceName, + thumbnailUrl: + att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); return; @@ -293,6 +327,15 @@ Future _downloadAttachment( resolveUrl: () async => url, saveName: 'AUD_$now.ogg', kind: SaveMediaKind.file, + download: DownloadMetadata( + cacheName: '${att.audioId ?? item.messageId}.ogg', + kind: DownloadKind.audio, + sourceName: sourceName, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); } @@ -593,7 +636,13 @@ class _SharedMediaTabState extends State children.add(_mediaGrid(cs, group.items)); case SharedContentKind.files: children.addAll( - group.items.map((i) => _FileRow(item: i, onGoTo: () => _goTo(i))), + group.items.map( + (i) => _FileRow( + item: i, + sourceName: widget.sourceName, + onGoTo: () => _goTo(i), + ), + ), ); case SharedContentKind.voice: children.addAll( @@ -601,6 +650,7 @@ class _SharedMediaTabState extends State (i) => _ProfileVoiceTile( item: i, senderName: _resolveName(i.senderId), + sourceName: widget.sourceName, onGoTo: () => _goTo(i), ), ), @@ -645,13 +695,12 @@ class _SharedMediaTabState extends State crossAxisSpacing: 3, ), itemCount: items.length, - itemBuilder: (context, index) => - _MediaTile( - item: items[index], - onGoTo: () => _goTo(items[index]), - onGoToMessage: widget.onGoToMessage, - sourceName: widget.sourceName, - ), + itemBuilder: (context, index) => _MediaTile( + item: items[index], + onGoTo: () => _goTo(items[index]), + onGoToMessage: widget.onGoToMessage, + sourceName: widget.sourceName, + ), ); } } @@ -676,7 +725,7 @@ class _MediaTile extends StatelessWidget { onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { - await _downloadAttachment(context, item); + await _downloadAttachment(context, item, sourceName); }), ]); } @@ -810,9 +859,14 @@ class _MediaTile extends StatelessWidget { class _FileRow extends StatelessWidget { final SharedMediaItem item; + final String sourceName; final VoidCallback onGoTo; - const _FileRow({required this.item, required this.onGoTo}); + const _FileRow({ + required this.item, + required this.sourceName, + required this.onGoTo, + }); void _menu(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -821,7 +875,7 @@ class _FileRow extends StatelessWidget { onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { - await _downloadAttachment(context, item); + await _downloadAttachment(context, item, sourceName); }), ]); } @@ -946,6 +1000,20 @@ class _FileRow extends StatelessWidget { ), onProgress: (p) => MediaDownloadProgress.set(cacheName, p), onReady: () => MediaDownloadProgress.set(cacheName, null), + download: DownloadMetadata( + cacheName: cacheName, + name: downloadKindForName(att.name ?? '') == DownloadKind.file + ? att.name ?? '' + : '', + kind: downloadKindForName(att.name ?? ''), + sourceName: sourceName, + thumbnailUrl: + att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (!context.mounted) return; @@ -1083,11 +1151,13 @@ class _LinkRow extends StatelessWidget { class _ProfileVoiceTile extends StatefulWidget { final SharedMediaItem item; final String senderName; + final String sourceName; final VoidCallback onGoTo; const _ProfileVoiceTile({ required this.item, required this.senderName, + required this.sourceName, required this.onGoTo, }); @@ -1262,7 +1332,7 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { widget.onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { - await _downloadAttachment(context, widget.item); + await _downloadAttachment(context, widget.item, widget.sourceName); }), ]); } diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index a4d32eb..067c61c 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -3,8 +3,6 @@ import 'dart:collection'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -14,9 +12,11 @@ import '../../backend/modules/messages.dart'; import '../../backend/modules/shared_content.dart'; import '../../core/cache/info_cache.dart'; import '../../core/config/app_frost.dart'; +import '../../core/utils/download_history.dart'; import '../../core/utils/format.dart'; import '../../core/utils/media_cache.dart'; import '../../core/utils/media_saver.dart'; +import '../../core/utils/save_file_as.dart'; import '../../l10n/app_localizations.dart'; import '../../main.dart'; import '../../models/attachment.dart'; @@ -456,6 +456,45 @@ class _PhotoViewerScreenState extends State { String _cacheNameFor(PhotoAttachment photo, String url) => 'photo_${photo.photoId ?? (url.hashCode & 0x7fffffff)}.jpg'; + String _downloadSource(_ViewerMedia item) { + final sourceName = widget.sourceName?.trim(); + if (sourceName != null && sourceName.isNotEmpty) return sourceName; + return ContactCache.get(item.senderId) ?? ''; + } + + DownloadMetadata _photoDownload( + _ViewerMedia item, + PhotoAttachment photo, + String cacheName, + ) => DownloadMetadata( + cacheName: cacheName, + kind: DownloadKind.photo, + sourceName: _downloadSource(item), + thumbnailUrl: photo.baseUrl ?? photo.previewData, + expectedSize: photo.size ?? 0, + chatId: widget.chatId, + messageId: item.messageId.isEmpty ? null : item.messageId, + messageTime: item.time, + ); + + String _videoCacheName(_ViewerMedia item, VideoAttachment video) => + 'video_${video.videoId ?? item.messageId}.mp4'; + + DownloadMetadata _videoDownload( + _ViewerMedia item, + VideoAttachment video, + String cacheName, + ) => DownloadMetadata( + cacheName: cacheName, + kind: DownloadKind.video, + sourceName: _downloadSource(item), + thumbnailUrl: video.thumbnail ?? video.baseUrl ?? video.previewData, + expectedSize: video.size ?? 0, + chatId: widget.chatId, + messageId: item.messageId.isEmpty ? null : item.messageId, + messageTime: item.time, + ); + Future _fileFor(PhotoAttachment photo) async { final localPath = photo.localPath; if (localPath != null) { @@ -467,12 +506,25 @@ class _PhotoViewerScreenState extends State { return MediaCache.getOrDownload(_cacheNameFor(photo, url), url); } + Future _videoFileFor(_ViewerMedia item) async { + final video = item.video; + if (video == null) return null; + final sources = await _loadVideoSources(item); + if (sources.isEmpty) return null; + final sessionQuality = _videoSessions[item.id]?.quality; + final url = sessionQuality != null + ? sources[sessionQuality] ?? sources.values.first + : sources.values.first; + return MediaCache.getOrDownload(_videoCacheName(item, video), url); + } + Future _save() async { final photo = _current.photo; if (photo == null || _saving) return; setState(() => _saving = true); final localPath = photo.localPath; final url = photo.baseUrl ?? ''; + final cacheName = _cacheNameFor(photo, url); final MediaSaveResult result; if (localPath != null) { @@ -481,10 +533,11 @@ class _PhotoViewerScreenState extends State { result = const MediaSaveResult(ok: false, error: 'нет ссылки'); } else { result = await saveMediaFile( - cacheName: _cacheNameFor(photo, url), + cacheName: cacheName, resolveUrl: () async => url, saveName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', kind: SaveMediaKind.image, + download: _photoDownload(_current, photo, cacheName), ); } @@ -504,35 +557,59 @@ class _PhotoViewerScreenState extends State { } Future _saveAs() async { - final photo = _current.photo; - if (photo == null) { - showCustomNotification(context, 'Сохранение видео появится позже'); - return; - } - final file = await _fileFor(photo); - if (!mounted) return; - if (file == null) { - showCustomNotification(context, 'Не удалось загрузить фото'); - return; - } + if (_saving) return; + setState(() => _saving = true); + try { + final item = _current; + final now = DateTime.now().millisecondsSinceEpoch; + File? file; + DownloadMetadata? download; + String saveName; - final bytes = await file.readAsBytes(); - if (!mounted) return; + final photo = item.photo; + final video = item.video; + if (photo != null) { + file = await _fileFor(photo); + final url = photo.baseUrl ?? ''; + final cacheName = _cacheNameFor(photo, url); + if (url.isNotEmpty) download = _photoDownload(item, photo, cacheName); + saveName = 'IMG_$now.jpg'; + } else if (video != null) { + file = await _videoFileFor(item); + final cacheName = _videoCacheName(item, video); + download = _videoDownload(item, video, cacheName); + saveName = 'VID_$now.mp4'; + } else { + file = null; + saveName = 'media_$now'; + } - final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); - final path = await FilePicker.platform.saveFile( - dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs, - fileName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', - type: FileType.any, - bytes: isMobile ? bytes : null, - ); - if (path == null || !mounted) return; - - if (!isMobile) { - await File(path).writeAsBytes(bytes); if (!mounted) return; + if (file == null) { + showCustomNotification(context, 'Не удалось загрузить медиа'); + return; + } + final result = await saveFileAs( + source: file, + fileName: saveName, + dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs, + ); + if (!mounted || result.cancelled) return; + if (!result.saved) { + showCustomNotification(context, 'Не удалось сохранить файл'); + return; + } + if (download != null) { + try { + await DownloadHistory.record(download, file); + } catch (_) {} + } + if (mounted) showCustomNotification(context, 'Файл сохранён'); + } catch (_) { + if (mounted) showCustomNotification(context, 'Не удалось сохранить файл'); + } finally { + if (mounted) setState(() => _saving = false); } - showCustomNotification(context, 'Файл сохранён'); } void _openMenu(BuildContext anchorContext) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cf5b540..e53f1f1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -979,5 +979,21 @@ "editContactDeleteConfirmTitle": "Delete contact?", "editContactDeleteConfirmBody": "This contact will be removed from your list.", "editContactDeleteCancel": "Cancel", - "editContactError": "Couldn't save changes" + "editContactError": "Couldn't save changes", + "downloadsTitle": "Recent downloads", + "downloadsTooltip": "Downloads", + "downloadsSettings": "Settings", + "downloadsEmpty": "Downloaded files will appear here", + "downloadsUnknownSource": "Unknown source", + "downloadsPhoto": "Photo", + "downloadsVideo": "Video", + "downloadsGif": "GIF", + "downloadsAudio": "Audio", + "downloadsFile": "File", + "downloadsOpenFailed": "Couldn't open the file", + "downloadsClearHistory": "Clear download history", + "downloadsClearTitle": "Clear download history?", + "downloadsClearBody": "The files will stay on the device, but this list will be cleared.", + "downloadsClearConfirm": "Clear", + "downloadsHistoryCleared": "Download history cleared" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 12a5700..9c67def 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4291,6 +4291,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Couldn\'t save changes'** String get editContactError; + + /// No description provided for @downloadsTitle. + /// + /// In en, this message translates to: + /// **'Recent downloads'** + String get downloadsTitle; + + /// No description provided for @downloadsTooltip. + /// + /// In en, this message translates to: + /// **'Downloads'** + String get downloadsTooltip; + + /// No description provided for @downloadsSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get downloadsSettings; + + /// No description provided for @downloadsEmpty. + /// + /// In en, this message translates to: + /// **'Downloaded files will appear here'** + String get downloadsEmpty; + + /// No description provided for @downloadsUnknownSource. + /// + /// In en, this message translates to: + /// **'Unknown source'** + String get downloadsUnknownSource; + + /// No description provided for @downloadsPhoto. + /// + /// In en, this message translates to: + /// **'Photo'** + String get downloadsPhoto; + + /// No description provided for @downloadsVideo. + /// + /// In en, this message translates to: + /// **'Video'** + String get downloadsVideo; + + /// No description provided for @downloadsGif. + /// + /// In en, this message translates to: + /// **'GIF'** + String get downloadsGif; + + /// No description provided for @downloadsAudio. + /// + /// In en, this message translates to: + /// **'Audio'** + String get downloadsAudio; + + /// No description provided for @downloadsFile. + /// + /// In en, this message translates to: + /// **'File'** + String get downloadsFile; + + /// No description provided for @downloadsOpenFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t open the file'** + String get downloadsOpenFailed; + + /// No description provided for @downloadsClearHistory. + /// + /// In en, this message translates to: + /// **'Clear download history'** + String get downloadsClearHistory; + + /// No description provided for @downloadsClearTitle. + /// + /// In en, this message translates to: + /// **'Clear download history?'** + String get downloadsClearTitle; + + /// No description provided for @downloadsClearBody. + /// + /// In en, this message translates to: + /// **'The files will stay on the device, but this list will be cleared.'** + String get downloadsClearBody; + + /// No description provided for @downloadsClearConfirm. + /// + /// In en, this message translates to: + /// **'Clear'** + String get downloadsClearConfirm; + + /// No description provided for @downloadsHistoryCleared. + /// + /// In en, this message translates to: + /// **'Download history cleared'** + String get downloadsHistoryCleared; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index df45056..b612924 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2237,4 +2237,53 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editContactError => 'Couldn\'t save changes'; + + @override + String get downloadsTitle => 'Recent downloads'; + + @override + String get downloadsTooltip => 'Downloads'; + + @override + String get downloadsSettings => 'Settings'; + + @override + String get downloadsEmpty => 'Downloaded files will appear here'; + + @override + String get downloadsUnknownSource => 'Unknown source'; + + @override + String get downloadsPhoto => 'Photo'; + + @override + String get downloadsVideo => 'Video'; + + @override + String get downloadsGif => 'GIF'; + + @override + String get downloadsAudio => 'Audio'; + + @override + String get downloadsFile => 'File'; + + @override + String get downloadsOpenFailed => 'Couldn\'t open the file'; + + @override + String get downloadsClearHistory => 'Clear download history'; + + @override + String get downloadsClearTitle => 'Clear download history?'; + + @override + String get downloadsClearBody => + 'The files will stay on the device, but this list will be cleared.'; + + @override + String get downloadsClearConfirm => 'Clear'; + + @override + String get downloadsHistoryCleared => 'Download history cleared'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 04d27fc..e7ef6e1 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2251,4 +2251,53 @@ class AppLocalizationsRu extends AppLocalizations { @override String get editContactError => 'Не удалось сохранить изменения'; + + @override + String get downloadsTitle => 'Недавние загрузки'; + + @override + String get downloadsTooltip => 'Загрузки'; + + @override + String get downloadsSettings => 'Настройки'; + + @override + String get downloadsEmpty => 'Скачанные файлы появятся здесь'; + + @override + String get downloadsUnknownSource => 'Источник неизвестен'; + + @override + String get downloadsPhoto => 'Фото'; + + @override + String get downloadsVideo => 'Видео'; + + @override + String get downloadsGif => 'GIF'; + + @override + String get downloadsAudio => 'Аудио'; + + @override + String get downloadsFile => 'Файл'; + + @override + String get downloadsOpenFailed => 'Не удалось открыть файл'; + + @override + String get downloadsClearHistory => 'Очистить историю загрузок'; + + @override + String get downloadsClearTitle => 'Очистить историю загрузок?'; + + @override + String get downloadsClearBody => + 'Файлы останутся на устройстве, но этот список будет очищен.'; + + @override + String get downloadsClearConfirm => 'Очистить'; + + @override + String get downloadsHistoryCleared => 'История загрузок очищена'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5bf7bc4..fb2df60 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -737,5 +737,21 @@ "editContactDeleteConfirmTitle": "Удалить контакт?", "editContactDeleteConfirmBody": "Контакт будет удалён из вашего списка.", "editContactDeleteCancel": "Отмена", - "editContactError": "Не удалось сохранить изменения" + "editContactError": "Не удалось сохранить изменения", + "downloadsTitle": "Недавние загрузки", + "downloadsTooltip": "Загрузки", + "downloadsSettings": "Настройки", + "downloadsEmpty": "Скачанные файлы появятся здесь", + "downloadsUnknownSource": "Источник неизвестен", + "downloadsPhoto": "Фото", + "downloadsVideo": "Видео", + "downloadsGif": "GIF", + "downloadsAudio": "Аудио", + "downloadsFile": "Файл", + "downloadsOpenFailed": "Не удалось открыть файл", + "downloadsClearHistory": "Очистить историю загрузок", + "downloadsClearTitle": "Очистить историю загрузок?", + "downloadsClearBody": "Файлы останутся на устройстве, но этот список будет очищен.", + "downloadsClearConfirm": "Очистить", + "downloadsHistoryCleared": "История загрузок очищена" } diff --git a/test/downloads_screen_test.dart b/test/downloads_screen_test.dart new file mode 100644 index 0000000..5c846b3 --- /dev/null +++ b/test/downloads_screen_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/utils/download_history.dart'; +import 'package:komet/core/utils/media_cache.dart'; +import 'package:komet/frontend/screens/downloads_screen.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _SyntheticPathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + final String directory; + + _SyntheticPathProvider(this.directory); + + @override + Future getApplicationSupportPath() async => directory; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('download kinds are inferred from synthetic file names', () { + expect(downloadKindForName('sample.gif'), DownloadKind.gif); + expect(downloadKindForName('clip.mp4'), DownloadKind.video); + expect(downloadKindForName('sound.ogg'), DownloadKind.audio); + expect(downloadKindForName('picture.png'), DownloadKind.photo); + expect(downloadKindForName('package.zip'), DownloadKind.file); + }); + + testWidgets('recent downloads show persisted metadata', (tester) async { + final directory = Directory.systemTemp.createTempSync( + 'synthetic_downloads_test', + ); + addTearDown(() { + DownloadHistory.resetForTesting(); + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + PathProviderPlatform.instance = _SyntheticPathProvider(directory.path); + SharedPreferences.setMockInitialValues({}); + + await tester.runAsync(() async { + DownloadHistory.resetForTesting(); + final file = await MediaCache.fileFor('synthetic-package.zip'); + await file.writeAsBytes(List.filled(2048, 1)); + await DownloadHistory.record( + const DownloadMetadata( + cacheName: 'synthetic-package.zip', + name: 'sample-package.zip', + kind: DownloadKind.file, + sourceName: 'Synthetic channel', + chatId: 77, + messageId: 'synthetic-message', + messageTime: 123456, + ), + file, + ); + DownloadHistory.resetForTesting(); + await DownloadHistory.load(); + }); + + final record = DownloadHistory.records.value.single; + expect(record.chatId, 77); + expect(record.messageId, 'synthetic-message'); + expect(record.messageTime, 123456); + + await tester.pumpWidget( + const MaterialApp( + locale: Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DownloadsScreen(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Недавние загрузки'), findsOneWidget); + expect(find.text('sample-package.zip'), findsOneWidget); + expect(find.text('2.0 КБ · Synthetic channel'), findsOneWidget); + expect(find.byKey(const ValueKey('downloads-settings')), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('download-more-synthetic-package.zip')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Перейти к сообщению'), findsOneWidget); + expect(find.text('Сохранить как…'), findsOneWidget); + }); +} diff --git a/test/media_viewer_video_test.dart b/test/media_viewer_video_test.dart index 665c5d1..eecfb3e 100644 --- a/test/media_viewer_video_test.dart +++ b/test/media_viewer_video_test.dart @@ -66,6 +66,7 @@ void main() { expect(find.text('00:12'), findsOneWidget); expect(find.byKey(const ValueKey('video-play-toggle')), findsOneWidget); expect(find.byKey(const ValueKey('video-settings')), findsOneWidget); + expect(find.byKey(const ValueKey('downloads-button')), findsNothing); expect(find.byIcon(Symbols.rotate_90_degrees_ccw), findsOneWidget); expect(find.byIcon(Symbols.download), findsNothing); expect(find.byType(GlassSurface), findsOneWidget);