Add support for downloading media

Fixes #85
This commit is contained in:
Henry Hiles 2026-10-16 17:05:26 -04:00
commit 0ad85f24d5
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
15 changed files with 285 additions and 24 deletions

View file

@ -0,0 +1,34 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/show_error_snack_bar.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
extension SaveAttachment on BuildContext {
/// Downloads the media at [mxc], displaying a snackbar whilst it's donwnloading,
/// then lets the user pick where to save it, suggesting [filename].
Future<void> saveAttachment(
WidgetRef ref,
Uri mxc, {
required bool encrypted,
String? filename,
}) async {
final file = await withProgressSnackbar(
"Downloading file...",
() => ref
.read(ClientController.provider.notifier)
.downloadMedia(.new(mxc: mxc, encrypted: encrypted)),
);
if (file == null || !mounted) return;
final messenger = ScaffoldMessenger.of(this);
try {
await ref
.read(FileDialogHelper.provider)
.saveFile(file, filename ?? mxc.pathSegments.last);
} catch (error) {
messenger.showErrorSnackBar(error.toString());
}
}
}

View file

@ -0,0 +1,93 @@
import "dart:io";
import "package:file_selector/file_selector.dart";
import "package:flutter_file_dialog/flutter_file_dialog.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:mime/mime.dart";
import "package:nexus/controllers/image_picker.dart";
import "package:nexus/controllers/portal.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class FileDialogHelper(final Ref ref) {
Future<bool> saveFile(File file, String filename) async {
final mimeType = lookupMimeType(
file.path,
headerBytes: await file
.openRead(0, defaultMagicNumbersMaxLength)
.expand((bytes) => bytes)
.toList(),
);
final filenameWithExtension = mimeType == null
? filename
: "$filename.${extensionFromMime(mimeType)}";
if (Platform.isAndroid || Platform.isIOS) {
return await FlutterFileDialog.saveFile(
params: .new(
sourceFilePath: file.path,
fileName: filenameWithExtension,
),
) !=
null;
}
final destination = Platform.isLinux
? await getLinuxSaveLocation(filenameWithExtension)
: (await getSaveLocation(suggestedName: filenameWithExtension))?.path;
if (destination == null) return false;
await file.copy(destination);
return true;
}
Future<String?> getLinuxSaveLocation(String filename) async {
final portal = await ref.read(PortalController.provider.future);
try {
final result = await portal.fileChooser
.saveFile(title: "Save file", currentName: filename)
.first;
return switch (result.uris.firstOrNull) {
final uri? => Uri.parse(uri).toFilePath(),
null => null,
};
} on XdgPortalRequestCancelledException catch (_) {
return null;
}
}
/// Lets the user pick a file, or only images if [images] is true. Returns
/// null if the user cancelled.
Future<XFile?> pickFile({bool images = false}) async {
if (!Platform.isLinux) {
return images
? ref.read(ImagePickerController.provider).pickImage(source: .gallery)
: openFile();
}
final portal = await ref.read(PortalController.provider.future);
try {
final result = await portal.fileChooser
.openFile(
title: images ? "Pick image" : "Pick file",
filters: [
if (images)
.new("Images", [XdgFileChooserMimeTypePattern("image/*")]),
],
)
.first;
return switch (result.uris.firstOrNull) {
final uri? => XFile(Uri.parse(uri).toFilePath()),
null => null,
};
} on XdgPortalRequestCancelledException catch (_) {
return null;
}
}
static final provider = Provider<FileDialogHelper>(FileDialogHelper.new);
}