Files
Qlyra/lib/frontend/screens/downloads_screen.dart
sevenhill b22654b99f
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Update Qlyra to 1.0.13 with messaging, media and plugin improvements
2026-09-10 17:55:55 +03:00

471 lines
15 KiB
Dart

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/media/audio_file_track.dart';
import '../../core/media/media_playback.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';
import '../widgets/sheet_helpers.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;
}
if (record.kind == DownloadKind.audio) {
try {
await MediaPlayback.instance.activateAudioFile(
AudioFileTrack(
cacheName: record.cacheName,
path: file.path,
name: record.name.trim().isEmpty
? AppLocalizations.of(context)!.downloadsAudio
: record.name.trim(),
sourceName: record.sourceName,
chatId: record.chatId,
messageId: record.messageId,
messageTime: record.messageTime,
thumbnailUrl: record.thumbnailUrl,
),
notificationChannelName: AppLocalizations.of(
context,
)!.audioPlaybackChannel,
);
} catch (_) {
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: kSheetShape,
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,
),
),
),
],
),
);
}
}