feat: загрузки

This commit is contained in:
Jganenokk
2026-07-27 18:11:38 +07:00
parent 92c0a90367
commit 1a043f033d
18 changed files with 1566 additions and 102 deletions
+338
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<List<DownloadRecord>> records = ValueNotifier(
const [],
);
static Future<void>? _loading;
static Future<void> _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<void> load() => _loading ??= _load();
static Future<void> 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<void> 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 = <DownloadRecord>[
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<void> 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<void> clear() => _enqueue(() async {
await load();
records.value = const [];
await _save();
});
static Future<File?> 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<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
List<DownloadRecord> 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<DownloadRecord> _decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
return decoded
.whereType<Map>()
.map(
(item) => DownloadRecord.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.cacheName.isNotEmpty)
.toList();
} catch (_) {
return const [];
}
}
static Future<List<DownloadRecord>> _available(
List<DownloadRecord> source,
) async {
final available = <DownloadRecord>[];
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<List<DownloadRecord>> _migrateCache() async {
final files = await MediaCache.files();
final migrated = <DownloadRecord>[];
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<void> _save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
jsonEncode(records.value.map((item) => item.toJson()).toList()),
);
}
static Future<void> _enqueue(Future<void> Function() operation) {
final next = _mutations.then((_) => operation());
_mutations = next.catchError((_) {});
return next;
}
}
+7
View File
@@ -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<FileDownloadResult> openCachedFile(
Future<String?> Function() resolveUrl, {
void Function(double progress)? onProgress,
void Function()? onReady,
DownloadMetadata? download,
}) async {
var readyFired = false;
void ready() {
@@ -52,6 +54,11 @@ Future<FileDownloadResult> 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,
+9
View File
@@ -42,6 +42,15 @@ class MediaCache {
return File(p.join(dir.path, _sanitize(name)));
}
static Future<List<File>> files() async {
final dir = await _cacheDir();
final files = <File>[];
await for (final entity in dir.list()) {
if (entity is File && !entity.path.endsWith('.part')) files.add(entity);
}
return files;
}
/// Существует ли непустой кэш-файл [name].
///
/// При попадании обновляет mtime файла — это делает вытеснение LRU
+9 -1
View File
@@ -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<MediaSaveResult> saveMediaFile({
required Future<String?> Function() resolveUrl,
required String saveName,
required SaveMediaKind kind,
DownloadMetadata? download,
}) async {
try {
var file = await MediaCache.existing(cacheName);
@@ -60,7 +62,13 @@ Future<MediaSaveResult> 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());
}
+58
View File
@@ -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<SaveFileAsResult> 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<File> _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;
}
+145 -54
View File
@@ -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<ChatListScreen>
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<int>(
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<int>(
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<ChatListScreen>
),
MessageDecryptionState.wrongKey => _buildPreviewLine(
cs,
'$previewPrefix' 'неверный ключ',
'$previewPrefix'
'неверный ключ',
const [],
draft,
true,
@@ -2971,6 +3005,63 @@ class _ChatListScreenState extends State<ChatListScreen>
}
}
Future<void> _openDownloads() async {
final record = await pushSwipeable<DownloadRecord>(
context,
(_) => const DownloadsScreen(),
);
if (record == null || !mounted) return;
await _openDownloadedMessage(record);
}
Future<void> _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) {
+440
View File
@@ -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<DownloadsScreen> createState() => _DownloadsScreenState();
}
class _DownloadsScreenState extends State<DownloadsScreen> {
late bool _loading = DownloadHistory.records.value.isEmpty;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (!_loading) {
DownloadHistory.refresh().ignore();
return;
}
await DownloadHistory.load();
if (mounted) setState(() => _loading = false);
await DownloadHistory.refresh();
}
Future<void> _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<void> _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<void> _settings() async {
final l10n = AppLocalizations.of(context)!;
final clear = await showModalBottomSheet<bool>(
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<List<DownloadRecord>>(
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?> _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<File?>(
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,
),
),
),
],
),
);
}
}
+9 -1
View File
@@ -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<AdaptiveShell> {
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,
),
@@ -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) {
@@ -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<void> _downloadAttachment(
BuildContext context,
SharedMediaItem item,
String sourceName,
) async {
final att = item.attachment;
final now = DateTime.now().millisecondsSinceEpoch;
@@ -243,6 +244,16 @@ Future<void> _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<void> _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<void> _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<void> _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<void> _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<SharedMediaTab>
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<SharedMediaTab>
(i) => _ProfileVoiceTile(
item: i,
senderName: _resolveName(i.senderId),
sourceName: widget.sourceName,
onGoTo: () => _goTo(i),
),
),
@@ -645,13 +695,12 @@ class _SharedMediaTabState extends State<SharedMediaTab>
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);
}),
]);
}
+105 -28
View File
@@ -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<PhotoViewerScreen> {
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<File?> _fileFor(PhotoAttachment photo) async {
final localPath = photo.localPath;
if (localPath != null) {
@@ -467,12 +506,25 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
return MediaCache.getOrDownload(_cacheNameFor(photo, url), url);
}
Future<File?> _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<void> _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<PhotoViewerScreen> {
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<PhotoViewerScreen> {
}
Future<void> _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) {
+17 -1
View File
@@ -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"
}
+96
View File
@@ -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
+49
View File
@@ -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';
}
+49
View File
@@ -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 => 'История загрузок очищена';
}
+17 -1
View File
@@ -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": "История загрузок очищена"
}