feat: загрузки
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "История загрузок очищена"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user